diff --git a/backend/internal/cli/preview.go b/backend/internal/cli/preview.go index 527efb30..b57dd48a 100644 --- a/backend/internal/cli/preview.go +++ b/backend/internal/cli/preview.go @@ -23,8 +23,7 @@ func newPreviewCommand(ctx *commandContext) *cobra.Command { Use: "preview [url]", Short: "Open a URL (or the workspace's index.html) in the desktop browser panel for the current session", Long: "Open a URL in the desktop browser panel for the current session.\n\n" + - "With no argument it re-opens whatever this session last previewed,\n" + - "falling back to the workspace's index.html. A workspace-relative path\n" + + "With no argument it opens the workspace's index.html. A workspace-relative path\n" + "(e.g. ./dist/index.html) is served as a local file. Use `ao preview\n" + "clear` to empty the panel.", Args: cobra.MaximumNArgs(1), diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index 7ea50c3a..59791c86 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -18,6 +18,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/httpd" "github.com/aoagents/agent-orchestrator/backend/internal/notify" "github.com/aoagents/agent-orchestrator/backend/internal/ports" + "github.com/aoagents/agent-orchestrator/backend/internal/preview" "github.com/aoagents/agent-orchestrator/backend/internal/runfile" notificationsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/notification" projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" @@ -127,6 +128,7 @@ func Run() error { } return fmt.Errorf("wire session service: %w", err) } + previewDone := preview.NewPoller(store, sessionSvc, "http://"+cfg.Addr(), preview.PollerConfig{Logger: log}).Start(ctx) srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{ Projects: projectsvc.NewWithDeps(projectsvc.Deps{Store: store, Sessions: sessionSvc, Telemetry: telemetrySink}), @@ -141,6 +143,7 @@ func Run() error { }) if err != nil { stop() + <-previewDone lcStack.Stop() if cdcErr := cdcPipe.Stop(); cdcErr != nil { log.Error("cdc pipeline shutdown", "err", cdcErr) @@ -155,6 +158,7 @@ func Run() error { // via defer) avoids the LIFO trap where a Stop() that blocks on ctx-cancel // runs before the cancel — which would hang any non-signal exit path. stop() + <-previewDone lcStack.Stop() if err := cdcPipe.Stop(); err != nil { log.Error("cdc pipeline shutdown", "err", err) diff --git a/backend/internal/httpd/controllers/sessions.go b/backend/internal/httpd/controllers/sessions.go index 31d899ad..d10e72d4 100644 --- a/backend/internal/httpd/controllers/sessions.go +++ b/backend/internal/httpd/controllers/sessions.go @@ -17,6 +17,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" "github.com/aoagents/agent-orchestrator/backend/internal/ports" + previewutil "github.com/aoagents/agent-orchestrator/backend/internal/preview" sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" ) @@ -25,6 +26,8 @@ const ( maxMessageLen = 4096 ) +var errPreviewFileNotFound = errors.New("preview file not found") + // SessionService is the controller-facing session service contract. type SessionService interface { List(ctx context.Context, filter sessionsvc.ListFilter) ([]domain.Session, error) @@ -182,10 +185,9 @@ func (c *SessionsController) previewFile(w http.ResponseWriter, r *http.Request) // session and fans out a session_updated CDC event so the dashboard's browser // panel reacts live. The target is resolved as follows: // -// - An empty url reuses the session's existing preview target (so a bare -// `ao preview` re-opens whatever this agent/context last previewed), -// falling back to autodetecting a static entry point (index.html and -// friends) only when nothing has been previewed yet. +// - An empty url opens the workspace's static entry point (index.html and +// friends), falling back to the session's existing preview target only +// when no entry point exists. // - An explicit workspace-local path (e.g. `index.html`, `./dist/index.html`) // is served through the preview/files route so local files load. // - Anything else (http(s)/file URLs, host:port dev servers) is kept verbatim. @@ -212,16 +214,26 @@ func (c *SessionsController) setPreview(w http.ResponseWriter, r *http.Request) // ponytail: no URL sanitization on preview target; agent-trusted for now previewURL := strings.TrimSpace(in.URL) if previewURL == "" { - if existing := strings.TrimSpace(sess.Metadata.PreviewURL); existing != "" { - previewURL = existing - } else if entry, ok := discoverPreviewEntry(sess.Metadata.WorkspacePath); ok { + if entry, ok := discoverPreviewEntry(sess.Metadata.WorkspacePath); ok { previewURL = previewFileURL(r, sessionID(r), entry) + } else if existing := strings.TrimSpace(sess.Metadata.PreviewURL); existing != "" { + var resolveErr error + previewURL, resolveErr = resolvePreviewTarget(r, sessionID(r), sess.Metadata.WorkspacePath, existing) + if resolveErr != nil { + writePreviewResolveError(w, r, resolveErr) + return + } } else { envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "NO_PREVIEW_ENTRY", "No preview entry point found in session workspace", nil) return } - } else if resolved, ok := resolveLocalPreview(r, sessionID(r), sess.Metadata.WorkspacePath, previewURL); ok { - previewURL = resolved + } else { + var resolveErr error + previewURL, resolveErr = resolvePreviewTarget(r, sessionID(r), sess.Metadata.WorkspacePath, previewURL) + if resolveErr != nil { + writePreviewResolveError(w, r, resolveErr) + return + } } updated, err := c.Svc.SetPreview(r.Context(), sessionID(r), previewURL) if err != nil { @@ -544,20 +556,8 @@ func writeSessionPRError(w http.ResponseWriter, r *http.Request, err error) { } func discoverPreviewEntry(workspacePath string) (string, bool) { - if strings.TrimSpace(workspacePath) == "" { - return "", false - } - for _, candidate := range []string{"index.html", "public/index.html", "dist/index.html", "build/index.html"} { - file, ok := confinedPreviewPath(workspacePath, candidate) - if !ok { - continue - } - info, err := os.Stat(file) - if err == nil && !info.IsDir() { - return candidate, true - } - } - return "", false + entry, ok := previewutil.DiscoverEntry(workspacePath) + return entry.Path, ok } // resolveLocalPreview maps a workspace-local path (e.g. "index.html" or @@ -583,6 +583,49 @@ func resolveLocalPreview(r *http.Request, id domain.SessionID, workspacePath, ra return previewFileURL(r, id, entry), true } +func resolvePreviewTarget(r *http.Request, id domain.SessionID, workspacePath, raw string) (string, error) { + raw = strings.TrimSpace(raw) + if isAbsolutePreviewPath(raw) { + return absolutePreviewFileURL(raw) + } + if resolved, ok := resolveLocalPreview(r, id, workspacePath, raw); ok { + return resolved, nil + } + return raw, nil +} + +func isAbsolutePreviewPath(raw string) bool { + return filepath.IsAbs(raw) || isWindowsAbsolutePath(raw) +} + +func isWindowsAbsolutePath(raw string) bool { + return len(raw) >= 3 && ((raw[0] >= 'a' && raw[0] <= 'z') || (raw[0] >= 'A' && raw[0] <= 'Z')) && raw[1] == ':' && (raw[2] == '\\' || raw[2] == '/') +} + +func absolutePreviewFileURL(raw string) (string, error) { + file, err := filepath.Abs(raw) + if err != nil { + return "", errPreviewFileNotFound + } + info, err := os.Stat(file) + if err != nil || info.IsDir() { + return "", errPreviewFileNotFound + } + filePath := filepath.ToSlash(file) + if filepath.VolumeName(file) != "" || isWindowsAbsolutePath(filePath) { + filePath = "/" + filePath + } + return (&url.URL{Scheme: "file", Path: filePath}).String(), nil +} + +func writePreviewResolveError(w http.ResponseWriter, r *http.Request, err error) { + if errors.Is(err, errPreviewFileNotFound) { + envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PREVIEW_FILE_NOT_FOUND", "Preview file not found", nil) + return + } + envelope.WriteError(w, r, err) +} + // hasURLScheme reports whether raw begins with an RFC-3986 "scheme:" prefix // (http:, https:, file:, or a host:port like localhost:5173). It mirrors the // renderer's withDefaultScheme heuristic so the daemon and browser panel agree @@ -602,41 +645,11 @@ func hasURLScheme(raw string) bool { } func confinedPreviewPath(workspacePath, assetPath string) (string, bool) { - root, err := filepath.Abs(workspacePath) - if err != nil || root == "" { - return "", false - } - clean := strings.TrimPrefix(path.Clean("/"+strings.TrimSpace(assetPath)), "/") - if clean == "" || clean == "." { - clean = "index.html" - } - file := filepath.Join(root, filepath.FromSlash(clean)) - absFile, err := filepath.Abs(file) - if err != nil { - return "", false - } - rel, err := filepath.Rel(root, absFile) - if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." { - return "", false - } - return absFile, true + return previewutil.ConfinedPath(workspacePath, assetPath) } func previewFileURL(r *http.Request, id domain.SessionID, entry string) string { - u := url.URL{ - Scheme: "http", - Host: r.Host, - Path: "/api/v1/sessions/" + url.PathEscape(string(id)) + "/preview/files/" + escapePath(entry), - } - return u.String() -} - -func escapePath(raw string) string { - parts := strings.Split(raw, "/") - for i, part := range parts { - parts[i] = url.PathEscape(part) - } - return strings.Join(parts, "/") + return previewutil.FileURL("http://"+r.Host, id, entry) } func sessionView(s domain.Session) SessionView { diff --git a/backend/internal/httpd/controllers/sessions_test.go b/backend/internal/httpd/controllers/sessions_test.go index 1cafa64f..ee27405b 100644 --- a/backend/internal/httpd/controllers/sessions_test.go +++ b/backend/internal/httpd/controllers/sessions_test.go @@ -9,6 +9,7 @@ import ( "net/url" "os" "path/filepath" + "strconv" "strings" "testing" "time" @@ -430,11 +431,11 @@ func TestSessionsAPI_SetPreviewEmptyURLAutodetectsIndex(t *testing.T) { } } -func TestSessionsAPI_SetPreviewEmptyURLReusesExistingTarget(t *testing.T) { +func TestSessionsAPI_SetPreviewEmptyURLPrefersWorkspaceEntryOverExistingTarget(t *testing.T) { svc := newFakeSessionService() workspace := t.TempDir() - // An index.html exists, but the session already has a preview target — the - // bare `ao preview` must reuse that target rather than autodetecting index. + // An index.html exists, so bare `ao preview` returns to the workspace entry + // instead of sticking to the last explicit target. if err := os.WriteFile(filepath.Join(workspace, "index.html"), []byte(``), 0o644); err != nil { t.Fatalf("write index: %v", err) } @@ -443,6 +444,57 @@ func TestSessionsAPI_SetPreviewEmptyURLReusesExistingTarget(t *testing.T) { svc.sessions["ao-1"] = s srv := newSessionTestServer(t, svc) + body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/preview", `{}`) + if status != http.StatusOK { + t.Fatalf("set preview = %d, want 200; body=%s", status, body) + } + var resp struct { + Session struct { + PreviewURL string `json:"previewUrl"` + } `json:"session"` + } + mustJSON(t, body, &resp) + if !strings.HasSuffix(resp.Session.PreviewURL, "/preview/files/index.html") { + t.Fatalf("response previewUrl = %q, want workspace index files URL", resp.Session.PreviewURL) + } +} + +func TestSessionsAPI_SetPreviewEmptyURLNormalizesExistingRelativeTarget(t *testing.T) { + svc := newFakeSessionService() + workspace := t.TempDir() + if err := os.WriteFile(filepath.Join(workspace, "index.html"), []byte(``), 0o644); err != nil { + t.Fatalf("write index: %v", err) + } + s := svc.sessions["ao-1"] + s.Metadata = domain.SessionMetadata{WorkspacePath: workspace, PreviewURL: "index.html"} + svc.sessions["ao-1"] = s + srv := newSessionTestServer(t, svc) + + body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/preview", `{}`) + if status != http.StatusOK { + t.Fatalf("set preview = %d, want 200; body=%s", status, body) + } + var resp struct { + Session struct { + PreviewURL string `json:"previewUrl"` + } `json:"session"` + } + mustJSON(t, body, &resp) + if !strings.HasSuffix(resp.Session.PreviewURL, "/preview/files/index.html") { + t.Fatalf("response previewUrl = %q, want index.html files URL", resp.Session.PreviewURL) + } + if got := svc.sessions["ao-1"].Metadata.PreviewURL; got != resp.Session.PreviewURL { + t.Fatalf("persisted previewUrl = %q, want normalized response URL %q", got, resp.Session.PreviewURL) + } +} + +func TestSessionsAPI_SetPreviewEmptyURLReusesExistingTargetWhenNoEntryExists(t *testing.T) { + svc := newFakeSessionService() + s := svc.sessions["ao-1"] + s.Metadata = domain.SessionMetadata{WorkspacePath: t.TempDir(), PreviewURL: "http://localhost:4321/docs"} + svc.sessions["ao-1"] = s + srv := newSessionTestServer(t, svc) + body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/preview", `{}`) if status != http.StatusOK { t.Fatalf("set preview = %d, want 200; body=%s", status, body) @@ -499,6 +551,50 @@ func TestSessionsAPI_SetPreviewLocalRelativePathResolvesToFilesURL(t *testing.T) } } +func TestSessionsAPI_SetPreviewAbsoluteFilePathPersistsFileURL(t *testing.T) { + svc := newFakeSessionService() + file := filepath.Join(t.TempDir(), "implementation_plan.html") + if err := os.WriteFile(file, []byte(``), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + srv := newSessionTestServer(t, svc) + + body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/preview", `{"url":`+strconv.Quote(file)+`}`) + if status != http.StatusOK { + t.Fatalf("set preview = %d, want 200; body=%s", status, body) + } + var resp struct { + Session struct { + PreviewURL string `json:"previewUrl"` + } `json:"session"` + } + mustJSON(t, body, &resp) + parsed, err := url.Parse(resp.Session.PreviewURL) + if err != nil { + t.Fatalf("parse preview url: %v", err) + } + if parsed.Scheme != "file" { + t.Fatalf("previewUrl = %q, want file URL", resp.Session.PreviewURL) + } +} + +func TestSessionsAPI_SetPreviewMissingAbsoluteFilePathFailsWithoutOverwriting(t *testing.T) { + svc := newFakeSessionService() + missing := filepath.Join(t.TempDir(), "implmentation_plan.html") + s := svc.sessions["ao-1"] + s.Metadata = domain.SessionMetadata{PreviewURL: "http://localhost:4321/docs"} + svc.sessions["ao-1"] = s + srv := newSessionTestServer(t, svc) + + body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/preview", `{"url":`+strconv.Quote(missing)+`}`) + if status != http.StatusNotFound { + t.Fatalf("set missing absolute preview = %d, want 404; body=%s", status, body) + } + if got := svc.sessions["ao-1"].Metadata.PreviewURL; got != "http://localhost:4321/docs" { + t.Fatalf("persisted previewUrl = %q, want existing target preserved", got) + } +} + func TestSessionsAPI_SetPreviewBumpsRevisionOnSameURL(t *testing.T) { svc := newFakeSessionService() srv := newSessionTestServer(t, svc) diff --git a/backend/internal/preview/entry.go b/backend/internal/preview/entry.go new file mode 100644 index 00000000..2cc3a60c --- /dev/null +++ b/backend/internal/preview/entry.go @@ -0,0 +1,96 @@ +package preview + +import ( + "net/url" + "os" + "path" + "path/filepath" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +var entryCandidates = []string{"index.html", "public/index.html", "dist/index.html", "build/index.html"} + +// Entry is a workspace-local static frontend entrypoint. +type Entry struct { + Path string + AbsPath string + ModTime time.Time + Size int64 +} + +// DiscoverEntry returns the first supported HTML entrypoint that exists inside +// the workspace. +func DiscoverEntry(workspacePath string) (Entry, bool) { + if strings.TrimSpace(workspacePath) == "" { + return Entry{}, false + } + for _, candidate := range entryCandidates { + file, ok := ConfinedPath(workspacePath, candidate) + if !ok { + continue + } + info, err := os.Stat(file) + if err == nil && !info.IsDir() { + return Entry{Path: candidate, AbsPath: file, ModTime: info.ModTime(), Size: info.Size()}, true + } + } + return Entry{}, false +} + +// ConfinedPath maps an asset path into workspacePath and rejects paths that +// escape the workspace root. +func ConfinedPath(workspacePath, assetPath string) (string, bool) { + root, err := filepath.Abs(workspacePath) + if err != nil || root == "" { + return "", false + } + clean := strings.TrimPrefix(path.Clean("/"+strings.TrimSpace(assetPath)), "/") + if clean == "" || clean == "." { + clean = "index.html" + } + file := filepath.Join(root, filepath.FromSlash(clean)) + absFile, err := filepath.Abs(file) + if err != nil { + return "", false + } + rel, err := filepath.Rel(root, absFile) + if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." { + return "", false + } + return absFile, true +} + +// FileURL builds the daemon preview/files URL for a workspace-local entry. +func FileURL(baseURL string, id domain.SessionID, entry string) string { + u := normalizedBaseURL(baseURL) + u.Path = "/api/v1/sessions/" + url.PathEscape(string(id)) + "/preview/files/" + escapePath(entry) + u.RawQuery = "" + u.Fragment = "" + return u.String() +} + +func normalizedBaseURL(raw string) url.URL { + raw = strings.TrimRight(strings.TrimSpace(raw), "/") + if raw == "" { + raw = "http://127.0.0.1:3001" + } + if !strings.Contains(raw, "://") { + raw = "http://" + raw + } + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return url.URL{Scheme: "http", Host: raw} + } + return *u +} + +func escapePath(raw string) string { + parts := strings.Split(raw, "/") + for i, part := range parts { + parts[i] = url.PathEscape(part) + } + return strings.Join(parts, "/") +} diff --git a/backend/internal/preview/poller.go b/backend/internal/preview/poller.go new file mode 100644 index 00000000..6825840e --- /dev/null +++ b/backend/internal/preview/poller.go @@ -0,0 +1,178 @@ +package preview + +import ( + "context" + "fmt" + "log/slog" + "net/url" + "path/filepath" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// DefaultPollInterval is the preview poller's scan interval when none is configured. +const DefaultPollInterval = 250 * time.Millisecond + +type sessionPreviewSource interface { + ListAllSessions(ctx context.Context) ([]domain.SessionRecord, error) +} + +type previewSetter interface { + SetPreview(ctx context.Context, id domain.SessionID, previewURL string) (domain.Session, error) +} + +// PollerConfig configures preview poller timing and logging. +type PollerConfig struct { + Interval time.Duration + Logger *slog.Logger +} + +// Poller watches active worker workspaces for static frontend entrypoints and +// persists preview URL refreshes through the normal session service path. +type Poller struct { + source sessionPreviewSource + setter previewSetter + baseURL string + interval time.Duration + logger *slog.Logger + seen map[domain.SessionID]entryState +} + +type entryState struct { + path string + modUnix int64 + size int64 +} + +// NewPoller constructs a preview poller over the supplied session source and setter. +func NewPoller(source sessionPreviewSource, setter previewSetter, baseURL string, cfg PollerConfig) *Poller { + p := &Poller{ + source: source, + setter: setter, + baseURL: baseURL, + interval: cfg.Interval, + logger: cfg.Logger, + seen: map[domain.SessionID]entryState{}, + } + if p.interval <= 0 { + p.interval = DefaultPollInterval + } + if p.logger == nil { + p.logger = slog.Default() + } + return p +} + +// Start runs an immediate poll followed by interval polling until ctx is +// cancelled. The returned channel closes after the goroutine exits. +func (p *Poller) Start(ctx context.Context) <-chan struct{} { + done := make(chan struct{}) + go func() { + defer close(done) + p.pollAndLog(ctx) + ticker := time.NewTicker(p.interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + p.pollAndLog(ctx) + } + } + }() + return done +} + +func (p *Poller) pollAndLog(ctx context.Context) { + if err := p.Poll(ctx); err != nil { + p.logger.Error("preview poller: poll failed", "err", err) + } +} + +// Poll performs one deterministic scan of active worker sessions. +func (p *Poller) Poll(ctx context.Context) error { + if p.source == nil || p.setter == nil { + return nil + } + sessions, err := p.source.ListAllSessions(ctx) + if err != nil { + return fmt.Errorf("preview poller list sessions: %w", err) + } + activeIDs := make(map[domain.SessionID]struct{}, len(sessions)) + for _, sess := range sessions { + if sess.IsTerminated { + continue + } + activeIDs[sess.ID] = struct{}{} + if sess.Kind != domain.KindWorker { + continue + } + entry, ok := DiscoverEntry(sess.Metadata.WorkspacePath) + if !ok { + continue + } + state := stateFor(entry) + previous, seenBefore := p.seen[sess.ID] + if seenBefore && previous == state { + continue + } + target := FileURL(p.baseURL, sess.ID, entry.Path) + if !p.shouldRefresh(sess, target, seenBefore) { + p.seen[sess.ID] = state + continue + } + if _, err := p.setter.SetPreview(ctx, sess.ID, target); err != nil { + return fmt.Errorf("preview poller set preview %s: %w", sess.ID, err) + } + p.seen[sess.ID] = state + } + for id := range p.seen { + if _, ok := activeIDs[id]; !ok { + delete(p.seen, id) + } + } + return nil +} + +func (p *Poller) shouldRefresh(sess domain.SessionRecord, target string, seenBefore bool) bool { + current := strings.TrimSpace(sess.Metadata.PreviewURL) + if current == "" { + return !seenBefore && sess.Metadata.PreviewRevision == 0 + } + if current == target || isWorkspacePreviewURL(current, sess.ID) { + return true + } + return isStaleWorkspacePath(current) +} + +func stateFor(entry Entry) entryState { + return entryState{path: entry.Path, modUnix: entry.ModTime.UnixNano(), size: entry.Size} +} + +func isWorkspacePreviewURL(raw string, id domain.SessionID) bool { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return false + } + previewPath := parsed.Path + if previewPath == "" { + previewPath = raw + } + prefix := "/api/v1/sessions/" + url.PathEscape(string(id)) + "/preview/files/" + return strings.HasPrefix(previewPath, prefix) +} + +func isStaleWorkspacePath(raw string) bool { + raw = strings.TrimSpace(raw) + if raw == "" || strings.Contains(raw, "://") || filepath.IsAbs(raw) || isWindowsAbs(raw) { + return false + } + return !strings.Contains(raw, ":") +} + +func isWindowsAbs(raw string) bool { + return len(raw) >= 3 && ((raw[0] >= 'a' && raw[0] <= 'z') || (raw[0] >= 'A' && raw[0] <= 'Z')) && raw[1] == ':' && (raw[2] == '\\' || raw[2] == '/') +} diff --git a/backend/internal/preview/poller_test.go b/backend/internal/preview/poller_test.go new file mode 100644 index 00000000..c9427eaf --- /dev/null +++ b/backend/internal/preview/poller_test.go @@ -0,0 +1,209 @@ +package preview + +import ( + "context" + "io" + "log/slog" + "os" + "path/filepath" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +type fakePreviewSessions struct { + sessions []domain.SessionRecord + sets []previewSet +} + +type previewSet struct { + id domain.SessionID + url string +} + +func (f *fakePreviewSessions) ListAllSessions(_ context.Context) ([]domain.SessionRecord, error) { + return append([]domain.SessionRecord(nil), f.sessions...), nil +} + +func (f *fakePreviewSessions) SetPreview(_ context.Context, id domain.SessionID, previewURL string) (domain.Session, error) { + f.sets = append(f.sets, previewSet{id: id, url: previewURL}) + for i, sess := range f.sessions { + if sess.ID == id { + sess.Metadata.PreviewURL = previewURL + f.sessions[i] = sess + return domain.Session{SessionRecord: sess}, nil + } + } + return domain.Session{}, nil +} + +func TestPollerSetsPreviewWhenActiveWorkerEntryAppears(t *testing.T) { + workspace := t.TempDir() + writeFile(t, filepath.Join(workspace, "index.html"), "
hello
") + svc := &fakePreviewSessions{sessions: []domain.SessionRecord{workerSession("ao-1", workspace, "")}} + poller := NewPoller(svc, svc, "http://127.0.0.1:3001", PollerConfig{Logger: discardLogger()}) + + if err := poller.Poll(context.Background()); err != nil { + t.Fatalf("Poll: %v", err) + } + + assertSets(t, svc.sets, previewSet{ + id: "ao-1", + url: "http://127.0.0.1:3001/api/v1/sessions/ao-1/preview/files/index.html", + }) +} + +func TestPollerUsesFirstExistingEntrypoint(t *testing.T) { + workspace := t.TempDir() + writeFile(t, filepath.Join(workspace, "dist", "index.html"), "
dist
") + svc := &fakePreviewSessions{sessions: []domain.SessionRecord{workerSession("ao-1", workspace, "")}} + poller := NewPoller(svc, svc, "http://127.0.0.1:3001", PollerConfig{Logger: discardLogger()}) + + if err := poller.Poll(context.Background()); err != nil { + t.Fatalf("Poll: %v", err) + } + + assertSets(t, svc.sets, previewSet{ + id: "ao-1", + url: "http://127.0.0.1:3001/api/v1/sessions/ao-1/preview/files/dist/index.html", + }) +} + +func TestPollerPreservesEntrypointPriority(t *testing.T) { + workspace := t.TempDir() + writeFile(t, filepath.Join(workspace, "public", "index.html"), "
public
") + writeFile(t, filepath.Join(workspace, "dist", "index.html"), "
dist
") + svc := &fakePreviewSessions{sessions: []domain.SessionRecord{workerSession("ao-1", workspace, "")}} + poller := NewPoller(svc, svc, "http://127.0.0.1:3001", PollerConfig{Logger: discardLogger()}) + + if err := poller.Poll(context.Background()); err != nil { + t.Fatalf("Poll: %v", err) + } + + assertSets(t, svc.sets, previewSet{ + id: "ao-1", + url: "http://127.0.0.1:3001/api/v1/sessions/ao-1/preview/files/public/index.html", + }) +} + +func TestPollerRefreshesOnlyWhenEntrypointChanges(t *testing.T) { + workspace := t.TempDir() + entry := filepath.Join(workspace, "index.html") + writeFile(t, entry, "
v1
") + svc := &fakePreviewSessions{sessions: []domain.SessionRecord{workerSession("ao-1", workspace, "")}} + poller := NewPoller(svc, svc, "http://127.0.0.1:3001", PollerConfig{Logger: discardLogger()}) + + if err := poller.Poll(context.Background()); err != nil { + t.Fatalf("first Poll: %v", err) + } + if err := poller.Poll(context.Background()); err != nil { + t.Fatalf("second Poll: %v", err) + } + if len(svc.sets) != 1 { + t.Fatalf("sets after unchanged entry = %#v, want one set", svc.sets) + } + + writeFile(t, entry, "
v2 changed
") + nextMod := time.Now().Add(2 * time.Second) + if err := os.Chtimes(entry, nextMod, nextMod); err != nil { + t.Fatalf("chtimes entry: %v", err) + } + if err := poller.Poll(context.Background()); err != nil { + t.Fatalf("third Poll: %v", err) + } + + if len(svc.sets) != 2 { + t.Fatalf("sets after changed entry = %#v, want refresh set", svc.sets) + } +} + +func TestPollerDoesNotRestoreClearedPreviewAfterRestart(t *testing.T) { + workspace := t.TempDir() + writeFile(t, filepath.Join(workspace, "index.html"), "
hello
") + sess := workerSession("ao-1", workspace, "") + sess.Metadata.PreviewRevision = 2 + svc := &fakePreviewSessions{sessions: []domain.SessionRecord{sess}} + poller := NewPoller(svc, svc, "http://127.0.0.1:3001", PollerConfig{Logger: discardLogger()}) + + if err := poller.Poll(context.Background()); err != nil { + t.Fatalf("Poll: %v", err) + } + + if len(svc.sets) != 0 { + t.Fatalf("sets = %#v, want cleared preview to remain empty after restart", svc.sets) + } +} + +func TestPollerDoesNotOverrideExplicitPreviewTarget(t *testing.T) { + workspace := t.TempDir() + writeFile(t, filepath.Join(workspace, "index.html"), "
hello
") + svc := &fakePreviewSessions{sessions: []domain.SessionRecord{workerSession("ao-1", workspace, "file:///C:/tmp/other.html")}} + poller := NewPoller(svc, svc, "http://127.0.0.1:3001", PollerConfig{Logger: discardLogger()}) + + if err := poller.Poll(context.Background()); err != nil { + t.Fatalf("Poll: %v", err) + } + + if len(svc.sets) != 0 { + t.Fatalf("sets = %#v, want no automatic override", svc.sets) + } +} + +func TestPollerSkipsNonWorkerSessions(t *testing.T) { + workspace := t.TempDir() + writeFile(t, filepath.Join(workspace, "index.html"), "
hello
") + svc := &fakePreviewSessions{sessions: []domain.SessionRecord{{ + ID: "ao-orch", + Kind: domain.KindOrchestrator, + Metadata: domain.SessionMetadata{ + WorkspacePath: workspace, + }, + }}} + poller := NewPoller(svc, svc, "http://127.0.0.1:3001", PollerConfig{Logger: discardLogger()}) + + if err := poller.Poll(context.Background()); err != nil { + t.Fatalf("Poll: %v", err) + } + + if len(svc.sets) != 0 { + t.Fatalf("sets = %#v, want no preview updates for orchestrator sessions", svc.sets) + } +} + +func workerSession(id domain.SessionID, workspace, previewURL string) domain.SessionRecord { + return domain.SessionRecord{ + ID: id, + Kind: domain.KindWorker, + Metadata: domain.SessionMetadata{ + WorkspacePath: workspace, + PreviewURL: previewURL, + }, + } +} + +func writeFile(t *testing.T, path string, contents string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func assertSets(t *testing.T, got []previewSet, want ...previewSet) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("sets = %#v, want %#v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("sets[%d] = %#v, want %#v", i, got[i], want[i]) + } + } +} + +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} diff --git a/frontend/src/main/browser-view-host.test.ts b/frontend/src/main/browser-view-host.test.ts index d1e70db2..2eacf168 100644 --- a/frontend/src/main/browser-view-host.test.ts +++ b/frontend/src/main/browser-view-host.test.ts @@ -76,6 +76,14 @@ describe("normalizeBrowserURL", () => { expect(normalizeBrowserURL("file:///C:/tmp/index.html").protocol).toBe("file:"); }); + it("converts absolute local file paths to file URLs", () => { + expect(normalizeBrowserURL("C:\\Users\\Lenovo\\Downloads\\sm5\\paper_explainer.html").href).toBe( + "file:///C:/Users/Lenovo/Downloads/sm5/paper_explainer.html", + ); + expect(normalizeBrowserURL("C:/Users/Lenovo/My File.html").href).toBe("file:///C:/Users/Lenovo/My%20File.html"); + expect(normalizeBrowserURL("/tmp/preview/index.html").href).toBe("file:///tmp/preview/index.html"); + }); + it("rejects privileged or unsupported schemes", () => { expect(() => normalizeBrowserURL("app://renderer/index.html")).toThrow(/unsupported/i); expect(() => normalizeBrowserURL("javascript:alert(1)")).toThrow(/unsupported/i); diff --git a/frontend/src/main/browser-view-host.ts b/frontend/src/main/browser-view-host.ts index e6c373f7..79653038 100644 --- a/frontend/src/main/browser-view-host.ts +++ b/frontend/src/main/browser-view-host.ts @@ -249,12 +249,33 @@ export function createBrowserViewHost(options: BrowserViewHostOptions): BrowserV } function withDefaultScheme(raw: string): string { + if (isWindowsAbsolutePath(raw) || isPosixAbsolutePath(raw)) return localPathToFileURL(raw); if (/^https?:\/\//i.test(raw)) return raw; if (isLocalhostLike(raw)) return `http://${raw}`; if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(raw)) return raw; return `https://${raw}`; } +function isWindowsAbsolutePath(raw: string): boolean { + return /^[a-zA-Z]:[\\/]/.test(raw); +} + +function isPosixAbsolutePath(raw: string): boolean { + return raw.startsWith("/"); +} + +function localPathToFileURL(raw: string): string { + if (isWindowsAbsolutePath(raw)) { + const normalized = raw.replace(/\\/g, "/"); + return `file:///${encodePathSegments(normalized).replace(/^([A-Za-z])%3A(?=\/)/, "$1:")}`; + } + return `file://${encodePathSegments(raw)}`; +} + +function encodePathSegments(pathname: string): string { + return pathname.split("/").map(encodeURIComponent).join("/"); +} + function isLocalhostLike(raw: string): boolean { return /^(localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[::1\])(?::\d+)?(?:[/?#]|$)/i.test(raw); } diff --git a/frontend/src/renderer/hooks/useBrowserView.test.tsx b/frontend/src/renderer/hooks/useBrowserView.test.tsx index ef0e8c9e..cc6d2455 100644 --- a/frontend/src/renderer/hooks/useBrowserView.test.tsx +++ b/frontend/src/renderer/hooks/useBrowserView.test.tsx @@ -179,6 +179,34 @@ describe("useBrowserView", () => { expect(bridge.navigate).toHaveBeenCalledTimes(3); }); + it("navigates legacy preview URLs when the daemon omits preview revisions", async () => { + const bridge = setupBridge(); + const { result, rerender } = renderHook( + ({ previewUrl }) => useBrowserView({ sessionId: "sess-1", active: true, poppedOut: false, previewUrl }), + { initialProps: { previewUrl: undefined as string | undefined } }, + ); + await waitFor(() => expect(result.current.viewId).toBe("42:sess-1")); + expect(bridge.navigate).not.toHaveBeenCalled(); + + rerender({ previewUrl: "http://localhost:5173/" }); + await waitFor(() => + expect(bridge.navigate).toHaveBeenCalledWith({ viewId: "42:sess-1", url: "http://localhost:5173/" }), + ); + expect(bridge.navigate).toHaveBeenCalledTimes(1); + + rerender({ previewUrl: "http://localhost:5173/" }); + expect(bridge.navigate).toHaveBeenCalledTimes(1); + + rerender({ previewUrl: "C:\\Users\\Lenovo\\Downloads\\sm5\\paper_explainer.html" }); + await waitFor(() => + expect(bridge.navigate).toHaveBeenCalledWith({ + viewId: "42:sess-1", + url: "C:\\Users\\Lenovo\\Downloads\\sm5\\paper_explainer.html", + }), + ); + expect(bridge.navigate).toHaveBeenCalledTimes(2); + }); + it("clears the view when the preview is reset (ao preview clear) and does not navigate", async () => { const bridge = setupBridge(); const { rerender } = renderHook( diff --git a/frontend/src/renderer/hooks/useBrowserView.ts b/frontend/src/renderer/hooks/useBrowserView.ts index 0788c6c3..a97c111f 100644 --- a/frontend/src/renderer/hooks/useBrowserView.ts +++ b/frontend/src/renderer/hooks/useBrowserView.ts @@ -58,7 +58,7 @@ export function useBrowserView({ const activeRef = useRef(active); const frameRef = useRef(null); const observerRef = useRef(null); - const previewRevisionRef = useRef(null); + const previewTriggerRef = useRef<{ revision: number | null; target: string } | null>(null); useEffect(() => { activeRef.current = active; @@ -182,21 +182,20 @@ export function useBrowserView({ const clear = useCallback(() => withView((id) => window.ao!.browser.clear(id)), [withView]); - // Drive the view from the daemon-set preview target, keyed on the preview - // revision (bumped on every `ao preview` call). Acting on the revision rather - // than the URL means a repeated `ao preview ` still refreshes, an - // `ao preview clear` (empty URL) blanks the view, and CDC replays of - // unrelated session updates (revision unchanged) are ignored — so the panel - // never reloads on an unrelated activity flip. + // Drive the view from the daemon-set preview target. Current daemons key + // this on previewRevision (bumped on every `ao preview` call); older daemons + // did not send it, so fall back to URL changes for compatibility. useEffect(() => { if (!viewId) return; - const revision = previewRevision ?? 0; - if (previewRevisionRef.current === revision) return; - previewRevisionRef.current = revision; - const target = previewUrl?.trim(); + const target = previewUrl?.trim() ?? ""; + const revision = typeof previewRevision === "number" ? previewRevision : null; + const previous = previewTriggerRef.current; + if (previous?.revision === revision && previous.target === target) return; + if (revision !== null && previous?.revision === revision) return; + previewTriggerRef.current = { revision, target }; if (target) { void navigate(target); - } else if (revision > 0) { + } else if ((revision !== null && revision > 0) || previous?.target) { void clear(); } }, [clear, navigate, previewRevision, previewUrl, viewId]);