feat: session lifecycle wrapper with manifest capture and session logging
Refactor new/resume/open to use session.Run() which wraps child process launch with pre/post manifest capture and append-only session logging to logs/sessions.log. Bump version to 0.2.0. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FileEntry represents a single file in the workspace manifest.
|
||||
type FileEntry struct {
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
Mtime time.Time `json:"mtime"`
|
||||
}
|
||||
|
||||
// Manifest represents a point-in-time snapshot of workspace files.
|
||||
type Manifest struct {
|
||||
CapturedAt time.Time `json:"captured_at"`
|
||||
Files []FileEntry `json:"files"`
|
||||
}
|
||||
|
||||
// ignoredPaths returns true for paths that should be excluded from manifest diffing.
|
||||
func ignoredPath(relPath string) bool {
|
||||
// Ignore .ctask/ directory contents
|
||||
if strings.HasPrefix(relPath, ".ctask/") || strings.HasPrefix(relPath, ".ctask\\") {
|
||||
return true
|
||||
}
|
||||
// Ignore task.yaml (ctask updates this itself)
|
||||
if relPath == "task.yaml" {
|
||||
return true
|
||||
}
|
||||
// Ignore sessions.log (ctask's own logging)
|
||||
if relPath == filepath.Join("logs", "sessions.log") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CaptureManifest walks the workspace directory and captures file metadata.
|
||||
func CaptureManifest(wsDir string) (*Manifest, error) {
|
||||
m := &Manifest{
|
||||
CapturedAt: time.Now().UTC().Truncate(time.Second),
|
||||
}
|
||||
|
||||
err := filepath.Walk(wsDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil // skip files we can't stat
|
||||
}
|
||||
if info.IsDir() {
|
||||
// Skip .ctask directory entirely
|
||||
rel, _ := filepath.Rel(wsDir, path)
|
||||
if rel == ".ctask" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(wsDir, path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
// Normalize to forward slashes for consistency
|
||||
rel = filepath.ToSlash(rel)
|
||||
|
||||
m.Files = append(m.Files, FileEntry{
|
||||
Path: rel,
|
||||
Size: info.Size(),
|
||||
Mtime: info.ModTime().UTC().Truncate(time.Second),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// WriteManifest writes a manifest to a JSON file.
|
||||
func WriteManifest(path string, m *Manifest) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0644)
|
||||
}
|
||||
|
||||
// ReadManifest reads a manifest from a JSON file.
|
||||
func ReadManifest(path string) (*Manifest, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m Manifest
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// ManifestDiff represents the differences between two manifests.
|
||||
type ManifestDiff struct {
|
||||
Added []string // files only in end manifest
|
||||
Modified []string // files in both, but size or mtime differ
|
||||
Deleted []string // files only in start manifest
|
||||
}
|
||||
|
||||
// DiffManifests compares start and end manifests, applying ignore rules.
|
||||
func DiffManifests(start, end *Manifest) *ManifestDiff {
|
||||
diff := &ManifestDiff{}
|
||||
|
||||
startMap := make(map[string]FileEntry)
|
||||
for _, f := range start.Files {
|
||||
if !ignoredPath(f.Path) {
|
||||
startMap[f.Path] = f
|
||||
}
|
||||
}
|
||||
|
||||
endMap := make(map[string]FileEntry)
|
||||
for _, f := range end.Files {
|
||||
if !ignoredPath(f.Path) {
|
||||
endMap[f.Path] = f
|
||||
}
|
||||
}
|
||||
|
||||
// Added: in end but not start
|
||||
for path := range endMap {
|
||||
if _, ok := startMap[path]; !ok {
|
||||
diff.Added = append(diff.Added, path)
|
||||
}
|
||||
}
|
||||
|
||||
// Modified: in both but different size or mtime
|
||||
for path, endEntry := range endMap {
|
||||
if startEntry, ok := startMap[path]; ok {
|
||||
if endEntry.Size != startEntry.Size || !endEntry.Mtime.Equal(startEntry.Mtime) {
|
||||
diff.Modified = append(diff.Modified, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deleted: in start but not end
|
||||
for path := range startMap {
|
||||
if _, ok := endMap[path]; !ok {
|
||||
diff.Deleted = append(diff.Deleted, path)
|
||||
}
|
||||
}
|
||||
|
||||
return diff
|
||||
}
|
||||
|
||||
// NotesUpdated checks if notes.md was modified between manifests.
|
||||
func NotesUpdated(diff *ManifestDiff) bool {
|
||||
for _, f := range diff.Modified {
|
||||
if f == "notes.md" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, f := range diff.Added {
|
||||
if f == "notes.md" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user