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()
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSessionSummaryRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, ".ctask", "last-session-summary.json")
|
||||
|
||||
start := time.Date(2026, 4, 21, 14, 30, 22, 0, time.UTC)
|
||||
end := start.Add(1*time.Hour + 14*time.Minute + 48*time.Second)
|
||||
|
||||
want := &SessionSummary{
|
||||
SessionID: "h-1-20260421143022",
|
||||
Hostname: "h",
|
||||
Agent: "claude",
|
||||
Mode: "local",
|
||||
StartedAt: start,
|
||||
EndedAt: end,
|
||||
DurationSeconds: int64(end.Sub(start).Seconds()),
|
||||
FilesAdded: []string{"output/migration-plan.md", "output/schema.sql"},
|
||||
FilesModified: []string{"notes.md", "CLAUDE.md"},
|
||||
FilesDeleted: []string{},
|
||||
NotesUpdated: true,
|
||||
EndManifest: []FileEntry{
|
||||
{Path: "notes.md", Size: 240, Mtime: end},
|
||||
{Path: "CLAUDE.md", Size: 300, Mtime: end},
|
||||
},
|
||||
}
|
||||
|
||||
if err := WriteSummary(path, want); err != nil {
|
||||
t.Fatalf("WriteSummary: %v", err)
|
||||
}
|
||||
|
||||
got, err := ReadSummary(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadSummary: %v", err)
|
||||
}
|
||||
if got.SessionID != want.SessionID {
|
||||
t.Errorf("SessionID: got %q, want %q", got.SessionID, want.SessionID)
|
||||
}
|
||||
if got.DurationSeconds != want.DurationSeconds {
|
||||
t.Errorf("DurationSeconds: got %d, want %d", got.DurationSeconds, want.DurationSeconds)
|
||||
}
|
||||
if len(got.FilesAdded) != 2 {
|
||||
t.Errorf("FilesAdded length: got %d, want 2", len(got.FilesAdded))
|
||||
}
|
||||
if !got.NotesUpdated {
|
||||
t.Error("NotesUpdated: got false, want true")
|
||||
}
|
||||
if len(got.EndManifest) != 2 {
|
||||
t.Errorf("EndManifest length: got %d, want 2", len(got.EndManifest))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummaryJSONFields(t *testing.T) {
|
||||
s := &SessionSummary{
|
||||
FilesAdded: []string{"a"},
|
||||
FilesModified: []string{},
|
||||
FilesDeleted: []string{},
|
||||
EndManifest: []FileEntry{},
|
||||
}
|
||||
data, _ := json.Marshal(s)
|
||||
for _, want := range []string{
|
||||
`"session_id"`, `"hostname"`, `"agent"`, `"mode"`,
|
||||
`"started_at"`, `"ended_at"`, `"duration_seconds"`,
|
||||
`"files_added"`, `"files_modified"`, `"files_deleted"`,
|
||||
`"notes_updated"`, `"end_manifest"`,
|
||||
} {
|
||||
if !strings.Contains(string(data), want) {
|
||||
t.Errorf("summary JSON missing %s: %s", want, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSummaryMissingFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, ".ctask", "last-session-summary.json")
|
||||
|
||||
got, err := ReadSummary(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadSummary on missing file should return (nil, nil), got err=%v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Errorf("expected nil summary for missing file, got %+v", got)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("expected missing file, got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSummaryCorruptReturnsError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, ".ctask", "last-session-summary.json")
|
||||
os.MkdirAll(filepath.Dir(path), 0755)
|
||||
os.WriteFile(path, []byte("{not json"), 0644)
|
||||
|
||||
if _, err := ReadSummary(path); err == nil {
|
||||
t.Error("expected error reading corrupt summary")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user