diff --git a/apps/backend/cmd/preview/build.go b/apps/backend/cmd/preview/build.go index 8de100522d..04e51835aa 100644 --- a/apps/backend/cmd/preview/build.go +++ b/apps/backend/cmd/preview/build.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" ) // appsDir is the apps/ workspace root relative to the working directory (apps/backend/). @@ -185,6 +186,9 @@ func packageBundle(binDir, tarPath string) error { // Add Vite SPA assets. `kandev start` resolves these from /app/apps/web/dist // and passes KANDEV_WEB_DIST_DIR to the backend. webDistDir := filepath.Join(appsDir, "web", "dist") + if err := validateViteWebDist(webDistDir); err != nil { + return err + } if err := addDirToTar(tw, webDistDir, filepath.Join("app", "apps", "web", "dist")); err != nil { return fmt.Errorf("add web dist: %w", err) } @@ -201,6 +205,33 @@ func packageBundle(binDir, tarPath string) error { return gz.Close() } +func validateViteWebDist(webDistDir string) error { + indexPath := filepath.Join(webDistDir, "index.html") + indexHTML, err := os.ReadFile(indexPath) + if err != nil { + return fmt.Errorf("read web dist index: %w", err) + } + if !viteIndexHasEntrypoint(string(indexHTML)) { + return fmt.Errorf("web dist index %s is missing Vite module entrypoint", indexPath) + } + return nil +} + +var ( + scriptTagPattern = regexp.MustCompile(`(?is)]*>`) + moduleTypeAttrPattern = regexp.MustCompile(`(?is)\btype\s*=\s*["']module["']`) + assetSrcAttrPattern = regexp.MustCompile(`(?is)\bsrc\s*=\s*["']/assets/`) +) + +func viteIndexHasEntrypoint(indexHTML string) bool { + for _, tag := range scriptTagPattern.FindAllString(indexHTML, -1) { + if moduleTypeAttrPattern.MatchString(tag) && assetSrcAttrPattern.MatchString(tag) { + return true + } + } + return false +} + func addFileToTar(tw *tar.Writer, src, dst string, mode fs.FileMode) error { data, err := os.ReadFile(src) if err != nil { diff --git a/apps/backend/cmd/preview/build_test.go b/apps/backend/cmd/preview/build_test.go new file mode 100644 index 0000000000..c64f850999 --- /dev/null +++ b/apps/backend/cmd/preview/build_test.go @@ -0,0 +1,55 @@ +package main + +import "testing" + +func TestViteIndexHasEntrypoint(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + html string + want bool + }{ + { + name: "vite dist index", + html: `
`, + want: true, + }, + { + name: "vite dist index with src before type and single quotes", + html: `
`, + want: true, + }, + { + name: "vite dist index with spaced attribute assignment", + html: `
`, + want: true, + }, + { + name: "fallback shell has no app entrypoint", + html: `Kandev
`, + want: false, + }, + { + name: "source index still points at vite dev source", + html: `
`, + want: false, + }, + { + name: "inline module script plus unrelated assets reference", + html: ``, + want: false, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := viteIndexHasEntrypoint(tc.html); got != tc.want { + t.Fatalf("viteIndexHasEntrypoint() = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/apps/backend/cmd/preview/sprite_ops.go b/apps/backend/cmd/preview/sprite_ops.go index 18d80545e3..10e110022e 100644 --- a/apps/backend/cmd/preview/sprite_ops.go +++ b/apps/backend/cmd/preview/sprite_ops.go @@ -132,6 +132,9 @@ export KANDEV_DOCKER_ENABLED=false export KANDEV_LOG_LEVEL=info # Preview mode: only register the mock agent, suppress real agent discovery. export KANDEV_MOCK_AGENT=only +# The preview service runs from /app, so the backend's relative dist probes do +# not reach the packaged Vite build under /app/apps/web/dist. +export KANDEV_WEB_DIST_DIR=/app/apps/web/dist # Launch through the CLI so the backend runs under the restart supervisor. exec kandev start \ diff --git a/apps/backend/cmd/preview/sprite_ops_test.go b/apps/backend/cmd/preview/sprite_ops_test.go index 998767288f..b2c0177d4e 100644 --- a/apps/backend/cmd/preview/sprite_ops_test.go +++ b/apps/backend/cmd/preview/sprite_ops_test.go @@ -17,6 +17,9 @@ func TestBuildExtractScript(t *testing.T) { if !strings.Contains(script, "KANDEV_MOCK_AGENT=only") { t.Errorf("expected KANDEV_MOCK_AGENT=only in script") } + if !strings.Contains(script, "KANDEV_WEB_DIST_DIR=/app/apps/web/dist") { + t.Errorf("expected KANDEV_WEB_DIST_DIR to point at packaged Vite dist") + } if !strings.Contains(script, "ln -sf /usr/local/lib/kandev-cli/bin/cli.js /usr/local/bin/kandev") { t.Errorf("expected kandev cli symlink in script") } diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index b46b1e667f..121119109e 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -48,7 +48,10 @@ var ErrSessionResetInProgress = errors.New("session reset in progress") var ErrSessionNotPromptable = errors.New("session not promptable") const ( - agentPromptReadyTimeout = 10 * time.Second + // Backend restart recovery can restore the session state before the ACP + // stream is promptable again. Keep this above CI's slow-start tail so a + // valid resume waits instead of surfacing "Failed to send message to agent". + agentPromptReadyTimeout = 30 * time.Second agentPromptReadyInterval = 100 * time.Millisecond ) diff --git a/apps/packages/ui/src/textarea.tsx b/apps/packages/ui/src/textarea.tsx index 1e91c4d1aa..9439802937 100644 --- a/apps/packages/ui/src/textarea.tsx +++ b/apps/packages/ui/src/textarea.tsx @@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {