168 lines
5.2 KiB
Go
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()
|
|
}
|