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:
2026-04-06 09:56:21 -04:00
parent 57f345ae2b
commit 10ab9efc80
7 changed files with 435 additions and 32 deletions
+13 -14
View File
@@ -5,14 +5,15 @@ import (
"github.com/spf13/cobra"
"github.com/warrenronsiek/ctask/internal/config"
"github.com/warrenronsiek/ctask/internal/session"
"github.com/warrenronsiek/ctask/internal/shell"
"github.com/warrenronsiek/ctask/internal/workspace"
)
var newCmd = &cobra.Command{
Use: "new [title]",
Short: "Create a new task workspace and launch the agent",
Long: "Create a new task workspace and launch the agent. If title is omitted, generates task-HHMMSS.",
Use: "new [title]",
Short: "Create a new task workspace and launch the agent",
Long: "Create a new task workspace and launch the agent. If title is omitted, generates task-HHMMSS.",
Args: cobra.MaximumNArgs(1),
SilenceUsage: true,
RunE: runNew,
@@ -28,7 +29,7 @@ var (
func init() {
newCmd.Flags().StringVarP(&newCategory, "category", "c", "general", "Workspace category subdirectory")
newCmd.Flags().BoolVar(&newContainer, "container", false, "Launch in container sandbox (v0.2)")
newCmd.Flags().BoolVar(&newContainer, "container", false, "Launch in container sandbox (deferred)")
newCmd.Flags().BoolVar(&newShell, "shell", false, "Open interactive shell instead of agent")
newCmd.Flags().StringVarP(&newAgent, "agent", "a", "", "Command to exec as the agent")
newCmd.Flags().BoolVar(&newNoLaunch, "no-launch", false, "Create workspace only, do not launch")
@@ -72,14 +73,12 @@ func runNew(cmd *cobra.Command, args []string) error {
envVars := config.EnvVars(ws.Meta.Slug, ws.Meta.Mode, root, ws.Path, ws.Meta.Category)
if newShell {
return shell.ExecShell(ws.Path, envVars, ws.Meta.Slug, ws.Meta.Mode)
}
// Agent mode: print banner and exec
for _, line := range shell.BannerLines(ws.Meta.Mode, ws.Meta.Slug, ws.Path) {
fmt.Println(line)
}
return shell.ExecAgent(agent, ws.Path, envVars)
return session.Run(session.LaunchOpts{
WsDir: ws.Path,
EnvVars: envVars,
Agent: agent,
Mode: ws.Meta.Mode,
Slug: ws.Meta.Slug,
Shell: newShell,
})
}
+9 -3
View File
@@ -7,7 +7,7 @@ import (
"github.com/spf13/cobra"
"github.com/warrenronsiek/ctask/internal/config"
"github.com/warrenronsiek/ctask/internal/shell"
"github.com/warrenronsiek/ctask/internal/session"
"github.com/warrenronsiek/ctask/internal/workspace"
)
@@ -40,6 +40,12 @@ func runOpen(cmd *cobra.Command, args []string) error {
envVars := config.EnvVars(ws.Meta.Slug, ws.Meta.Mode, root, ws.Path, ws.Meta.Category)
// Spawn interactive subshell (not agent, not cd in parent)
return shell.ExecShell(ws.Path, envVars, ws.Meta.Slug, ws.Meta.Mode)
return session.Run(session.LaunchOpts{
WsDir: ws.Path,
EnvVars: envVars,
Agent: ws.Meta.Agent,
Mode: ws.Meta.Mode,
Slug: ws.Meta.Slug,
Shell: true, // open always launches shell
})
}
+18 -14
View File
@@ -7,6 +7,7 @@ import (
"github.com/spf13/cobra"
"github.com/warrenronsiek/ctask/internal/config"
"github.com/warrenronsiek/ctask/internal/session"
"github.com/warrenronsiek/ctask/internal/shell"
"github.com/warrenronsiek/ctask/internal/workspace"
)
@@ -26,20 +27,25 @@ var (
)
func init() {
resumeCmd.Flags().BoolVar(&resumeContainer, "container", false, "Resume in container mode (v0.2)")
resumeCmd.Flags().BoolVar(&resumeContainer, "container", false, "Resume in container mode (deferred)")
resumeCmd.Flags().BoolVar(&resumeShell, "shell", false, "Open shell instead of agent")
resumeCmd.Flags().StringVarP(&resumeAgent, "agent", "a", "", "Override agent command")
rootCmd.AddCommand(resumeCmd)
}
func runResume(cmd *cobra.Command, args []string) error {
if resumeContainer {
return doResume(args[0], resumeContainer, resumeShell, resumeAgent)
}
// doResume is the shared resume logic used by both resume and last commands.
func doResume(query string, container, useShell bool, agentOverride string) error {
if container {
fmt.Println(shell.ContainerNotice())
return nil
}
root := config.ResolveRoot()
ws := resolveOne(root, args[0], false)
ws := resolveOne(root, query, false)
// Update updated_at
now := time.Now().UTC().Truncate(time.Second)
@@ -49,21 +55,19 @@ func runResume(cmd *cobra.Command, args []string) error {
return fmt.Errorf("updating metadata: %w", err)
}
agent := resumeAgent
agent := agentOverride
if agent == "" {
agent = ws.Meta.Agent
}
envVars := config.EnvVars(ws.Meta.Slug, ws.Meta.Mode, root, ws.Path, ws.Meta.Category)
if resumeShell {
return shell.ExecShell(ws.Path, envVars, ws.Meta.Slug, ws.Meta.Mode)
}
// Agent mode
for _, line := range shell.BannerLines(ws.Meta.Mode, ws.Meta.Slug, ws.Path) {
fmt.Println(line)
}
return shell.ExecAgent(agent, ws.Path, envVars)
return session.Run(session.LaunchOpts{
WsDir: ws.Path,
EnvVars: envVars,
Agent: agent,
Mode: ws.Meta.Mode,
Slug: ws.Meta.Slug,
Shell: useShell,
})
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"github.com/spf13/cobra"
)
var version = "0.1.0"
var version = "0.2.0"
var rootCmd = &cobra.Command{
Use: "ctask",
+122
View File
@@ -0,0 +1,122 @@
package session
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// SessionInfo holds data for a session log entry.
type SessionInfo struct {
Agent string
Mode string
StartTime time.Time
EndTime time.Time
Diff *ManifestDiff
}
// FormatDuration returns a human-readable duration string like "1h 14m 48s".
func FormatDuration(d time.Duration) string {
h := int(d.Hours())
m := int(d.Minutes()) % 60
s := int(d.Seconds()) % 60
if h > 0 {
return fmt.Sprintf("%dh %dm %ds", h, m, s)
}
if m > 0 {
return fmt.Sprintf("%dm %ds", m, s)
}
return fmt.Sprintf("%ds", s)
}
// FormatSessionEntry formats a session log entry as a human-readable string.
func FormatSessionEntry(info *SessionInfo) string {
var b strings.Builder
duration := info.EndTime.Sub(info.StartTime)
timeFmt := "2006-01-02 15:04:05"
fmt.Fprintf(&b, "── Session %s ──\n", info.StartTime.Format(timeFmt))
fmt.Fprintf(&b, "Agent: %s\n", info.Agent)
fmt.Fprintf(&b, "Mode: %s\n", info.Mode)
fmt.Fprintf(&b, "Start: %s\n", info.StartTime.Format(timeFmt))
fmt.Fprintf(&b, "End: %s\n", info.EndTime.Format(timeFmt))
fmt.Fprintf(&b, "Duration: %s\n", FormatDuration(duration))
b.WriteString("\nAdded:\n")
if len(info.Diff.Added) > 0 {
sorted := make([]string, len(info.Diff.Added))
copy(sorted, info.Diff.Added)
sort.Strings(sorted)
for _, f := range sorted {
fmt.Fprintf(&b, " %s\n", f)
}
} else {
b.WriteString(" (none)\n")
}
b.WriteString("\nModified:\n")
if len(info.Diff.Modified) > 0 {
sorted := make([]string, len(info.Diff.Modified))
copy(sorted, info.Diff.Modified)
sort.Strings(sorted)
for _, f := range sorted {
fmt.Fprintf(&b, " %s\n", f)
}
} else {
b.WriteString(" (none)\n")
}
b.WriteString("\nDeleted:\n")
if len(info.Diff.Deleted) > 0 {
sorted := make([]string, len(info.Diff.Deleted))
copy(sorted, info.Diff.Deleted)
sort.Strings(sorted)
for _, f := range sorted {
fmt.Fprintf(&b, " %s\n", f)
}
} else {
b.WriteString(" (none)\n")
}
notesChanged := NotesUpdated(info.Diff)
if notesChanged {
b.WriteString("\nNotes updated: yes\n")
} else {
b.WriteString("\nNotes updated: no\n")
}
// Short session with no changes note
noChanges := len(info.Diff.Added) == 0 && len(info.Diff.Modified) == 0 && len(info.Diff.Deleted) == 0
if duration < 5*time.Second && noChanges {
b.WriteString("(No changes detected)\n")
}
b.WriteString("────────────────────────────────\n")
return b.String()
}
// AppendSessionLog appends a session entry to logs/sessions.log.
func AppendSessionLog(wsDir string, info *SessionInfo) error {
logDir := filepath.Join(wsDir, "logs")
if err := os.MkdirAll(logDir, 0755); err != nil {
return err
}
logPath := filepath.Join(logDir, "sessions.log")
entry := FormatSessionEntry(info)
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer f.Close()
_, err = f.WriteString("\n" + entry)
return err
}
+171
View File
@@ -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
}
+101
View File
@@ -0,0 +1,101 @@
package session
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/warrenronsiek/ctask/internal/shell"
)
// LaunchOpts configures a session launch.
type LaunchOpts struct {
WsDir string
EnvVars map[string]string
Agent string
Mode string
Slug string
Shell bool // true = interactive shell, false = agent
}
// manifestStartPath returns the path to the start manifest file.
func manifestStartPath(wsDir string) string {
return filepath.Join(wsDir, ".ctask", "manifest-start.json")
}
// Run launches an agent or shell session with pre/post manifest capture and session logging.
// Returns the child process error (for exit code propagation).
func Run(opts LaunchOpts) error {
startTime := time.Now().UTC().Truncate(time.Second)
// Pre-session: capture start manifest
startManifest, err := CaptureManifest(opts.WsDir)
if err != nil {
fmt.Fprintf(os.Stderr, "[ctask] warning: failed to capture start manifest: %v\n", err)
// Continue anyway — never block the user
}
if startManifest != nil {
mPath := manifestStartPath(opts.WsDir)
if err := WriteManifest(mPath, startManifest); err != nil {
fmt.Fprintf(os.Stderr, "[ctask] warning: failed to write start manifest: %v\n", err)
}
}
// Launch the session
var childErr error
if opts.Shell {
childErr = shell.ExecShell(opts.WsDir, opts.EnvVars, opts.Slug, opts.Mode)
} else {
// Print banner before agent launch
for _, line := range shell.BannerLines(opts.Mode, opts.Slug, opts.WsDir) {
fmt.Println(line)
}
childErr = shell.ExecAgent(opts.Agent, opts.WsDir, opts.EnvVars)
}
// Post-session: capture end manifest and log
endTime := time.Now().UTC().Truncate(time.Second)
if startManifest != nil {
if logErr := captureAndLog(opts, startManifest, startTime, endTime); logErr != nil {
fmt.Fprintf(os.Stderr, "[ctask] warning: session logging failed: %v\n", logErr)
// Keep manifest-start.json for debugging
} else {
// Clean up manifest-start.json on success
os.Remove(manifestStartPath(opts.WsDir))
}
}
return childErr
}
// captureAndLog captures the end manifest, diffs, and appends to session log.
func captureAndLog(opts LaunchOpts, startManifest *Manifest, startTime, endTime time.Time) error {
endManifest, err := CaptureManifest(opts.WsDir)
if err != nil {
return fmt.Errorf("capturing end manifest: %w", err)
}
diff := DiffManifests(startManifest, endManifest)
info := &SessionInfo{
Agent: opts.Agent,
Mode: opts.Mode,
StartTime: startTime,
EndTime: endTime,
Diff: diff,
}
// For shell sessions, record "shell" as agent
if opts.Shell {
info.Agent = "shell"
}
if err := AppendSessionLog(opts.WsDir, info); err != nil {
return fmt.Errorf("appending session log: %w", err)
}
return nil
}