diff --git a/internal/cmd/web.go b/internal/cmd/web.go index 7318fd4..f19809e 100644 --- a/internal/cmd/web.go +++ b/internal/cmd/web.go @@ -2,13 +2,19 @@ package cmd import ( "fmt" + "net/http" + "os" "os/exec" "runtime" + "strings" + "time" "github.com/spf13/cobra" + "github.com/thevibeworks/ccx/internal/catalog" "github.com/thevibeworks/ccx/internal/config" "github.com/thevibeworks/ccx/internal/db" + "github.com/thevibeworks/ccx/internal/parser" "github.com/thevibeworks/ccx/internal/provider" "github.com/thevibeworks/ccx/internal/web" ) @@ -18,6 +24,14 @@ var webCmd = &cobra.Command{ Short: "Start local web server for session browsing", Long: `Start ccx web UI for browsing agent sessions. +Deep-link flags: + --project [path] Open directly to a project page + --session Open directly to a session (full UUID or short prefix) + --latest Open to the most recent session in the current workspace + +If the server is already running on the target port, these flags open +the browser without starting a second server. + Features: - Project/session browser with global search - Tree-aware session viewer with message threading @@ -33,15 +47,23 @@ Opens browser automatically. Use --no-open to disable.`, } var ( - webPort int - webHost string - webNoOpen bool + webPort int + webHost string + webNoOpen bool + webProject string + webSession string + webLatest bool ) func init() { webCmd.Flags().IntVarP(&webPort, "port", "p", 8080, "port to listen on") webCmd.Flags().StringVar(&webHost, "host", "localhost", "host to bind to") webCmd.Flags().BoolVar(&webNoOpen, "no-open", false, "don't open browser automatically") + webCmd.Flags().StringVar(&webProject, "project", "", "open project page (path or name; empty = current workspace)") + webCmd.Flags().Lookup("project").NoOptDefVal = "." + webCmd.Flags().StringVar(&webSession, "session", "", "open a specific session by ID or short prefix") + webCmd.Flags().BoolVar(&webLatest, "latest", false, "open the most recent session in the current workspace") + webCmd.MarkFlagsMutuallyExclusive("project", "session", "latest") rootCmd.AddCommand(webCmd) } @@ -49,9 +71,27 @@ func init() { func runWeb(cmd *cobra.Command, args []string) error { backend := provider.Default() addr := fmt.Sprintf("%s:%d", webHost, webPort) - url := fmt.Sprintf("http://%s", addr) + baseURL := fmt.Sprintf("http://%s", addr) + + deepPath, err := resolveDeepLink(backend, cmd) + if err != nil { + return err + } + targetURL := baseURL + deepPath + + if serverAlive(baseURL) { + if deepPath != "" { + fmt.Printf("Server already running at %s\n", baseURL) + fmt.Printf("Opening: %s\n", targetURL) + } else { + fmt.Printf("Server already running, opening: %s\n", baseURL) + } + if !webNoOpen { + openBrowser(targetURL) + } + return nil + } - // Initialize database dataDir := config.DataDir() if err := db.Init(dataDir); err != nil { fmt.Printf("Warning: Could not initialize database: %v\n", err) @@ -63,17 +103,167 @@ func runWeb(cmd *cobra.Command, args []string) error { fmt.Printf("Source: %s\n", home) } fmt.Printf("Database: %s\n", dataDir) - fmt.Printf("URL: %s\n\n", url) + if deepPath != "" { + fmt.Printf("URL: %s\n\n", targetURL) + } else { + fmt.Printf("URL: %s\n\n", baseURL) + } if !webNoOpen { - go func() { - openBrowser(url) - }() + openBrowser(targetURL) } return web.Serve(addr, backend) } +func resolveDeepLink(backend provider.Backend, cmd *cobra.Command) (string, error) { + projectFlagSet := cmd.Flags().Changed("project") + + switch { + case webSession != "": + return resolveSessionLink(backend, webSession) + case webLatest: + return resolveLatestLink(backend) + case projectFlagSet: + return resolveProjectLink(backend, webProject) + default: + return "", nil + } +} + +func resolveProjectLink(backend provider.Backend, path string) (string, error) { + if path == "" || path == "." { + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("--project: %w", err) + } + path = cwd + } + + project, err := findProjectByPath(backend, path) + if err != nil { + return "", fmt.Errorf("--project: %w", err) + } + if project == nil { + return "", fmt.Errorf("--project: no project found for %s", path) + } + + return "/project/" + project.EncodedName, nil +} + +func resolveSessionLink(backend provider.Backend, sessionID string) (string, error) { + projects, err := backend.DiscoverProjects() + if err != nil { + return "", fmt.Errorf("--session: %w", err) + } + + // Collect all sessions, try exact then prefix match + var allSessions []sessionWithProject + for _, p := range projects { + for _, s := range p.Sessions { + allSessions = append(allSessions, sessionWithProject{session: s, project: p}) + } + } + + // Exact match first + for _, sp := range allSessions { + if sp.session.ID == sessionID { + return "/session/" + sp.project.EncodedName + "/" + sp.session.ID, nil + } + } + + // Prefix match + var matches []sessionWithProject + for _, sp := range allSessions { + if strings.HasPrefix(sp.session.ID, sessionID) { + matches = append(matches, sp) + } + } + + switch len(matches) { + case 0: + return "", fmt.Errorf("--session: no session matches %q", sessionID) + case 1: + sp := matches[0] + return "/session/" + sp.project.EncodedName + "/" + sp.session.ID, nil + default: + var ids []string + for _, sp := range matches { + short := sp.session.ID + if len(short) > 12 { + short = short[:12] + } + ids = append(ids, short) + } + return "", fmt.Errorf("--session: %q is ambiguous, matches %d sessions: %s", + sessionID, len(matches), strings.Join(ids, ", ")) + } +} + +type sessionWithProject struct { + session *parser.Session + project *parser.Project +} + +func resolveLatestLink(backend provider.Backend) (string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("--latest: %w", err) + } + + project, err := findProjectByPath(backend, cwd) + if err != nil { + return "", fmt.Errorf("--latest: %w", err) + } + if project == nil { + return "", fmt.Errorf("--latest: no project found for current workspace") + } + + if len(project.Sessions) == 0 { + return "", fmt.Errorf("--latest: project has no sessions") + } + + latest := project.Sessions[0] + for _, s := range project.Sessions[1:] { + if s.EndTime.After(latest.EndTime) { + latest = s + } + } + + return "/session/" + project.EncodedName + "/" + latest.ID, nil +} + +func findProjectByPath(backend provider.Backend, path string) (*parser.Project, error) { + projects, err := backend.DiscoverProjects() + if err != nil { + return nil, err + } + + for _, p := range projects { + if catalog.ProjectMatchesWorkspace(p, path) { + return p, nil + } + } + + for _, p := range projects { + if catalog.ProjectMatchesName(p, path) { + return p, nil + } + } + + return nil, nil +} + +func serverAlive(baseURL string) bool { + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Get(baseURL + "/api/stats") + if err != nil { + return false + } + resp.Body.Close() + return resp.StatusCode == http.StatusOK +} + func openBrowser(url string) { var cmd *exec.Cmd switch runtime.GOOS { diff --git a/internal/cmd/web_test.go b/internal/cmd/web_test.go new file mode 100644 index 0000000..b7b4a38 --- /dev/null +++ b/internal/cmd/web_test.go @@ -0,0 +1,310 @@ +package cmd + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/thevibeworks/ccx/internal/catalog" + "github.com/thevibeworks/ccx/internal/parser" +) + +type stubBackend struct { + projects []*parser.Project + err error +} + +func (s *stubBackend) ID() string { return "stub" } +func (s *stubBackend) Homes() []string { return nil } +func (s *stubBackend) DiscoverProjects() ([]*parser.Project, error) { + return s.projects, s.err +} +func (s *stubBackend) ListSessions(_ catalog.SessionQuery) ([]*parser.Session, error) { + return nil, nil +} +func (s *stubBackend) FindProject(_ string) (*parser.Project, error) { return nil, nil } +func (s *stubBackend) FindSession(_, _ string) (*parser.Session, error) { return nil, nil } +func (s *stubBackend) ParseSession(_ string) (*parser.Session, error) { return nil, nil } + +func testProjects() []*parser.Project { + now := time.Now() + return []*parser.Project{ + { + Name: "my-project", + EncodedName: "-Users-eric-my-project", + Path: "/Users/eric/my-project", + Sessions: []*parser.Session{ + { + ID: "aaaa1111-2222-3333-4444-555555555555", + CWD: "/Users/eric/my-project", + EndTime: now.Add(-1 * time.Hour), + }, + { + ID: "bbbb1111-2222-3333-4444-555555555555", + CWD: "/Users/eric/my-project", + EndTime: now, + }, + { + ID: "aaaa2222-2222-3333-4444-555555555555", + CWD: "/Users/eric/my-project", + EndTime: now.Add(-2 * time.Hour), + }, + }, + }, + { + Name: "other-repo", + EncodedName: "-Users-eric-other-repo", + Path: "/Users/eric/other-repo", + Sessions: []*parser.Session{ + { + ID: "cccc1111-2222-3333-4444-555555555555", + CWD: "/Users/eric/other-repo", + EndTime: now.Add(-30 * time.Minute), + }, + }, + }, + } +} + +// --- resolveSessionLink --- + +func TestResolveSessionLink_ExactMatch(t *testing.T) { + backend := &stubBackend{projects: testProjects()} + path, err := resolveSessionLink(backend, "bbbb1111-2222-3333-4444-555555555555") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := "/session/-Users-eric-my-project/bbbb1111-2222-3333-4444-555555555555" + if path != want { + t.Fatalf("got %q, want %q", path, want) + } +} + +func TestResolveSessionLink_ShortPrefix(t *testing.T) { + backend := &stubBackend{projects: testProjects()} + path, err := resolveSessionLink(backend, "bbbb1111") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := "/session/-Users-eric-my-project/bbbb1111-2222-3333-4444-555555555555" + if path != want { + t.Fatalf("got %q, want %q", path, want) + } +} + +func TestResolveSessionLink_AmbiguousPrefix(t *testing.T) { + backend := &stubBackend{projects: testProjects()} + _, err := resolveSessionLink(backend, "aaaa") + if err == nil { + t.Fatal("expected ambiguity error, got nil") + } + if !strings.Contains(err.Error(), "ambiguous") { + t.Fatalf("expected ambiguous error, got: %v", err) + } +} + +func TestResolveSessionLink_NoMatch(t *testing.T) { + backend := &stubBackend{projects: testProjects()} + _, err := resolveSessionLink(backend, "zzzz9999") + if err == nil { + t.Fatal("expected error for non-existent session") + } + if !strings.Contains(err.Error(), "no session matches") { + t.Fatalf("expected 'no session matches', got: %v", err) + } +} + +func TestResolveSessionLink_CrossProject(t *testing.T) { + backend := &stubBackend{projects: testProjects()} + path, err := resolveSessionLink(backend, "cccc1111") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := "/session/-Users-eric-other-repo/cccc1111-2222-3333-4444-555555555555" + if path != want { + t.Fatalf("got %q, want %q", path, want) + } +} + +func TestResolveSessionLink_EmptyProjects(t *testing.T) { + backend := &stubBackend{projects: nil} + _, err := resolveSessionLink(backend, "anything") + if err == nil { + t.Fatal("expected error for empty project list") + } + if !strings.Contains(err.Error(), "no session matches") { + t.Fatalf("expected 'no session matches', got: %v", err) + } +} + +func TestResolveSessionLink_BackendError(t *testing.T) { + backend := &stubBackend{err: fmt.Errorf("disk on fire")} + _, err := resolveSessionLink(backend, "anything") + if err == nil { + t.Fatal("expected error propagation") + } + if !strings.Contains(err.Error(), "disk on fire") { + t.Fatalf("expected wrapped backend error, got: %v", err) + } +} + +// --- resolveLatestLink --- + +func TestResolveLatestLink_PicksNewest(t *testing.T) { + now := time.Now() + projects := []*parser.Project{ + { + Name: "test-proj", + EncodedName: "-tmp-test-proj", + Path: "/tmp/test-proj", + Sessions: []*parser.Session{ + {ID: "old-session", CWD: "/tmp/test-proj", EndTime: now.Add(-2 * time.Hour)}, + {ID: "newest-session", CWD: "/tmp/test-proj", EndTime: now}, + {ID: "mid-session", CWD: "/tmp/test-proj", EndTime: now.Add(-1 * time.Hour)}, + }, + }, + } + backend := &stubBackend{projects: projects} + + // findProjectByPath uses CWD matching, so we need to test via + // resolveLatestLink indirectly. Instead test the pick-newest logic + // by calling findProjectByPath + the latest logic directly. + project, err := findProjectByPath(backend, "/tmp/test-proj") + if err != nil { + t.Fatalf("findProjectByPath error: %v", err) + } + if project == nil { + t.Fatal("expected project, got nil") + } + + // Verify the original session order is preserved + origOrder := make([]string, len(project.Sessions)) + for i, s := range project.Sessions { + origOrder[i] = s.ID + } + + // Simulate resolveLatestLink logic (finds newest without mutating) + latest := project.Sessions[0] + for _, s := range project.Sessions[1:] { + if s.EndTime.After(latest.EndTime) { + latest = s + } + } + + if latest.ID != "newest-session" { + t.Fatalf("expected newest-session, got %s", latest.ID) + } + + // Verify session order was NOT mutated + for i, s := range project.Sessions { + if s.ID != origOrder[i] { + t.Fatalf("session order mutated: position %d is %s, was %s", i, s.ID, origOrder[i]) + } + } +} + +func TestResolveLatestLink_NoSessions(t *testing.T) { + projects := []*parser.Project{ + { + Name: "empty-proj", + EncodedName: "-tmp-empty-proj", + Path: "/tmp/empty-proj", + Sessions: nil, + }, + } + backend := &stubBackend{projects: projects} + + project, err := findProjectByPath(backend, "/tmp/empty-proj") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if project == nil { + t.Fatal("expected project, got nil") + } + if len(project.Sessions) != 0 { + t.Fatalf("expected 0 sessions, got %d", len(project.Sessions)) + } +} + +// --- findProjectByPath --- + +func TestFindProjectByPath_WorkspaceMatch(t *testing.T) { + backend := &stubBackend{projects: testProjects()} + project, err := findProjectByPath(backend, "/Users/eric/my-project") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if project == nil { + t.Fatal("expected project, got nil") + } + if project.Name != "my-project" { + t.Fatalf("got project %q, want my-project", project.Name) + } +} + +func TestFindProjectByPath_NameFallback(t *testing.T) { + backend := &stubBackend{projects: testProjects()} + project, err := findProjectByPath(backend, "my-project") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if project == nil { + t.Fatal("expected project from name fallback, got nil") + } + if project.Name != "my-project" { + t.Fatalf("got project %q, want my-project", project.Name) + } +} + +func TestFindProjectByPath_NoMatch(t *testing.T) { + backend := &stubBackend{projects: testProjects()} + project, err := findProjectByPath(backend, "/nonexistent/path") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if project != nil { + t.Fatalf("expected nil, got project %q", project.Name) + } +} + +func TestFindProjectByPath_BackendError(t *testing.T) { + backend := &stubBackend{err: fmt.Errorf("storage unavailable")} + _, err := findProjectByPath(backend, "/any/path") + if err == nil { + t.Fatal("expected error propagation") + } + if !strings.Contains(err.Error(), "storage unavailable") { + t.Fatalf("expected wrapped error, got: %v", err) + } +} + +// --- serverAlive --- + +func TestServerAlive_Running(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/stats" { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"projects":1}`) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + if !serverAlive(srv.URL) { + t.Fatal("expected serverAlive=true for running server") + } +} + +func TestServerAlive_NotRunning(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + deadURL := srv.URL + srv.Close() + + if serverAlive(deadURL) { + t.Fatal("expected serverAlive=false for closed server") + } +}