feat(snapshot): capture foreground process restore metadata
- commit
- 755f4f5947d6526c0c647e52685537afe9904828
- author
- brice · · 2026-07-06 07:54 UTC
- parent
- 01179be
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +593 -14
M boundary_test.go +5 -0
| @@ -29,6 +29,11 @@ func TestImportBoundaries(t *testing.T) { | |||
| 29 | 29 | root: "internal/usecase", | |
| 30 | 30 | disallowedPrefix: "github.com/bnema/vev/internal/adapters", | |
| 31 | 31 | }, | |
| 32 | + | { | |
| 33 | + | name: "usecase must not import platform", | |
| 34 | + | root: "internal/usecase", | |
| 35 | + | disallowedPrefix: "github.com/bnema/vev/internal/platform", | |
| 36 | + | }, | |
| 32 | 37 | } | |
| 33 | 38 | ||
| 34 | 39 | for _, rule := range rules { | |
| @@ -29,6 +29,11 @@ func TestImportBoundaries(t *testing.T) { | |||||
| 29 | root: "internal/usecase", | 29 | root: "internal/usecase", | ||
| 30 | disallowedPrefix: "github.com/bnema/vev/internal/adapters", | 30 | disallowedPrefix: "github.com/bnema/vev/internal/adapters", | ||
| 31 | }, | 31 | }, | ||
| 32 | + | { | |||
| 33 | + | name: "usecase must not import platform", | |||
| 34 | + | root: "internal/usecase", | |||
| 35 | + | disallowedPrefix: "github.com/bnema/vev/internal/platform", | |||
| 36 | + | }, | |||
| 32 | } | 37 | } | ||
| 33 | 38 | ||||
| 34 | for _, rule := range rules { | 39 | for _, rule := range rules { | ||
M internal/app/run.go +1 -0
| @@ -284,6 +284,7 @@ func runDaemon() error { | |||
| 284 | 284 | } | |
| 285 | 285 | logConfigWarnings(log, warnings) | |
| 286 | 286 | daemonOpts = append(daemonOpts, daemon.WithConfig(cfg)) | |
| 287 | + | daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.ProcessInspector{}), daemon.WithDirOrHome(platform.DirOrHome)) | |
| 287 | 288 | daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir()))) | |
| 288 | 289 | storePath := persist.StorePath(platform.StateDir()) | |
| 289 | 290 | if store, err := kv.Open(storePath); err != nil { | |
| @@ -284,6 +284,7 @@ func runDaemon() error { | |||||
| 284 | } | 284 | } | ||
| 285 | logConfigWarnings(log, warnings) | 285 | logConfigWarnings(log, warnings) | ||
| 286 | daemonOpts = append(daemonOpts, daemon.WithConfig(cfg)) | 286 | daemonOpts = append(daemonOpts, daemon.WithConfig(cfg)) | ||
| 287 | + | daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.ProcessInspector{}), daemon.WithDirOrHome(platform.DirOrHome)) | |||
| 287 | daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir()))) | 288 | daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir()))) | ||
| 288 | storePath := persist.StorePath(platform.StateDir()) | 289 | storePath := persist.StorePath(platform.StateDir()) | ||
| 289 | if store, err := kv.Open(storePath); err != nil { | 290 | if store, err := kv.Open(storePath); err != nil { | ||
A internal/platform/process_linux.go +117 -0
| @@ -0,0 +1,117 @@ | |||
| 1 | + | package platform | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "sort" | |
| 9 | + | "strconv" | |
| 10 | + | "strings" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // ProcessInspector implements ports.ProcessInspector using Linux /proc. | |
| 14 | + | type ProcessInspector struct{} | |
| 15 | + | ||
| 16 | + | func (ProcessInspector) Cwd(pid int) (string, error) { return ProcessCwd(pid) } | |
| 17 | + | func (ProcessInspector) Comm(pid int) (string, error) { return ProcessComm(pid) } | |
| 18 | + | func (ProcessInspector) Argv(pid int) ([]string, error) { return ProcessArgv(pid) } | |
| 19 | + | func (ProcessInspector) GroupArgv(pgid int, shellPid int) ([]string, error) { | |
| 20 | + | return ProcessGroupArgv(pgid, shellPid) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // ProcessArgv returns argv from /proc/<pid>/cmdline. | |
| 24 | + | func ProcessArgv(pid int) ([]string, error) { | |
| 25 | + | return processArgv(filepath.Join("/proc", strconv.Itoa(pid), "cmdline"), pid) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func processArgv(path string, pid int) ([]string, error) { | |
| 29 | + | data, err := os.ReadFile(path) | |
| 30 | + | if err != nil { | |
| 31 | + | return nil, err | |
| 32 | + | } | |
| 33 | + | data = bytes.TrimRight(data, "\x00") | |
| 34 | + | if len(data) == 0 { | |
| 35 | + | return nil, fmt.Errorf("process argv: empty argv for pid %d", pid) | |
| 36 | + | } | |
| 37 | + | parts := bytes.Split(data, []byte{0}) | |
| 38 | + | argv := make([]string, 0, len(parts)) | |
| 39 | + | for _, part := range parts { | |
| 40 | + | if len(part) > 0 { | |
| 41 | + | argv = append(argv, string(part)) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | if len(argv) == 0 { | |
| 45 | + | return nil, fmt.Errorf("process argv: empty argv for pid %d", pid) | |
| 46 | + | } | |
| 47 | + | return argv, nil | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | type processRecord struct{ pid, pgrp int } | |
| 51 | + | ||
| 52 | + | // ProcessGroupArgv returns argv for a restorable foreground process in pgid. | |
| 53 | + | // A bare pane shell (the only process in its foreground group) returns nil argv. | |
| 54 | + | func ProcessGroupArgv(pgid int, shellPid int) ([]string, error) { | |
| 55 | + | recs, err := readProcRecords("/proc") | |
| 56 | + | if err != nil { | |
| 57 | + | return nil, err | |
| 58 | + | } | |
| 59 | + | pid, ok := selectProcessGroupPID(recs, pgid, shellPid) | |
| 60 | + | if !ok { | |
| 61 | + | return nil, nil | |
| 62 | + | } | |
| 63 | + | return ProcessArgv(pid) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func selectProcessGroupPID(recs []processRecord, pgid int, shellPid int) (int, bool) { | |
| 67 | + | if pgid <= 0 { | |
| 68 | + | return 0, false | |
| 69 | + | } | |
| 70 | + | matches := make([]int, 0) | |
| 71 | + | for _, r := range recs { | |
| 72 | + | if r.pgrp == pgid && r.pid != shellPid { | |
| 73 | + | matches = append(matches, r.pid) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | if len(matches) == 0 { | |
| 77 | + | return 0, false | |
| 78 | + | } | |
| 79 | + | sort.Ints(matches) | |
| 80 | + | return matches[0], true | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func readProcRecords(root string) ([]processRecord, error) { | |
| 84 | + | ents, err := os.ReadDir(root) | |
| 85 | + | if err != nil { | |
| 86 | + | return nil, err | |
| 87 | + | } | |
| 88 | + | var recs []processRecord | |
| 89 | + | for _, ent := range ents { | |
| 90 | + | pid, err := strconv.Atoi(ent.Name()) | |
| 91 | + | if err != nil { | |
| 92 | + | continue | |
| 93 | + | } | |
| 94 | + | data, err := os.ReadFile(filepath.Join(root, ent.Name(), "stat")) | |
| 95 | + | if err != nil { | |
| 96 | + | continue | |
| 97 | + | } | |
| 98 | + | pgrp, err := parseStatPgrp(string(data)) | |
| 99 | + | if err != nil { | |
| 100 | + | continue | |
| 101 | + | } | |
| 102 | + | recs = append(recs, processRecord{pid: pid, pgrp: pgrp}) | |
| 103 | + | } | |
| 104 | + | return recs, nil | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func parseStatPgrp(stat string) (int, error) { | |
| 108 | + | end := strings.LastIndex(stat, ")") | |
| 109 | + | if end < 0 || end+2 >= len(stat) { | |
| 110 | + | return 0, fmt.Errorf("invalid stat") | |
| 111 | + | } | |
| 112 | + | fields := strings.Fields(stat[end+2:]) | |
| 113 | + | if len(fields) < 3 { | |
| 114 | + | return 0, fmt.Errorf("invalid stat") | |
| 115 | + | } | |
| 116 | + | return strconv.Atoi(fields[2]) | |
| 117 | + | } | |
| @@ -0,0 +1,117 @@ | |||||
| 1 | + | package platform | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "sort" | |||
| 9 | + | "strconv" | |||
| 10 | + | "strings" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // ProcessInspector implements ports.ProcessInspector using Linux /proc. | |||
| 14 | + | type ProcessInspector struct{} | |||
| 15 | + | ||||
| 16 | + | func (ProcessInspector) Cwd(pid int) (string, error) { return ProcessCwd(pid) } | |||
| 17 | + | func (ProcessInspector) Comm(pid int) (string, error) { return ProcessComm(pid) } | |||
| 18 | + | func (ProcessInspector) Argv(pid int) ([]string, error) { return ProcessArgv(pid) } | |||
| 19 | + | func (ProcessInspector) GroupArgv(pgid int, shellPid int) ([]string, error) { | |||
| 20 | + | return ProcessGroupArgv(pgid, shellPid) | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // ProcessArgv returns argv from /proc/<pid>/cmdline. | |||
| 24 | + | func ProcessArgv(pid int) ([]string, error) { | |||
| 25 | + | return processArgv(filepath.Join("/proc", strconv.Itoa(pid), "cmdline"), pid) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func processArgv(path string, pid int) ([]string, error) { | |||
| 29 | + | data, err := os.ReadFile(path) | |||
| 30 | + | if err != nil { | |||
| 31 | + | return nil, err | |||
| 32 | + | } | |||
| 33 | + | data = bytes.TrimRight(data, "\x00") | |||
| 34 | + | if len(data) == 0 { | |||
| 35 | + | return nil, fmt.Errorf("process argv: empty argv for pid %d", pid) | |||
| 36 | + | } | |||
| 37 | + | parts := bytes.Split(data, []byte{0}) | |||
| 38 | + | argv := make([]string, 0, len(parts)) | |||
| 39 | + | for _, part := range parts { | |||
| 40 | + | if len(part) > 0 { | |||
| 41 | + | argv = append(argv, string(part)) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | if len(argv) == 0 { | |||
| 45 | + | return nil, fmt.Errorf("process argv: empty argv for pid %d", pid) | |||
| 46 | + | } | |||
| 47 | + | return argv, nil | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | type processRecord struct{ pid, pgrp int } | |||
| 51 | + | ||||
| 52 | + | // ProcessGroupArgv returns argv for a restorable foreground process in pgid. | |||
| 53 | + | // A bare pane shell (the only process in its foreground group) returns nil argv. | |||
| 54 | + | func ProcessGroupArgv(pgid int, shellPid int) ([]string, error) { | |||
| 55 | + | recs, err := readProcRecords("/proc") | |||
| 56 | + | if err != nil { | |||
| 57 | + | return nil, err | |||
| 58 | + | } | |||
| 59 | + | pid, ok := selectProcessGroupPID(recs, pgid, shellPid) | |||
| 60 | + | if !ok { | |||
| 61 | + | return nil, nil | |||
| 62 | + | } | |||
| 63 | + | return ProcessArgv(pid) | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func selectProcessGroupPID(recs []processRecord, pgid int, shellPid int) (int, bool) { | |||
| 67 | + | if pgid <= 0 { | |||
| 68 | + | return 0, false | |||
| 69 | + | } | |||
| 70 | + | matches := make([]int, 0) | |||
| 71 | + | for _, r := range recs { | |||
| 72 | + | if r.pgrp == pgid && r.pid != shellPid { | |||
| 73 | + | matches = append(matches, r.pid) | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | if len(matches) == 0 { | |||
| 77 | + | return 0, false | |||
| 78 | + | } | |||
| 79 | + | sort.Ints(matches) | |||
| 80 | + | return matches[0], true | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func readProcRecords(root string) ([]processRecord, error) { | |||
| 84 | + | ents, err := os.ReadDir(root) | |||
| 85 | + | if err != nil { | |||
| 86 | + | return nil, err | |||
| 87 | + | } | |||
| 88 | + | var recs []processRecord | |||
| 89 | + | for _, ent := range ents { | |||
| 90 | + | pid, err := strconv.Atoi(ent.Name()) | |||
| 91 | + | if err != nil { | |||
| 92 | + | continue | |||
| 93 | + | } | |||
| 94 | + | data, err := os.ReadFile(filepath.Join(root, ent.Name(), "stat")) | |||
| 95 | + | if err != nil { | |||
| 96 | + | continue | |||
| 97 | + | } | |||
| 98 | + | pgrp, err := parseStatPgrp(string(data)) | |||
| 99 | + | if err != nil { | |||
| 100 | + | continue | |||
| 101 | + | } | |||
| 102 | + | recs = append(recs, processRecord{pid: pid, pgrp: pgrp}) | |||
| 103 | + | } | |||
| 104 | + | return recs, nil | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func parseStatPgrp(stat string) (int, error) { | |||
| 108 | + | end := strings.LastIndex(stat, ")") | |||
| 109 | + | if end < 0 || end+2 >= len(stat) { | |||
| 110 | + | return 0, fmt.Errorf("invalid stat") | |||
| 111 | + | } | |||
| 112 | + | fields := strings.Fields(stat[end+2:]) | |||
| 113 | + | if len(fields) < 3 { | |||
| 114 | + | return 0, fmt.Errorf("invalid stat") | |||
| 115 | + | } | |||
| 116 | + | return strconv.Atoi(fields[2]) | |||
| 117 | + | } | |||
A internal/platform/process_linux_test.go +66 -0
| @@ -0,0 +1,66 @@ | |||
| 1 | + | package platform | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "reflect" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestProcessArgv(t *testing.T) { | |
| 11 | + | argv, err := ProcessArgv(os.Getpid()) | |
| 12 | + | if err != nil { | |
| 13 | + | t.Fatalf("ProcessArgv current process: %v", err) | |
| 14 | + | } | |
| 15 | + | if len(argv) == 0 { | |
| 16 | + | t.Fatal("ProcessArgv returned empty argv") | |
| 17 | + | } | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func TestProcessArgvRejectsEmptyCmdline(t *testing.T) { | |
| 21 | + | path := filepath.Join(t.TempDir(), "cmdline") | |
| 22 | + | if err := os.WriteFile(path, []byte("\x00\x00"), 0o600); err != nil { | |
| 23 | + | t.Fatal(err) | |
| 24 | + | } | |
| 25 | + | if argv, err := processArgv(path, 123); err == nil || argv != nil { | |
| 26 | + | t.Fatalf("processArgv empty = %#v, %v; want nil error", argv, err) | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestProcessGroupArgvSelection(t *testing.T) { | |
| 31 | + | recs := []processRecord{{pid: 10, pgrp: 7}, {pid: 12, pgrp: 7}, {pid: 20, pgrp: 9}} | |
| 32 | + | pid, ok := selectProcessGroupPID(recs, 7, 10) | |
| 33 | + | if !ok || pid != 12 { | |
| 34 | + | t.Fatalf("selectProcessGroupPID = %d, %v; want 12, true", pid, ok) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestProcessGroupArgvBareShellReturnsNoPID(t *testing.T) { | |
| 39 | + | recs := []processRecord{{pid: 10, pgrp: 7}, {pid: 20, pgrp: 9}} | |
| 40 | + | pid, ok := selectProcessGroupPID(recs, 7, 10) | |
| 41 | + | if ok || pid != 0 { | |
| 42 | + | t.Fatalf("selectProcessGroupPID bare shell = %d, %v; want 0, false", pid, ok) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestParseStatPgrp(t *testing.T) { | |
| 47 | + | got, err := parseStatPgrp("123 (cmd with spaces) S 1 456 456 0") | |
| 48 | + | if err != nil || got != 456 { | |
| 49 | + | t.Fatalf("parseStatPgrp = %d, %v; want 456, nil", got, err) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestProcessArgvFileParsing(t *testing.T) { | |
| 54 | + | path := filepath.Join(t.TempDir(), "cmdline") | |
| 55 | + | if err := os.WriteFile(path, []byte("cmd\x00two words\x00"), 0o600); err != nil { | |
| 56 | + | t.Fatal(err) | |
| 57 | + | } | |
| 58 | + | got, err := processArgv(path, 123) | |
| 59 | + | if err != nil { | |
| 60 | + | t.Fatal(err) | |
| 61 | + | } | |
| 62 | + | want := []string{"cmd", "two words"} | |
| 63 | + | if !reflect.DeepEqual(got, want) { | |
| 64 | + | t.Fatalf("processArgv = %#v; want %#v", got, want) | |
| 65 | + | } | |
| 66 | + | } | |
| @@ -0,0 +1,66 @@ | |||||
| 1 | + | package platform | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "reflect" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestProcessArgv(t *testing.T) { | |||
| 11 | + | argv, err := ProcessArgv(os.Getpid()) | |||
| 12 | + | if err != nil { | |||
| 13 | + | t.Fatalf("ProcessArgv current process: %v", err) | |||
| 14 | + | } | |||
| 15 | + | if len(argv) == 0 { | |||
| 16 | + | t.Fatal("ProcessArgv returned empty argv") | |||
| 17 | + | } | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func TestProcessArgvRejectsEmptyCmdline(t *testing.T) { | |||
| 21 | + | path := filepath.Join(t.TempDir(), "cmdline") | |||
| 22 | + | if err := os.WriteFile(path, []byte("\x00\x00"), 0o600); err != nil { | |||
| 23 | + | t.Fatal(err) | |||
| 24 | + | } | |||
| 25 | + | if argv, err := processArgv(path, 123); err == nil || argv != nil { | |||
| 26 | + | t.Fatalf("processArgv empty = %#v, %v; want nil error", argv, err) | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestProcessGroupArgvSelection(t *testing.T) { | |||
| 31 | + | recs := []processRecord{{pid: 10, pgrp: 7}, {pid: 12, pgrp: 7}, {pid: 20, pgrp: 9}} | |||
| 32 | + | pid, ok := selectProcessGroupPID(recs, 7, 10) | |||
| 33 | + | if !ok || pid != 12 { | |||
| 34 | + | t.Fatalf("selectProcessGroupPID = %d, %v; want 12, true", pid, ok) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func TestProcessGroupArgvBareShellReturnsNoPID(t *testing.T) { | |||
| 39 | + | recs := []processRecord{{pid: 10, pgrp: 7}, {pid: 20, pgrp: 9}} | |||
| 40 | + | pid, ok := selectProcessGroupPID(recs, 7, 10) | |||
| 41 | + | if ok || pid != 0 { | |||
| 42 | + | t.Fatalf("selectProcessGroupPID bare shell = %d, %v; want 0, false", pid, ok) | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestParseStatPgrp(t *testing.T) { | |||
| 47 | + | got, err := parseStatPgrp("123 (cmd with spaces) S 1 456 456 0") | |||
| 48 | + | if err != nil || got != 456 { | |||
| 49 | + | t.Fatalf("parseStatPgrp = %d, %v; want 456, nil", got, err) | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestProcessArgvFileParsing(t *testing.T) { | |||
| 54 | + | path := filepath.Join(t.TempDir(), "cmdline") | |||
| 55 | + | if err := os.WriteFile(path, []byte("cmd\x00two words\x00"), 0o600); err != nil { | |||
| 56 | + | t.Fatal(err) | |||
| 57 | + | } | |||
| 58 | + | got, err := processArgv(path, 123) | |||
| 59 | + | if err != nil { | |||
| 60 | + | t.Fatal(err) | |||
| 61 | + | } | |||
| 62 | + | want := []string{"cmd", "two words"} | |||
| 63 | + | if !reflect.DeepEqual(got, want) { | |||
| 64 | + | t.Fatalf("processArgv = %#v; want %#v", got, want) | |||
| 65 | + | } | |||
| 66 | + | } | |||
M internal/ports/ports.go +9 -0
| @@ -96,6 +96,15 @@ type Timer interface { | |||
| 96 | 96 | Stop() bool | |
| 97 | 97 | } | |
| 98 | 98 | ||
| 99 | + | // ProcessInspector reads process metadata for daemon snapshot/persistence use. | |
| 100 | + | // Implementations may live in platform packages; usecases depend on this port. | |
| 101 | + | type ProcessInspector interface { | |
| 102 | + | Cwd(pid int) (string, error) | |
| 103 | + | Comm(pid int) (string, error) | |
| 104 | + | Argv(pid int) ([]string, error) | |
| 105 | + | GroupArgv(pgid int, shellPid int) ([]string, error) | |
| 106 | + | } | |
| 107 | + | ||
| 99 | 108 | // ClipboardReader reads an image off the CLIENT-side system clipboard, for | |
| 100 | 109 | // forwarding to a remote session's focused pane. Implementations live in | |
| 101 | 110 | // internal/adapters (e.g. a wl-paste-backed one for Wayland); usecases only | |
| @@ -96,6 +96,15 @@ type Timer interface { | |||||
| 96 | Stop() bool | 96 | Stop() bool | ||
| 97 | } | 97 | } | ||
| 98 | 98 | ||||
| 99 | + | // ProcessInspector reads process metadata for daemon snapshot/persistence use. | |||
| 100 | + | // Implementations may live in platform packages; usecases depend on this port. | |||
| 101 | + | type ProcessInspector interface { | |||
| 102 | + | Cwd(pid int) (string, error) | |||
| 103 | + | Comm(pid int) (string, error) | |||
| 104 | + | Argv(pid int) ([]string, error) | |||
| 105 | + | GroupArgv(pgid int, shellPid int) ([]string, error) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 99 | // ClipboardReader reads an image off the CLIENT-side system clipboard, for | 108 | // ClipboardReader reads an image off the CLIENT-side system clipboard, for | ||
| 100 | // forwarding to a remote session's focused pane. Implementations live in | 109 | // forwarding to a remote session's focused pane. Implementations live in | ||
| 101 | // internal/adapters (e.g. a wl-paste-backed one for Wayland); usecases only | 110 | // internal/adapters (e.g. a wl-paste-backed one for Wayland); usecases only | ||
M internal/usecase/daemon/daemon.go +30 -10
| @@ -36,7 +36,6 @@ import ( | |||
| 36 | 36 | ||
| 37 | 37 | "github.com/bnema/vev/internal/domain" | |
| 38 | 38 | "github.com/bnema/vev/internal/persist" | |
| 39 | - | "github.com/bnema/vev/internal/platform" | |
| 40 | 39 | "github.com/bnema/vev/internal/ports" | |
| 41 | 40 | "github.com/bnema/vev/internal/usecase/keys" | |
| 42 | 41 | ) | |
| @@ -110,6 +109,9 @@ type Daemon struct { | |||
| 110 | 109 | restoreOnce sync.Once | |
| 111 | 110 | procCwd func(int) (string, error) | |
| 112 | 111 | procComm func(int) (string, error) | |
| 112 | + | procArgv func(int) ([]string, error) | |
| 113 | + | procGroupArgv func(int, int) ([]string, error) | |
| 114 | + | dirOrHome func(string) string | |
| 113 | 115 | bindings atomic.Pointer[keys.Bindings] | |
| 114 | 116 | codeOverrides atomic.Pointer[map[string]string] | |
| 115 | 117 | themeMode atomic.Uint32 | |
| @@ -190,6 +192,28 @@ func WithCwdReader(fn func(int) (string, error)) Option { | |||
| 190 | 192 | } | |
| 191 | 193 | } | |
| 192 | 194 | ||
| 195 | + | // WithProcessInspector installs the platform process-inspection implementation. | |
| 196 | + | func WithProcessInspector(ins ports.ProcessInspector) Option { | |
| 197 | + | return func(d *Daemon) { | |
| 198 | + | if ins == nil { | |
| 199 | + | return | |
| 200 | + | } | |
| 201 | + | d.procCwd = ins.Cwd | |
| 202 | + | d.procComm = ins.Comm | |
| 203 | + | d.procArgv = ins.Argv | |
| 204 | + | d.procGroupArgv = ins.GroupArgv | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // WithDirOrHome installs path fallback behavior from the application layer. | |
| 209 | + | func WithDirOrHome(fn func(string) string) Option { | |
| 210 | + | return func(d *Daemon) { | |
| 211 | + | if fn != nil { | |
| 212 | + | d.dirOrHome = fn | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | ||
| 193 | 217 | // WithTempDir overrides the directory clipboard-image-transfer writes temp | |
| 194 | 218 | // files into (production default: os.TempDir()); tests use this with | |
| 195 | 219 | // t.TempDir() so writes are isolated and auto-cleaned. | |
| @@ -226,8 +250,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 226 | 250 | baseEnv: os.Environ(), | |
| 227 | 251 | shell: shell, | |
| 228 | 252 | persist: persist.New(nil), | |
| 229 | - | procCwd: platform.ProcessCwd, | |
| 230 | - | procComm: platform.ProcessComm, | |
| 253 | + | dirOrHome: dirOrHome, | |
| 231 | 254 | done: make(chan struct{}), | |
| 232 | 255 | restoreDone: make(chan struct{}), | |
| 233 | 256 | animWake: make(chan struct{}, 1), | |
| @@ -238,11 +261,8 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 238 | 261 | if d.persist == nil { | |
| 239 | 262 | d.persist = persist.New(nil) | |
| 240 | 263 | } | |
| 241 | - | if d.procCwd == nil { | |
| 242 | - | d.procCwd = platform.ProcessCwd | |
| 243 | - | } | |
| 244 | - | if d.procComm == nil { | |
| 245 | - | d.procComm = platform.ProcessComm | |
| 264 | + | if d.dirOrHome == nil { | |
| 265 | + | d.dirOrHome = dirOrHome | |
| 246 | 266 | } | |
| 247 | 267 | if d.bindings.Load() == nil { | |
| 248 | 268 | d.bindings.Store(keys.DefaultBindings()) | |
| @@ -577,7 +597,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 577 | 597 | d.mu.Unlock() | |
| 578 | 598 | return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name} | |
| 579 | 599 | } | |
| 580 | - | cwd := platform.DirOrHome(stopped.cwd) | |
| 600 | + | cwd := d.dirOrHome(stopped.cwd) | |
| 581 | 601 | var err error | |
| 582 | 602 | sess, err = d.createSessionLocked(h.Name, false, cwd, sz) | |
| 583 | 603 | if err != nil { | |
| @@ -636,7 +656,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 636 | 656 | d.mu.Unlock() | |
| 637 | 657 | return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name} | |
| 638 | 658 | } | |
| 639 | - | cwd := platform.DirOrHome(stopped.cwd) | |
| 659 | + | cwd := d.dirOrHome(stopped.cwd) | |
| 640 | 660 | var err error | |
| 641 | 661 | sess, err = d.createSessionLocked(h.Name, false, cwd, sz) | |
| 642 | 662 | if err != nil { | |
| @@ -36,7 +36,6 @@ import ( | |||||
| 36 | 36 | ||||
| 37 | "github.com/bnema/vev/internal/domain" | 37 | "github.com/bnema/vev/internal/domain" | ||
| 38 | "github.com/bnema/vev/internal/persist" | 38 | "github.com/bnema/vev/internal/persist" | ||
| 39 | - | "github.com/bnema/vev/internal/platform" | |||
| 40 | "github.com/bnema/vev/internal/ports" | 39 | "github.com/bnema/vev/internal/ports" | ||
| 41 | "github.com/bnema/vev/internal/usecase/keys" | 40 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 42 | ) | 41 | ) | ||
| @@ -110,6 +109,9 @@ type Daemon struct { | |||||
| 110 | restoreOnce sync.Once | 109 | restoreOnce sync.Once | ||
| 111 | procCwd func(int) (string, error) | 110 | procCwd func(int) (string, error) | ||
| 112 | procComm func(int) (string, error) | 111 | procComm func(int) (string, error) | ||
| 112 | + | procArgv func(int) ([]string, error) | |||
| 113 | + | procGroupArgv func(int, int) ([]string, error) | |||
| 114 | + | dirOrHome func(string) string | |||
| 113 | bindings atomic.Pointer[keys.Bindings] | 115 | bindings atomic.Pointer[keys.Bindings] | ||
| 114 | codeOverrides atomic.Pointer[map[string]string] | 116 | codeOverrides atomic.Pointer[map[string]string] | ||
| 115 | themeMode atomic.Uint32 | 117 | themeMode atomic.Uint32 | ||
| @@ -190,6 +192,28 @@ func WithCwdReader(fn func(int) (string, error)) Option { | |||||
| 190 | } | 192 | } | ||
| 191 | } | 193 | } | ||
| 192 | 194 | ||||
| 195 | + | // WithProcessInspector installs the platform process-inspection implementation. | |||
| 196 | + | func WithProcessInspector(ins ports.ProcessInspector) Option { | |||
| 197 | + | return func(d *Daemon) { | |||
| 198 | + | if ins == nil { | |||
| 199 | + | return | |||
| 200 | + | } | |||
| 201 | + | d.procCwd = ins.Cwd | |||
| 202 | + | d.procComm = ins.Comm | |||
| 203 | + | d.procArgv = ins.Argv | |||
| 204 | + | d.procGroupArgv = ins.GroupArgv | |||
| 205 | + | } | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | // WithDirOrHome installs path fallback behavior from the application layer. | |||
| 209 | + | func WithDirOrHome(fn func(string) string) Option { | |||
| 210 | + | return func(d *Daemon) { | |||
| 211 | + | if fn != nil { | |||
| 212 | + | d.dirOrHome = fn | |||
| 213 | + | } | |||
| 214 | + | } | |||
| 215 | + | } | |||
| 216 | + | ||||
| 193 | // WithTempDir overrides the directory clipboard-image-transfer writes temp | 217 | // WithTempDir overrides the directory clipboard-image-transfer writes temp | ||
| 194 | // files into (production default: os.TempDir()); tests use this with | 218 | // files into (production default: os.TempDir()); tests use this with | ||
| 195 | // t.TempDir() so writes are isolated and auto-cleaned. | 219 | // t.TempDir() so writes are isolated and auto-cleaned. | ||
| @@ -226,8 +250,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 226 | baseEnv: os.Environ(), | 250 | baseEnv: os.Environ(), | ||
| 227 | shell: shell, | 251 | shell: shell, | ||
| 228 | persist: persist.New(nil), | 252 | persist: persist.New(nil), | ||
| 229 | - | procCwd: platform.ProcessCwd, | 253 | + | dirOrHome: dirOrHome, |
| 230 | - | procComm: platform.ProcessComm, | |||
| 231 | done: make(chan struct{}), | 254 | done: make(chan struct{}), | ||
| 232 | restoreDone: make(chan struct{}), | 255 | restoreDone: make(chan struct{}), | ||
| 233 | animWake: make(chan struct{}, 1), | 256 | animWake: make(chan struct{}, 1), | ||
| @@ -238,11 +261,8 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 238 | if d.persist == nil { | 261 | if d.persist == nil { | ||
| 239 | d.persist = persist.New(nil) | 262 | d.persist = persist.New(nil) | ||
| 240 | } | 263 | } | ||
| 241 | - | if d.procCwd == nil { | 264 | + | if d.dirOrHome == nil { |
| 242 | - | d.procCwd = platform.ProcessCwd | 265 | + | d.dirOrHome = dirOrHome |
| 243 | - | } | |||
| 244 | - | if d.procComm == nil { | |||
| 245 | - | d.procComm = platform.ProcessComm | |||
| 246 | } | 266 | } | ||
| 247 | if d.bindings.Load() == nil { | 267 | if d.bindings.Load() == nil { | ||
| 248 | d.bindings.Store(keys.DefaultBindings()) | 268 | d.bindings.Store(keys.DefaultBindings()) | ||
| @@ -577,7 +597,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 577 | d.mu.Unlock() | 597 | d.mu.Unlock() | ||
| 578 | return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name} | 598 | return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name} | ||
| 579 | } | 599 | } | ||
| 580 | - | cwd := platform.DirOrHome(stopped.cwd) | 600 | + | cwd := d.dirOrHome(stopped.cwd) |
| 581 | var err error | 601 | var err error | ||
| 582 | sess, err = d.createSessionLocked(h.Name, false, cwd, sz) | 602 | sess, err = d.createSessionLocked(h.Name, false, cwd, sz) | ||
| 583 | if err != nil { | 603 | if err != nil { | ||
| @@ -636,7 +656,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 636 | d.mu.Unlock() | 656 | d.mu.Unlock() | ||
| 637 | return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name} | 657 | return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name} | ||
| 638 | } | 658 | } | ||
| 639 | - | cwd := platform.DirOrHome(stopped.cwd) | 659 | + | cwd := d.dirOrHome(stopped.cwd) |
| 640 | var err error | 660 | var err error | ||
| 641 | sess, err = d.createSessionLocked(h.Name, false, cwd, sz) | 661 | sess, err = d.createSessionLocked(h.Name, false, cwd, sz) | ||
| 642 | if err != nil { | 662 | if err != nil { | ||
A internal/usecase/daemon/dir_fallback.go +15 -0
| @@ -0,0 +1,15 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import "os" | |
| 4 | + | ||
| 5 | + | func dirOrHome(dir string) string { | |
| 6 | + | if dir != "" { | |
| 7 | + | if st, err := os.Stat(dir); err == nil && st.IsDir() { | |
| 8 | + | return dir | |
| 9 | + | } | |
| 10 | + | } | |
| 11 | + | if home, err := os.UserHomeDir(); err == nil && home != "" { | |
| 12 | + | return home | |
| 13 | + | } | |
| 14 | + | return "/" | |
| 15 | + | } | |
| @@ -0,0 +1,15 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import "os" | |||
| 4 | + | ||||
| 5 | + | func dirOrHome(dir string) string { | |||
| 6 | + | if dir != "" { | |||
| 7 | + | if st, err := os.Stat(dir); err == nil && st.IsDir() { | |||
| 8 | + | return dir | |||
| 9 | + | } | |||
| 10 | + | } | |||
| 11 | + | if home, err := os.UserHomeDir(); err == nil && home != "" { | |||
| 12 | + | return home | |||
| 13 | + | } | |||
| 14 | + | return "/" | |||
| 15 | + | } | |||
M internal/usecase/daemon/picker.go +1 -2
| @@ -29,7 +29,6 @@ import ( | |||
| 29 | 29 | "strconv" | |
| 30 | 30 | ||
| 31 | 31 | "github.com/bnema/vev/internal/domain" | |
| 32 | - | "github.com/bnema/vev/internal/platform" | |
| 33 | 32 | "github.com/bnema/vev/internal/ports" | |
| 34 | 33 | "github.com/bnema/vev/internal/usecase/keys" | |
| 35 | 34 | "github.com/bnema/vev/internal/usecase/layout" | |
| @@ -421,7 +420,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||
| 421 | 420 | d.mu.Unlock() | |
| 422 | 421 | return | |
| 423 | 422 | } | |
| 424 | - | cwd := platform.DirOrHome(stopped.cwd) | |
| 423 | + | cwd := d.dirOrHome(stopped.cwd) | |
| 425 | 424 | targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size) | |
| 426 | 425 | if err != nil { | |
| 427 | 426 | from.mu.Unlock() | |
| @@ -29,7 +29,6 @@ import ( | |||||
| 29 | "strconv" | 29 | "strconv" | ||
| 30 | 30 | ||||
| 31 | "github.com/bnema/vev/internal/domain" | 31 | "github.com/bnema/vev/internal/domain" | ||
| 32 | - | "github.com/bnema/vev/internal/platform" | |||
| 33 | "github.com/bnema/vev/internal/ports" | 32 | "github.com/bnema/vev/internal/ports" | ||
| 34 | "github.com/bnema/vev/internal/usecase/keys" | 33 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 35 | "github.com/bnema/vev/internal/usecase/layout" | 34 | "github.com/bnema/vev/internal/usecase/layout" | ||
| @@ -421,7 +420,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||||
| 421 | d.mu.Unlock() | 420 | d.mu.Unlock() | ||
| 422 | return | 421 | return | ||
| 423 | } | 422 | } | ||
| 424 | - | cwd := platform.DirOrHome(stopped.cwd) | 423 | + | cwd := d.dirOrHome(stopped.cwd) |
| 425 | targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size) | 424 | targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size) | ||
| 426 | if err != nil { | 425 | if err != nil { | ||
| 427 | from.mu.Unlock() | 426 | from.mu.Unlock() | ||
A internal/usecase/daemon/process_restore.go +138 -0
| @@ -0,0 +1,138 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "path/filepath" | |
| 5 | + | "strings" | |
| 6 | + | "unicode" | |
| 7 | + | ||
| 8 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | const ( | |
| 12 | + | processStrategyGeneric = "generic" | |
| 13 | + | processStrategyClaude = "claude" | |
| 14 | + | processStrategyCodex = "codex" | |
| 15 | + | processStrategyOpenCode = "opencode" | |
| 16 | + | processStrategyPi = "pi" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | type processRestoreDecision struct { | |
| 20 | + | Command string | |
| 21 | + | Restore bool | |
| 22 | + | Reason string | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func detectProcessStrategy(argv []string) string { | |
| 26 | + | if len(argv) == 0 { | |
| 27 | + | return processStrategyGeneric | |
| 28 | + | } | |
| 29 | + | switch filepath.Base(argv[0]) { | |
| 30 | + | case "claude": | |
| 31 | + | return processStrategyClaude | |
| 32 | + | case "codex": | |
| 33 | + | return processStrategyCodex | |
| 34 | + | case "opencode": | |
| 35 | + | return processStrategyOpenCode | |
| 36 | + | case "pi": | |
| 37 | + | return processStrategyPi | |
| 38 | + | default: | |
| 39 | + | return processStrategyGeneric | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func extractAgentSessionID(strategy string, argv []string) string { | |
| 44 | + | for i, arg := range argv { | |
| 45 | + | if !agentSessionFlag(strategy, arg) || i+1 >= len(argv) { | |
| 46 | + | continue | |
| 47 | + | } | |
| 48 | + | candidate := argv[i+1] | |
| 49 | + | if candidate != "" && !strings.HasPrefix(candidate, "-") { | |
| 50 | + | return candidate | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | return "" | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func agentSessionFlag(strategy, arg string) bool { | |
| 57 | + | switch strategy { | |
| 58 | + | case processStrategyClaude: | |
| 59 | + | return arg == "--session-id" || arg == "--resume" | |
| 60 | + | case processStrategyOpenCode: | |
| 61 | + | return arg == "-s" || arg == "--session" | |
| 62 | + | case processStrategyCodex: | |
| 63 | + | return arg == "resume" | |
| 64 | + | case processStrategyPi: | |
| 65 | + | return arg == "--resume" || arg == "-r" | |
| 66 | + | default: | |
| 67 | + | return false | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func planProcessRestore(proc *snapcodec.Process, allow map[string]struct{}) processRestoreDecision { | |
| 72 | + | if proc == nil || len(proc.Argv) == 0 { | |
| 73 | + | return processRestoreDecision{Reason: "missing_process"} | |
| 74 | + | } | |
| 75 | + | strategy := proc.Strategy | |
| 76 | + | if strategy == "" { | |
| 77 | + | strategy = detectProcessStrategy(proc.Argv) | |
| 78 | + | } | |
| 79 | + | name := filepath.Base(proc.Argv[0]) | |
| 80 | + | if _, ok := allow[name]; !ok { | |
| 81 | + | return processRestoreDecision{Reason: "not_allowlisted"} | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | id := proc.Opts.AgentSessionID | |
| 85 | + | switch strategy { | |
| 86 | + | case processStrategyPi: | |
| 87 | + | if id != "" { | |
| 88 | + | return processRestoreDecision{Command: shellQuoteArgvMust([]string{"pi", "--resume", id}), Restore: true} | |
| 89 | + | } | |
| 90 | + | return processRestoreDecision{Command: "pi --continue", Restore: true} | |
| 91 | + | case processStrategyClaude: | |
| 92 | + | if id != "" { | |
| 93 | + | return processRestoreDecision{Command: shellQuoteArgvMust([]string{"claude", "--resume", id}), Restore: true} | |
| 94 | + | } | |
| 95 | + | case processStrategyOpenCode: | |
| 96 | + | if id != "" { | |
| 97 | + | return processRestoreDecision{Command: shellQuoteArgvMust([]string{"opencode", "--session", id}), Restore: true} | |
| 98 | + | } | |
| 99 | + | case processStrategyCodex: | |
| 100 | + | if id != "" { | |
| 101 | + | return processRestoreDecision{Command: shellQuoteArgvMust([]string{"codex", "resume", id}), Restore: true} | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | command, ok := shellQuoteArgv(proc.Argv) | |
| 106 | + | if !ok { | |
| 107 | + | return processRestoreDecision{Reason: "empty_command"} | |
| 108 | + | } | |
| 109 | + | return processRestoreDecision{Command: command, Restore: true} | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func shellQuoteArgvMust(argv []string) string { | |
| 113 | + | command, _ := shellQuoteArgv(argv) | |
| 114 | + | return command | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func shellQuoteArgv(argv []string) (string, bool) { | |
| 118 | + | if len(argv) == 0 { | |
| 119 | + | return "", false | |
| 120 | + | } | |
| 121 | + | quoted := make([]string, 0, len(argv)) | |
| 122 | + | for _, arg := range argv { | |
| 123 | + | quoted = append(quoted, shellQuote(arg)) | |
| 124 | + | } | |
| 125 | + | return strings.Join(quoted, " "), true | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func shellQuote(s string) string { | |
| 129 | + | if s == "" { | |
| 130 | + | return "''" | |
| 131 | + | } | |
| 132 | + | if strings.IndexFunc(s, func(r rune) bool { | |
| 133 | + | return r != '-' && r != '_' && r != '.' && r != '/' && r != ':' && r != '+' && r != '=' && !unicode.IsLetter(r) && !unicode.IsDigit(r) | |
| 134 | + | }) == -1 { | |
| 135 | + | return s | |
| 136 | + | } | |
| 137 | + | return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" | |
| 138 | + | } | |
| @@ -0,0 +1,138 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "path/filepath" | |||
| 5 | + | "strings" | |||
| 6 | + | "unicode" | |||
| 7 | + | ||||
| 8 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | const ( | |||
| 12 | + | processStrategyGeneric = "generic" | |||
| 13 | + | processStrategyClaude = "claude" | |||
| 14 | + | processStrategyCodex = "codex" | |||
| 15 | + | processStrategyOpenCode = "opencode" | |||
| 16 | + | processStrategyPi = "pi" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | type processRestoreDecision struct { | |||
| 20 | + | Command string | |||
| 21 | + | Restore bool | |||
| 22 | + | Reason string | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func detectProcessStrategy(argv []string) string { | |||
| 26 | + | if len(argv) == 0 { | |||
| 27 | + | return processStrategyGeneric | |||
| 28 | + | } | |||
| 29 | + | switch filepath.Base(argv[0]) { | |||
| 30 | + | case "claude": | |||
| 31 | + | return processStrategyClaude | |||
| 32 | + | case "codex": | |||
| 33 | + | return processStrategyCodex | |||
| 34 | + | case "opencode": | |||
| 35 | + | return processStrategyOpenCode | |||
| 36 | + | case "pi": | |||
| 37 | + | return processStrategyPi | |||
| 38 | + | default: | |||
| 39 | + | return processStrategyGeneric | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func extractAgentSessionID(strategy string, argv []string) string { | |||
| 44 | + | for i, arg := range argv { | |||
| 45 | + | if !agentSessionFlag(strategy, arg) || i+1 >= len(argv) { | |||
| 46 | + | continue | |||
| 47 | + | } | |||
| 48 | + | candidate := argv[i+1] | |||
| 49 | + | if candidate != "" && !strings.HasPrefix(candidate, "-") { | |||
| 50 | + | return candidate | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | return "" | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func agentSessionFlag(strategy, arg string) bool { | |||
| 57 | + | switch strategy { | |||
| 58 | + | case processStrategyClaude: | |||
| 59 | + | return arg == "--session-id" || arg == "--resume" | |||
| 60 | + | case processStrategyOpenCode: | |||
| 61 | + | return arg == "-s" || arg == "--session" | |||
| 62 | + | case processStrategyCodex: | |||
| 63 | + | return arg == "resume" | |||
| 64 | + | case processStrategyPi: | |||
| 65 | + | return arg == "--resume" || arg == "-r" | |||
| 66 | + | default: | |||
| 67 | + | return false | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func planProcessRestore(proc *snapcodec.Process, allow map[string]struct{}) processRestoreDecision { | |||
| 72 | + | if proc == nil || len(proc.Argv) == 0 { | |||
| 73 | + | return processRestoreDecision{Reason: "missing_process"} | |||
| 74 | + | } | |||
| 75 | + | strategy := proc.Strategy | |||
| 76 | + | if strategy == "" { | |||
| 77 | + | strategy = detectProcessStrategy(proc.Argv) | |||
| 78 | + | } | |||
| 79 | + | name := filepath.Base(proc.Argv[0]) | |||
| 80 | + | if _, ok := allow[name]; !ok { | |||
| 81 | + | return processRestoreDecision{Reason: "not_allowlisted"} | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | id := proc.Opts.AgentSessionID | |||
| 85 | + | switch strategy { | |||
| 86 | + | case processStrategyPi: | |||
| 87 | + | if id != "" { | |||
| 88 | + | return processRestoreDecision{Command: shellQuoteArgvMust([]string{"pi", "--resume", id}), Restore: true} | |||
| 89 | + | } | |||
| 90 | + | return processRestoreDecision{Command: "pi --continue", Restore: true} | |||
| 91 | + | case processStrategyClaude: | |||
| 92 | + | if id != "" { | |||
| 93 | + | return processRestoreDecision{Command: shellQuoteArgvMust([]string{"claude", "--resume", id}), Restore: true} | |||
| 94 | + | } | |||
| 95 | + | case processStrategyOpenCode: | |||
| 96 | + | if id != "" { | |||
| 97 | + | return processRestoreDecision{Command: shellQuoteArgvMust([]string{"opencode", "--session", id}), Restore: true} | |||
| 98 | + | } | |||
| 99 | + | case processStrategyCodex: | |||
| 100 | + | if id != "" { | |||
| 101 | + | return processRestoreDecision{Command: shellQuoteArgvMust([]string{"codex", "resume", id}), Restore: true} | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | command, ok := shellQuoteArgv(proc.Argv) | |||
| 106 | + | if !ok { | |||
| 107 | + | return processRestoreDecision{Reason: "empty_command"} | |||
| 108 | + | } | |||
| 109 | + | return processRestoreDecision{Command: command, Restore: true} | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func shellQuoteArgvMust(argv []string) string { | |||
| 113 | + | command, _ := shellQuoteArgv(argv) | |||
| 114 | + | return command | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func shellQuoteArgv(argv []string) (string, bool) { | |||
| 118 | + | if len(argv) == 0 { | |||
| 119 | + | return "", false | |||
| 120 | + | } | |||
| 121 | + | quoted := make([]string, 0, len(argv)) | |||
| 122 | + | for _, arg := range argv { | |||
| 123 | + | quoted = append(quoted, shellQuote(arg)) | |||
| 124 | + | } | |||
| 125 | + | return strings.Join(quoted, " "), true | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func shellQuote(s string) string { | |||
| 129 | + | if s == "" { | |||
| 130 | + | return "''" | |||
| 131 | + | } | |||
| 132 | + | if strings.IndexFunc(s, func(r rune) bool { | |||
| 133 | + | return r != '-' && r != '_' && r != '.' && r != '/' && r != ':' && r != '+' && r != '=' && !unicode.IsLetter(r) && !unicode.IsDigit(r) | |||
| 134 | + | }) == -1 { | |||
| 135 | + | return s | |||
| 136 | + | } | |||
| 137 | + | return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" | |||
| 138 | + | } | |||
A internal/usecase/daemon/process_restore_test.go +103 -0
| @@ -0,0 +1,103 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestProcessRestorePlan(t *testing.T) { | |
| 10 | + | allow := map[string]struct{}{ | |
| 11 | + | "less": {}, | |
| 12 | + | "pi": {}, | |
| 13 | + | "claude": {}, | |
| 14 | + | "opencode": {}, | |
| 15 | + | "codex": {}, | |
| 16 | + | } | |
| 17 | + | tests := []struct { | |
| 18 | + | name string | |
| 19 | + | proc *snapcodec.Process | |
| 20 | + | restore bool | |
| 21 | + | command string | |
| 22 | + | reason string | |
| 23 | + | }{ | |
| 24 | + | { | |
| 25 | + | name: "generic less allowed", | |
| 26 | + | proc: &snapcodec.Process{Argv: []string{"/usr/bin/less", "README.md"}, Strategy: processStrategyGeneric}, | |
| 27 | + | restore: true, | |
| 28 | + | command: "/usr/bin/less README.md", | |
| 29 | + | }, | |
| 30 | + | { | |
| 31 | + | name: "bash denied", | |
| 32 | + | proc: &snapcodec.Process{Argv: []string{"bash", "-lc", "rm -rf /"}, Strategy: processStrategyGeneric}, | |
| 33 | + | reason: "not_allowlisted", | |
| 34 | + | }, | |
| 35 | + | { | |
| 36 | + | name: "pi with ID resumes", | |
| 37 | + | proc: &snapcodec.Process{Argv: []string{"pi", "cli"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "session-123"}}, | |
| 38 | + | restore: true, | |
| 39 | + | command: "pi --resume session-123", | |
| 40 | + | }, | |
| 41 | + | { | |
| 42 | + | name: "pi no ID continues", | |
| 43 | + | proc: &snapcodec.Process{Argv: []string{"pi", "cli"}, Strategy: processStrategyPi}, | |
| 44 | + | restore: true, | |
| 45 | + | command: "pi --continue", | |
| 46 | + | }, | |
| 47 | + | { | |
| 48 | + | name: "opencode session command", | |
| 49 | + | proc: &snapcodec.Process{Argv: []string{"opencode"}, Strategy: processStrategyOpenCode, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc"}}, | |
| 50 | + | restore: true, | |
| 51 | + | command: "opencode --session abc", | |
| 52 | + | }, | |
| 53 | + | { | |
| 54 | + | name: "codex resume command", | |
| 55 | + | proc: &snapcodec.Process{Argv: []string{"codex"}, Strategy: processStrategyCodex, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc"}}, | |
| 56 | + | restore: true, | |
| 57 | + | command: "codex resume abc", | |
| 58 | + | }, | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | for _, tt := range tests { | |
| 62 | + | t.Run(tt.name, func(t *testing.T) { | |
| 63 | + | got := planProcessRestore(tt.proc, allow) | |
| 64 | + | if got.Restore != tt.restore || got.Command != tt.command || got.Reason != tt.reason { | |
| 65 | + | t.Fatalf("planProcessRestore() = %+v, want restore=%v command=%q reason=%q", got, tt.restore, tt.command, tt.reason) | |
| 66 | + | } | |
| 67 | + | }) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestExtractAgentSessionID(t *testing.T) { | |
| 72 | + | tests := []struct { | |
| 73 | + | name string | |
| 74 | + | strategy string | |
| 75 | + | argv []string | |
| 76 | + | want string | |
| 77 | + | }{ | |
| 78 | + | {name: "claude session flag extraction", strategy: processStrategyClaude, argv: []string{"claude", "--session-id", "uuid-123"}, want: "uuid-123"}, | |
| 79 | + | {name: "opencode short session flag extraction", strategy: processStrategyOpenCode, argv: []string{"opencode", "-s", "abc"}, want: "abc"}, | |
| 80 | + | {name: "codex resume extraction", strategy: processStrategyCodex, argv: []string{"codex", "resume", "abc"}, want: "abc"}, | |
| 81 | + | {name: "claude flag-looking ID rejected", strategy: processStrategyClaude, argv: []string{"claude", "--resume", "--continue"}}, | |
| 82 | + | {name: "pi flag-looking ID rejected", strategy: processStrategyPi, argv: []string{"pi", "-r", "--last"}}, | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | for _, tt := range tests { | |
| 86 | + | t.Run(tt.name, func(t *testing.T) { | |
| 87 | + | if got := extractAgentSessionID(tt.strategy, tt.argv); got != tt.want { | |
| 88 | + | t.Fatalf("extractAgentSessionID() = %q, want %q", got, tt.want) | |
| 89 | + | } | |
| 90 | + | }) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func TestShellQuoteArgv(t *testing.T) { | |
| 95 | + | got, ok := shellQuoteArgv([]string{"cmd", "two words", "it's"}) | |
| 96 | + | if !ok { | |
| 97 | + | t.Fatal("shellQuoteArgv() returned !ok") | |
| 98 | + | } | |
| 99 | + | want := "cmd 'two words' 'it'\\''s'" | |
| 100 | + | if got != want { | |
| 101 | + | t.Fatalf("shellQuoteArgv() = %q, want %q", got, want) | |
| 102 | + | } | |
| 103 | + | } | |
| @@ -0,0 +1,103 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestProcessRestorePlan(t *testing.T) { | |||
| 10 | + | allow := map[string]struct{}{ | |||
| 11 | + | "less": {}, | |||
| 12 | + | "pi": {}, | |||
| 13 | + | "claude": {}, | |||
| 14 | + | "opencode": {}, | |||
| 15 | + | "codex": {}, | |||
| 16 | + | } | |||
| 17 | + | tests := []struct { | |||
| 18 | + | name string | |||
| 19 | + | proc *snapcodec.Process | |||
| 20 | + | restore bool | |||
| 21 | + | command string | |||
| 22 | + | reason string | |||
| 23 | + | }{ | |||
| 24 | + | { | |||
| 25 | + | name: "generic less allowed", | |||
| 26 | + | proc: &snapcodec.Process{Argv: []string{"/usr/bin/less", "README.md"}, Strategy: processStrategyGeneric}, | |||
| 27 | + | restore: true, | |||
| 28 | + | command: "/usr/bin/less README.md", | |||
| 29 | + | }, | |||
| 30 | + | { | |||
| 31 | + | name: "bash denied", | |||
| 32 | + | proc: &snapcodec.Process{Argv: []string{"bash", "-lc", "rm -rf /"}, Strategy: processStrategyGeneric}, | |||
| 33 | + | reason: "not_allowlisted", | |||
| 34 | + | }, | |||
| 35 | + | { | |||
| 36 | + | name: "pi with ID resumes", | |||
| 37 | + | proc: &snapcodec.Process{Argv: []string{"pi", "cli"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "session-123"}}, | |||
| 38 | + | restore: true, | |||
| 39 | + | command: "pi --resume session-123", | |||
| 40 | + | }, | |||
| 41 | + | { | |||
| 42 | + | name: "pi no ID continues", | |||
| 43 | + | proc: &snapcodec.Process{Argv: []string{"pi", "cli"}, Strategy: processStrategyPi}, | |||
| 44 | + | restore: true, | |||
| 45 | + | command: "pi --continue", | |||
| 46 | + | }, | |||
| 47 | + | { | |||
| 48 | + | name: "opencode session command", | |||
| 49 | + | proc: &snapcodec.Process{Argv: []string{"opencode"}, Strategy: processStrategyOpenCode, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc"}}, | |||
| 50 | + | restore: true, | |||
| 51 | + | command: "opencode --session abc", | |||
| 52 | + | }, | |||
| 53 | + | { | |||
| 54 | + | name: "codex resume command", | |||
| 55 | + | proc: &snapcodec.Process{Argv: []string{"codex"}, Strategy: processStrategyCodex, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc"}}, | |||
| 56 | + | restore: true, | |||
| 57 | + | command: "codex resume abc", | |||
| 58 | + | }, | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | for _, tt := range tests { | |||
| 62 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 63 | + | got := planProcessRestore(tt.proc, allow) | |||
| 64 | + | if got.Restore != tt.restore || got.Command != tt.command || got.Reason != tt.reason { | |||
| 65 | + | t.Fatalf("planProcessRestore() = %+v, want restore=%v command=%q reason=%q", got, tt.restore, tt.command, tt.reason) | |||
| 66 | + | } | |||
| 67 | + | }) | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func TestExtractAgentSessionID(t *testing.T) { | |||
| 72 | + | tests := []struct { | |||
| 73 | + | name string | |||
| 74 | + | strategy string | |||
| 75 | + | argv []string | |||
| 76 | + | want string | |||
| 77 | + | }{ | |||
| 78 | + | {name: "claude session flag extraction", strategy: processStrategyClaude, argv: []string{"claude", "--session-id", "uuid-123"}, want: "uuid-123"}, | |||
| 79 | + | {name: "opencode short session flag extraction", strategy: processStrategyOpenCode, argv: []string{"opencode", "-s", "abc"}, want: "abc"}, | |||
| 80 | + | {name: "codex resume extraction", strategy: processStrategyCodex, argv: []string{"codex", "resume", "abc"}, want: "abc"}, | |||
| 81 | + | {name: "claude flag-looking ID rejected", strategy: processStrategyClaude, argv: []string{"claude", "--resume", "--continue"}}, | |||
| 82 | + | {name: "pi flag-looking ID rejected", strategy: processStrategyPi, argv: []string{"pi", "-r", "--last"}}, | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | for _, tt := range tests { | |||
| 86 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 87 | + | if got := extractAgentSessionID(tt.strategy, tt.argv); got != tt.want { | |||
| 88 | + | t.Fatalf("extractAgentSessionID() = %q, want %q", got, tt.want) | |||
| 89 | + | } | |||
| 90 | + | }) | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func TestShellQuoteArgv(t *testing.T) { | |||
| 95 | + | got, ok := shellQuoteArgv([]string{"cmd", "two words", "it's"}) | |||
| 96 | + | if !ok { | |||
| 97 | + | t.Fatal("shellQuoteArgv() returned !ok") | |||
| 98 | + | } | |||
| 99 | + | want := "cmd 'two words' 'it'\\''s'" | |||
| 100 | + | if got != want { | |||
| 101 | + | t.Fatalf("shellQuoteArgv() = %q, want %q", got, want) | |||
| 102 | + | } | |||
| 103 | + | } | |||
M internal/usecase/daemon/snapshot.go +26 -2
| @@ -273,8 +273,9 @@ func (d *Daemon) captureSession(sess *session) bool { | |||
| 273 | 273 | for _, p := range panes { | |
| 274 | 274 | p.mu.Lock() | |
| 275 | 275 | pid := 0 | |
| 276 | - | if p.pty != nil { | |
| 277 | - | pid = p.pty.Pid() | |
| 276 | + | pty := p.pty | |
| 277 | + | if pty != nil { | |
| 278 | + | pid = pty.Pid() | |
| 278 | 279 | } | |
| 279 | 280 | paneSnap := snapcodec.Pane{ | |
| 280 | 281 | ID: p.id, | |
| @@ -289,6 +290,7 @@ func (d *Daemon) captureSession(sess *session) bool { | |||
| 289 | 290 | paneSnap.Cwd = cwd | |
| 290 | 291 | } | |
| 291 | 292 | } | |
| 293 | + | paneSnap.Process = d.capturePaneProcess(pty, pid) | |
| 292 | 294 | paneIDs[paneSnap.ID] = struct{}{} | |
| 293 | 295 | paneSnaps = append(paneSnaps, paneSnap) | |
| 294 | 296 | } | |
| @@ -311,6 +313,28 @@ func (d *Daemon) captureSession(sess *session) bool { | |||
| 311 | 313 | return true | |
| 312 | 314 | } | |
| 313 | 315 | ||
| 316 | + | func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process { | |
| 317 | + | if d == nil || pty == nil || shellPid <= 0 || d.procGroupArgv == nil { | |
| 318 | + | return nil | |
| 319 | + | } | |
| 320 | + | pgid, err := pty.ForegroundPgid() | |
| 321 | + | if err != nil || pgid <= 0 || pgid == shellPid { | |
| 322 | + | return nil | |
| 323 | + | } | |
| 324 | + | argv, err := d.procGroupArgv(pgid, shellPid) | |
| 325 | + | if err != nil || len(argv) == 0 || argv[0] == "" { | |
| 326 | + | return nil | |
| 327 | + | } | |
| 328 | + | strategy := detectProcessStrategy(argv) | |
| 329 | + | return &snapcodec.Process{ | |
| 330 | + | Argv: append([]string(nil), argv...), | |
| 331 | + | Strategy: strategy, | |
| 332 | + | Opts: snapcodec.ProcessOpts{ | |
| 333 | + | AgentSessionID: extractAgentSessionID(strategy, argv), | |
| 334 | + | }, | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | ||
| 314 | 338 | func (d *Daemon) snapshotSaver(ctx context.Context) { | |
| 315 | 339 | t := d.clock.NewTimer(snapshotInterval) | |
| 316 | 340 | defer t.Stop() | |
| @@ -273,8 +273,9 @@ func (d *Daemon) captureSession(sess *session) bool { | |||||
| 273 | for _, p := range panes { | 273 | for _, p := range panes { | ||
| 274 | p.mu.Lock() | 274 | p.mu.Lock() | ||
| 275 | pid := 0 | 275 | pid := 0 | ||
| 276 | - | if p.pty != nil { | 276 | + | pty := p.pty |
| 277 | - | pid = p.pty.Pid() | 277 | + | if pty != nil { |
| 278 | + | pid = pty.Pid() | |||
| 278 | } | 279 | } | ||
| 279 | paneSnap := snapcodec.Pane{ | 280 | paneSnap := snapcodec.Pane{ | ||
| 280 | ID: p.id, | 281 | ID: p.id, | ||
| @@ -289,6 +290,7 @@ func (d *Daemon) captureSession(sess *session) bool { | |||||
| 289 | paneSnap.Cwd = cwd | 290 | paneSnap.Cwd = cwd | ||
| 290 | } | 291 | } | ||
| 291 | } | 292 | } | ||
| 293 | + | paneSnap.Process = d.capturePaneProcess(pty, pid) | |||
| 292 | paneIDs[paneSnap.ID] = struct{}{} | 294 | paneIDs[paneSnap.ID] = struct{}{} | ||
| 293 | paneSnaps = append(paneSnaps, paneSnap) | 295 | paneSnaps = append(paneSnaps, paneSnap) | ||
| 294 | } | 296 | } | ||
| @@ -311,6 +313,28 @@ func (d *Daemon) captureSession(sess *session) bool { | |||||
| 311 | return true | 313 | return true | ||
| 312 | } | 314 | } | ||
| 313 | 315 | ||||
| 316 | + | func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process { | |||
| 317 | + | if d == nil || pty == nil || shellPid <= 0 || d.procGroupArgv == nil { | |||
| 318 | + | return nil | |||
| 319 | + | } | |||
| 320 | + | pgid, err := pty.ForegroundPgid() | |||
| 321 | + | if err != nil || pgid <= 0 || pgid == shellPid { | |||
| 322 | + | return nil | |||
| 323 | + | } | |||
| 324 | + | argv, err := d.procGroupArgv(pgid, shellPid) | |||
| 325 | + | if err != nil || len(argv) == 0 || argv[0] == "" { | |||
| 326 | + | return nil | |||
| 327 | + | } | |||
| 328 | + | strategy := detectProcessStrategy(argv) | |||
| 329 | + | return &snapcodec.Process{ | |||
| 330 | + | Argv: append([]string(nil), argv...), | |||
| 331 | + | Strategy: strategy, | |||
| 332 | + | Opts: snapcodec.ProcessOpts{ | |||
| 333 | + | AgentSessionID: extractAgentSessionID(strategy, argv), | |||
| 334 | + | }, | |||
| 335 | + | } | |||
| 336 | + | } | |||
| 337 | + | ||||
| 314 | func (d *Daemon) snapshotSaver(ctx context.Context) { | 338 | func (d *Daemon) snapshotSaver(ctx context.Context) { | ||
| 315 | t := d.clock.NewTimer(snapshotInterval) | 339 | t := d.clock.NewTimer(snapshotInterval) | ||
| 316 | defer t.Stop() | 340 | defer t.Stop() | ||
M internal/usecase/daemon/snapshot_test.go +82 -0
| @@ -63,6 +63,77 @@ func TestSnapshotSaverWritesDirtyNamedSessionsOnly(t *testing.T) { | |||
| 63 | 63 | } | |
| 64 | 64 | } | |
| 65 | 65 | ||
| 66 | + | func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) { | |
| 67 | + | store := portsmocks.NewMockSnapshotStore(t) | |
| 68 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 69 | + | WithSnapshotStore(store)(d) | |
| 70 | + | d.procGroupArgv = func(pgid int, shellPid int) ([]string, error) { | |
| 71 | + | require.Equal(t, 4321, pgid) | |
| 72 | + | require.Equal(t, 1234, shellPid) | |
| 73 | + | return []string{"claude", "--session-id", "agent-123"}, nil | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |
| 77 | + | pty := snapshotProcessPTY(t, 4321) | |
| 78 | + | sess.tabs[0].panes["pane-1"].pty = pty | |
| 79 | + | ||
| 80 | + | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |
| 81 | + | snap, err := snapcodec.Unmarshal(data) | |
| 82 | + | require.NoError(t, err) | |
| 83 | + | proc := snap.Tabs[0].Panes[0].Process | |
| 84 | + | require.NotNil(t, proc) | |
| 85 | + | require.Equal(t, []string{"claude", "--session-id", "agent-123"}, proc.Argv) | |
| 86 | + | require.Equal(t, processStrategyClaude, proc.Strategy) | |
| 87 | + | require.Equal(t, "agent-123", proc.Opts.AgentSessionID) | |
| 88 | + | return nil | |
| 89 | + | }).Once() | |
| 90 | + | ||
| 91 | + | require.True(t, d.captureSession(sess)) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func TestSnapshotCaptureProcessFailureDoesNotBlockSnapshot(t *testing.T) { | |
| 95 | + | store := portsmocks.NewMockSnapshotStore(t) | |
| 96 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 97 | + | WithSnapshotStore(store)(d) | |
| 98 | + | d.procGroupArgv = func(int, int) ([]string, error) { return nil, errors.New("inspect failed") } | |
| 99 | + | ||
| 100 | + | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |
| 101 | + | pty := snapshotProcessPTY(t, 4321) | |
| 102 | + | sess.tabs[0].panes["pane-1"].pty = pty | |
| 103 | + | ||
| 104 | + | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |
| 105 | + | snap, err := snapcodec.Unmarshal(data) | |
| 106 | + | require.NoError(t, err) | |
| 107 | + | require.Nil(t, snap.Tabs[0].Panes[0].Process) | |
| 108 | + | return nil | |
| 109 | + | }).Once() | |
| 110 | + | ||
| 111 | + | require.True(t, d.captureSession(sess)) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestSnapshotCaptureSkipsBareShellProcess(t *testing.T) { | |
| 115 | + | store := portsmocks.NewMockSnapshotStore(t) | |
| 116 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 117 | + | WithSnapshotStore(store)(d) | |
| 118 | + | d.procGroupArgv = func(int, int) ([]string, error) { | |
| 119 | + | t.Fatal("bare shell should not inspect argv") | |
| 120 | + | return nil, nil | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |
| 124 | + | pty := snapshotProcessPTY(t, 1234) | |
| 125 | + | sess.tabs[0].panes["pane-1"].pty = pty | |
| 126 | + | ||
| 127 | + | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |
| 128 | + | snap, err := snapcodec.Unmarshal(data) | |
| 129 | + | require.NoError(t, err) | |
| 130 | + | require.Nil(t, snap.Tabs[0].Panes[0].Process) | |
| 131 | + | return nil | |
| 132 | + | }).Once() | |
| 133 | + | ||
| 134 | + | require.True(t, d.captureSession(sess)) | |
| 135 | + | } | |
| 136 | + | ||
| 66 | 137 | func TestKillSessionSnapshotsNamedSessionBeforeClosingPanes(t *testing.T) { | |
| 67 | 138 | store := portsmocks.NewMockSnapshotStore(t) | |
| 68 | 139 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| @@ -293,6 +364,17 @@ func TestSnapshotSaverCapturesLayoutOnlyDirtySave(t *testing.T) { | |||
| 293 | 364 | } | |
| 294 | 365 | } | |
| 295 | 366 | ||
| 367 | + | func snapshotProcessPTY(t *testing.T, pgid int) *portsmocks.MockPTY { | |
| 368 | + | t.Helper() | |
| 369 | + | pty := portsmocks.NewMockPTY(t) | |
| 370 | + | pty.EXPECT().Pid().Return(1234).Maybe() | |
| 371 | + | pty.EXPECT().Read(mock.Anything).Return(0, errors.New("unused")).Maybe() | |
| 372 | + | pty.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe() | |
| 373 | + | pty.EXPECT().Resize(mock.Anything).Return(nil).Maybe() | |
| 374 | + | pty.EXPECT().ForegroundPgid().Return(pgid, nil).Once() | |
| 375 | + | return pty | |
| 376 | + | } | |
| 377 | + | ||
| 296 | 378 | func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd string) *session { | |
| 297 | 379 | t.Helper() | |
| 298 | 380 | pty := portsmocks.NewMockPTY(t) | |
| @@ -63,6 +63,77 @@ func TestSnapshotSaverWritesDirtyNamedSessionsOnly(t *testing.T) { | |||||
| 63 | } | 63 | } | ||
| 64 | } | 64 | } | ||
| 65 | 65 | ||||
| 66 | + | func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) { | |||
| 67 | + | store := portsmocks.NewMockSnapshotStore(t) | |||
| 68 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 69 | + | WithSnapshotStore(store)(d) | |||
| 70 | + | d.procGroupArgv = func(pgid int, shellPid int) ([]string, error) { | |||
| 71 | + | require.Equal(t, 4321, pgid) | |||
| 72 | + | require.Equal(t, 1234, shellPid) | |||
| 73 | + | return []string{"claude", "--session-id", "agent-123"}, nil | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |||
| 77 | + | pty := snapshotProcessPTY(t, 4321) | |||
| 78 | + | sess.tabs[0].panes["pane-1"].pty = pty | |||
| 79 | + | ||||
| 80 | + | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |||
| 81 | + | snap, err := snapcodec.Unmarshal(data) | |||
| 82 | + | require.NoError(t, err) | |||
| 83 | + | proc := snap.Tabs[0].Panes[0].Process | |||
| 84 | + | require.NotNil(t, proc) | |||
| 85 | + | require.Equal(t, []string{"claude", "--session-id", "agent-123"}, proc.Argv) | |||
| 86 | + | require.Equal(t, processStrategyClaude, proc.Strategy) | |||
| 87 | + | require.Equal(t, "agent-123", proc.Opts.AgentSessionID) | |||
| 88 | + | return nil | |||
| 89 | + | }).Once() | |||
| 90 | + | ||||
| 91 | + | require.True(t, d.captureSession(sess)) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func TestSnapshotCaptureProcessFailureDoesNotBlockSnapshot(t *testing.T) { | |||
| 95 | + | store := portsmocks.NewMockSnapshotStore(t) | |||
| 96 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 97 | + | WithSnapshotStore(store)(d) | |||
| 98 | + | d.procGroupArgv = func(int, int) ([]string, error) { return nil, errors.New("inspect failed") } | |||
| 99 | + | ||||
| 100 | + | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |||
| 101 | + | pty := snapshotProcessPTY(t, 4321) | |||
| 102 | + | sess.tabs[0].panes["pane-1"].pty = pty | |||
| 103 | + | ||||
| 104 | + | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |||
| 105 | + | snap, err := snapcodec.Unmarshal(data) | |||
| 106 | + | require.NoError(t, err) | |||
| 107 | + | require.Nil(t, snap.Tabs[0].Panes[0].Process) | |||
| 108 | + | return nil | |||
| 109 | + | }).Once() | |||
| 110 | + | ||||
| 111 | + | require.True(t, d.captureSession(sess)) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func TestSnapshotCaptureSkipsBareShellProcess(t *testing.T) { | |||
| 115 | + | store := portsmocks.NewMockSnapshotStore(t) | |||
| 116 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 117 | + | WithSnapshotStore(store)(d) | |||
| 118 | + | d.procGroupArgv = func(int, int) ([]string, error) { | |||
| 119 | + | t.Fatal("bare shell should not inspect argv") | |||
| 120 | + | return nil, nil | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |||
| 124 | + | pty := snapshotProcessPTY(t, 1234) | |||
| 125 | + | sess.tabs[0].panes["pane-1"].pty = pty | |||
| 126 | + | ||||
| 127 | + | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |||
| 128 | + | snap, err := snapcodec.Unmarshal(data) | |||
| 129 | + | require.NoError(t, err) | |||
| 130 | + | require.Nil(t, snap.Tabs[0].Panes[0].Process) | |||
| 131 | + | return nil | |||
| 132 | + | }).Once() | |||
| 133 | + | ||||
| 134 | + | require.True(t, d.captureSession(sess)) | |||
| 135 | + | } | |||
| 136 | + | ||||
| 66 | func TestKillSessionSnapshotsNamedSessionBeforeClosingPanes(t *testing.T) { | 137 | func TestKillSessionSnapshotsNamedSessionBeforeClosingPanes(t *testing.T) { | ||
| 67 | store := portsmocks.NewMockSnapshotStore(t) | 138 | store := portsmocks.NewMockSnapshotStore(t) | ||
| 68 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | 139 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | ||
| @@ -293,6 +364,17 @@ func TestSnapshotSaverCapturesLayoutOnlyDirtySave(t *testing.T) { | |||||
| 293 | } | 364 | } | ||
| 294 | } | 365 | } | ||
| 295 | 366 | ||||
| 367 | + | func snapshotProcessPTY(t *testing.T, pgid int) *portsmocks.MockPTY { | |||
| 368 | + | t.Helper() | |||
| 369 | + | pty := portsmocks.NewMockPTY(t) | |||
| 370 | + | pty.EXPECT().Pid().Return(1234).Maybe() | |||
| 371 | + | pty.EXPECT().Read(mock.Anything).Return(0, errors.New("unused")).Maybe() | |||
| 372 | + | pty.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe() | |||
| 373 | + | pty.EXPECT().Resize(mock.Anything).Return(nil).Maybe() | |||
| 374 | + | pty.EXPECT().ForegroundPgid().Return(pgid, nil).Once() | |||
| 375 | + | return pty | |||
| 376 | + | } | |||
| 377 | + | ||||
| 296 | func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd string) *session { | 378 | func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd string) *session { | ||
| 297 | t.Helper() | 379 | t.Helper() | ||
| 298 | pty := portsmocks.NewMockPTY(t) | 380 | pty := portsmocks.NewMockPTY(t) | ||