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:
+13
-14
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user