fix: make preview browser deterministic (#384)
* feat(frontend): add live browser panel * feat: preserve and auto-open browser previews * fix: retry browser preview after session updates * fix: wait for browser view before preview navigation * fix: reopen preview after session switches * fix: preserve browser views across session switches * test: allow project settings form more time * fix: make preview browser deterministic * chore: format with prettier [skip ci] * fix: preserve cleared preview state --------- Co-authored-by: Vaibhaav <user@example.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
2946212171
commit
3e3d22c6fa
|
|
@ -23,8 +23,7 @@ func newPreviewCommand(ctx *commandContext) *cobra.Command {
|
||||||
Use: "preview [url]",
|
Use: "preview [url]",
|
||||||
Short: "Open a URL (or the workspace's index.html) in the desktop browser panel for the current session",
|
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" +
|
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" +
|
"With no argument it opens the workspace's index.html. A workspace-relative path\n" +
|
||||||
"falling back to 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" +
|
"(e.g. ./dist/index.html) is served as a local file. Use `ao preview\n" +
|
||||||
"clear` to empty the panel.",
|
"clear` to empty the panel.",
|
||||||
Args: cobra.MaximumNArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import (
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/httpd"
|
"github.com/aoagents/agent-orchestrator/backend/internal/httpd"
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/notify"
|
"github.com/aoagents/agent-orchestrator/backend/internal/notify"
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||||
|
"github.com/aoagents/agent-orchestrator/backend/internal/preview"
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
|
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
|
||||||
notificationsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/notification"
|
notificationsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/notification"
|
||||||
projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
|
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)
|
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{
|
srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{
|
||||||
Projects: projectsvc.NewWithDeps(projectsvc.Deps{Store: store, Sessions: sessionSvc, Telemetry: telemetrySink}),
|
Projects: projectsvc.NewWithDeps(projectsvc.Deps{Store: store, Sessions: sessionSvc, Telemetry: telemetrySink}),
|
||||||
|
|
@ -141,6 +143,7 @@ func Run() error {
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
stop()
|
stop()
|
||||||
|
<-previewDone
|
||||||
lcStack.Stop()
|
lcStack.Stop()
|
||||||
if cdcErr := cdcPipe.Stop(); cdcErr != nil {
|
if cdcErr := cdcPipe.Stop(); cdcErr != nil {
|
||||||
log.Error("cdc pipeline shutdown", "err", cdcErr)
|
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
|
// 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.
|
// runs before the cancel — which would hang any non-signal exit path.
|
||||||
stop()
|
stop()
|
||||||
|
<-previewDone
|
||||||
lcStack.Stop()
|
lcStack.Stop()
|
||||||
if err := cdcPipe.Stop(); err != nil {
|
if err := cdcPipe.Stop(); err != nil {
|
||||||
log.Error("cdc pipeline shutdown", "err", err)
|
log.Error("cdc pipeline shutdown", "err", err)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import (
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec"
|
"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/httpd/envelope"
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
"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"
|
sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -25,6 +26,8 @@ const (
|
||||||
maxMessageLen = 4096
|
maxMessageLen = 4096
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var errPreviewFileNotFound = errors.New("preview file not found")
|
||||||
|
|
||||||
// SessionService is the controller-facing session service contract.
|
// SessionService is the controller-facing session service contract.
|
||||||
type SessionService interface {
|
type SessionService interface {
|
||||||
List(ctx context.Context, filter sessionsvc.ListFilter) ([]domain.Session, error)
|
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
|
// session and fans out a session_updated CDC event so the dashboard's browser
|
||||||
// panel reacts live. The target is resolved as follows:
|
// panel reacts live. The target is resolved as follows:
|
||||||
//
|
//
|
||||||
// - An empty url reuses the session's existing preview target (so a bare
|
// - An empty url opens the workspace's static entry point (index.html and
|
||||||
// `ao preview` re-opens whatever this agent/context last previewed),
|
// friends), falling back to the session's existing preview target only
|
||||||
// falling back to autodetecting a static entry point (index.html and
|
// when no entry point exists.
|
||||||
// friends) only when nothing has been previewed yet.
|
|
||||||
// - An explicit workspace-local path (e.g. `index.html`, `./dist/index.html`)
|
// - An explicit workspace-local path (e.g. `index.html`, `./dist/index.html`)
|
||||||
// is served through the preview/files route so local files load.
|
// is served through the preview/files route so local files load.
|
||||||
// - Anything else (http(s)/file URLs, host:port dev servers) is kept verbatim.
|
// - 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
|
// ponytail: no URL sanitization on preview target; agent-trusted for now
|
||||||
previewURL := strings.TrimSpace(in.URL)
|
previewURL := strings.TrimSpace(in.URL)
|
||||||
if previewURL == "" {
|
if previewURL == "" {
|
||||||
if existing := strings.TrimSpace(sess.Metadata.PreviewURL); existing != "" {
|
if entry, ok := discoverPreviewEntry(sess.Metadata.WorkspacePath); ok {
|
||||||
previewURL = existing
|
|
||||||
} else if entry, ok := discoverPreviewEntry(sess.Metadata.WorkspacePath); ok {
|
|
||||||
previewURL = previewFileURL(r, sessionID(r), entry)
|
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 {
|
} else {
|
||||||
envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "NO_PREVIEW_ENTRY", "No preview entry point found in session workspace", nil)
|
envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "NO_PREVIEW_ENTRY", "No preview entry point found in session workspace", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else if resolved, ok := resolveLocalPreview(r, sessionID(r), sess.Metadata.WorkspacePath, previewURL); ok {
|
} else {
|
||||||
previewURL = resolved
|
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)
|
updated, err := c.Svc.SetPreview(r.Context(), sessionID(r), previewURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -544,20 +556,8 @@ func writeSessionPRError(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func discoverPreviewEntry(workspacePath string) (string, bool) {
|
func discoverPreviewEntry(workspacePath string) (string, bool) {
|
||||||
if strings.TrimSpace(workspacePath) == "" {
|
entry, ok := previewutil.DiscoverEntry(workspacePath)
|
||||||
return "", false
|
return entry.Path, ok
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveLocalPreview maps a workspace-local path (e.g. "index.html" or
|
// 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
|
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
|
// hasURLScheme reports whether raw begins with an RFC-3986 "scheme:" prefix
|
||||||
// (http:, https:, file:, or a host:port like localhost:5173). It mirrors the
|
// (http:, https:, file:, or a host:port like localhost:5173). It mirrors the
|
||||||
// renderer's withDefaultScheme heuristic so the daemon and browser panel agree
|
// 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) {
|
func confinedPreviewPath(workspacePath, assetPath string) (string, bool) {
|
||||||
root, err := filepath.Abs(workspacePath)
|
return previewutil.ConfinedPath(workspacePath, assetPath)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func previewFileURL(r *http.Request, id domain.SessionID, entry string) string {
|
func previewFileURL(r *http.Request, id domain.SessionID, entry string) string {
|
||||||
u := url.URL{
|
return previewutil.FileURL("http://"+r.Host, id, entry)
|
||||||
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, "/")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sessionView(s domain.Session) SessionView {
|
func sessionView(s domain.Session) SessionView {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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()
|
svc := newFakeSessionService()
|
||||||
workspace := t.TempDir()
|
workspace := t.TempDir()
|
||||||
// An index.html exists, but the session already has a preview target — the
|
// An index.html exists, so bare `ao preview` returns to the workspace entry
|
||||||
// bare `ao preview` must reuse that target rather than autodetecting index.
|
// instead of sticking to the last explicit target.
|
||||||
if err := os.WriteFile(filepath.Join(workspace, "index.html"), []byte(`<html></html>`), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(workspace, "index.html"), []byte(`<html></html>`), 0o644); err != nil {
|
||||||
t.Fatalf("write index: %v", err)
|
t.Fatalf("write index: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -443,6 +444,57 @@ func TestSessionsAPI_SetPreviewEmptyURLReusesExistingTarget(t *testing.T) {
|
||||||
svc.sessions["ao-1"] = s
|
svc.sessions["ao-1"] = s
|
||||||
srv := newSessionTestServer(t, svc)
|
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(`<html></html>`), 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", `{}`)
|
body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/preview", `{}`)
|
||||||
if status != http.StatusOK {
|
if status != http.StatusOK {
|
||||||
t.Fatalf("set preview = %d, want 200; body=%s", status, body)
|
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(`<html></html>`), 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) {
|
func TestSessionsAPI_SetPreviewBumpsRevisionOnSameURL(t *testing.T) {
|
||||||
svc := newFakeSessionService()
|
svc := newFakeSessionService()
|
||||||
srv := newSessionTestServer(t, svc)
|
srv := newSessionTestServer(t, svc)
|
||||||
|
|
|
||||||
|
|
@ -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, "/")
|
||||||
|
}
|
||||||
|
|
@ -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] == '/')
|
||||||
|
}
|
||||||
|
|
@ -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"), "<main>hello</main>")
|
||||||
|
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"), "<main>dist</main>")
|
||||||
|
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"), "<main>public</main>")
|
||||||
|
writeFile(t, filepath.Join(workspace, "dist", "index.html"), "<main>dist</main>")
|
||||||
|
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, "<main>v1</main>")
|
||||||
|
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, "<main>v2 changed</main>")
|
||||||
|
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"), "<main>hello</main>")
|
||||||
|
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"), "<main>hello</main>")
|
||||||
|
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"), "<main>hello</main>")
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
@ -76,6 +76,14 @@ describe("normalizeBrowserURL", () => {
|
||||||
expect(normalizeBrowserURL("file:///C:/tmp/index.html").protocol).toBe("file:");
|
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", () => {
|
it("rejects privileged or unsupported schemes", () => {
|
||||||
expect(() => normalizeBrowserURL("app://renderer/index.html")).toThrow(/unsupported/i);
|
expect(() => normalizeBrowserURL("app://renderer/index.html")).toThrow(/unsupported/i);
|
||||||
expect(() => normalizeBrowserURL("javascript:alert(1)")).toThrow(/unsupported/i);
|
expect(() => normalizeBrowserURL("javascript:alert(1)")).toThrow(/unsupported/i);
|
||||||
|
|
|
||||||
|
|
@ -254,12 +254,33 @@ export function createBrowserViewHost(options: BrowserViewHostOptions): BrowserV
|
||||||
}
|
}
|
||||||
|
|
||||||
function withDefaultScheme(raw: string): string {
|
function withDefaultScheme(raw: string): string {
|
||||||
|
if (isWindowsAbsolutePath(raw) || isPosixAbsolutePath(raw)) return localPathToFileURL(raw);
|
||||||
if (/^https?:\/\//i.test(raw)) return raw;
|
if (/^https?:\/\//i.test(raw)) return raw;
|
||||||
if (isLocalhostLike(raw)) return `http://${raw}`;
|
if (isLocalhostLike(raw)) return `http://${raw}`;
|
||||||
if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(raw)) return raw;
|
if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(raw)) return raw;
|
||||||
return `https://${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 {
|
function isLocalhostLike(raw: string): boolean {
|
||||||
return /^(localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[::1\])(?::\d+)?(?:[/?#]|$)/i.test(raw);
|
return /^(localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[::1\])(?::\d+)?(?:[/?#]|$)/i.test(raw);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,34 @@ describe("useBrowserView", () => {
|
||||||
expect(bridge.navigate).toHaveBeenCalledTimes(3);
|
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 () => {
|
it("clears the view when the preview is reset (ao preview clear) and does not navigate", async () => {
|
||||||
const bridge = setupBridge();
|
const bridge = setupBridge();
|
||||||
const { rerender } = renderHook(
|
const { rerender } = renderHook(
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ export function useBrowserView({
|
||||||
const activeRef = useRef(active);
|
const activeRef = useRef(active);
|
||||||
const frameRef = useRef<number | null>(null);
|
const frameRef = useRef<number | null>(null);
|
||||||
const observerRef = useRef<ResizeObserver | null>(null);
|
const observerRef = useRef<ResizeObserver | null>(null);
|
||||||
const previewRevisionRef = useRef<number | null>(null);
|
const previewTriggerRef = useRef<{ revision: number | null; target: string } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeRef.current = active;
|
activeRef.current = active;
|
||||||
|
|
@ -182,21 +182,20 @@ export function useBrowserView({
|
||||||
|
|
||||||
const clear = useCallback(() => withView((id) => window.ao!.browser.clear(id)), [withView]);
|
const clear = useCallback(() => withView((id) => window.ao!.browser.clear(id)), [withView]);
|
||||||
|
|
||||||
// Drive the view from the daemon-set preview target, keyed on the preview
|
// Drive the view from the daemon-set preview target. Current daemons key
|
||||||
// revision (bumped on every `ao preview` call). Acting on the revision rather
|
// this on previewRevision (bumped on every `ao preview` call); older daemons
|
||||||
// than the URL means a repeated `ao preview <same-url>` still refreshes, an
|
// did not send it, so fall back to URL changes for compatibility.
|
||||||
// `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.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!viewId) return;
|
if (!viewId) return;
|
||||||
const revision = previewRevision ?? 0;
|
const target = previewUrl?.trim() ?? "";
|
||||||
if (previewRevisionRef.current === revision) return;
|
const revision = typeof previewRevision === "number" ? previewRevision : null;
|
||||||
previewRevisionRef.current = revision;
|
const previous = previewTriggerRef.current;
|
||||||
const target = previewUrl?.trim();
|
if (previous?.revision === revision && previous.target === target) return;
|
||||||
|
if (revision !== null && previous?.revision === revision) return;
|
||||||
|
previewTriggerRef.current = { revision, target };
|
||||||
if (target) {
|
if (target) {
|
||||||
void navigate(target);
|
void navigate(target);
|
||||||
} else if (revision > 0) {
|
} else if ((revision !== null && revision > 0) || previous?.target) {
|
||||||
void clear();
|
void clear();
|
||||||
}
|
}
|
||||||
}, [clear, navigate, previewRevision, previewUrl, viewId]);
|
}, [clear, navigate, previewRevision, previewUrl, viewId]);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue