Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 1 addition & 87 deletions apps/stackpanel-go/internal/agent/server/connect_service.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// connect_service.go implements the core Connect-RPC AgentService handlers for
// project info, command execution, and devshell status and rebuild streaming.
// project info and command execution.

package server

Expand All @@ -10,7 +10,6 @@ import (

"connectrpc.com/connect"
gopb "github.com/darkmatter/stackpanel/packages/proto/gen/gopb"
"github.com/rs/zerolog/log"
)

// AgentServiceServer implements the Connect-RPC AgentService interface.
Expand Down Expand Up @@ -97,88 +96,3 @@ func (s *AgentServiceServer) Exec(
Stderr: res.Stderr,
}), nil
}

// =============================================================================
// Devshell Management
// =============================================================================

// GetShellStatus reports whether the Nix devshell is stale (nix files changed
// since last build) or currently rebuilding. The web UI uses this to show a
// "rebuild needed" banner.
func (s *AgentServiceServer) GetShellStatus(
ctx context.Context,
req *connect.Request[gopb.GetShellStatusRequest],
) (*connect.Response[gopb.ShellStatusResponse], error) {
if s.server.shellManager == nil {
return connect.NewResponse(&gopb.ShellStatusResponse{
Stale: false,
Rebuilding: false,
}), nil
}

status := s.server.shellManager.Status()

var lastBuilt, lastNixChange string
if !status.LastBuilt.IsZero() {
lastBuilt = status.LastBuilt.Format("2006-01-02T15:04:05Z07:00")
}
if !status.LastNixChange.IsZero() {
lastNixChange = status.LastNixChange.Format("2006-01-02T15:04:05Z07:00")
}

return connect.NewResponse(&gopb.ShellStatusResponse{
Stale: status.Stale,
Rebuilding: status.Rebuilding,
LastBuilt: lastBuilt,
LastNixChange: lastNixChange,
ChangedFiles: status.ChangedFiles,
}), nil
}

// RebuildShell triggers a devshell rebuild and streams progress events to the client.
// This is a server-streaming RPC — the client receives events as the build progresses
// (output lines, completion status, errors) rather than waiting for the full result.
func (s *AgentServiceServer) RebuildShell(
ctx context.Context,
req *connect.Request[gopb.RebuildShellRequest],
stream *connect.ServerStream[gopb.RebuildShellEvent],
) error {
if s.server.shellManager == nil {
return connect.NewError(
connect.CodeFailedPrecondition,
fmt.Errorf("no project is open"),
)
}

method := req.Msg.Method
if method == "" {
method = "devshell"
}

events := make(chan RebuildEvent, 100)

// Start rebuild in goroutine
go func() {
defer close(events)
if err := s.server.shellManager.Rebuild(ctx, method, events); err != nil {
log.Warn().Err(err).Msg("Shell rebuild error")
}
}()

// Stream events to client
for event := range events {
pbEvent := &gopb.RebuildShellEvent{
Type: event.Type,
Output: event.Output,
ExitCode: int32(event.ExitCode),
Error: event.Error,
Timestamp: event.Timestamp.Format("2006-01-02T15:04:05Z07:00"),
}

if err := stream.Send(pbEvent); err != nil {
return err
}
}

return nil
}
44 changes: 18 additions & 26 deletions apps/stackpanel-go/internal/agent/server/shell_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,13 +136,12 @@ type ShellStatus struct {
ChangedFiles []string
}

// RebuildEvent represents a streaming event during shell rebuild, sent to SSE subscribers.
// RebuildEvent is one step of a shell rebuild, streamed to the Rebuild caller.
type RebuildEvent struct {
Type string // "started", "output", "completed", "error"
Output string // Line of stdout/stderr (for Type="output")
ExitCode int // Set on Type="completed"
Error string // Set on Type="error"
Timestamp time.Time
Type string // "started", "output", "completed", "error"
Output string // Line of stdout/stderr (for Type="output")
ExitCode int // Set on Type="completed"
Error string // Set on Type="error"
}

// Rebuild starts a shell rebuild and streams output events to the caller.
Expand Down Expand Up @@ -196,27 +195,24 @@ func (sm *ShellManager) Rebuild(
stdout, err := cmd.StdoutPipe()
if err != nil {
events <- RebuildEvent{
Type: "error",
Error: err.Error(),
Timestamp: time.Now(),
Type: "error",
Error: err.Error(),
}
return err
}

stderr, err := cmd.StderrPipe()
if err != nil {
events <- RebuildEvent{
Type: "error",
Error: err.Error(),
Timestamp: time.Now(),
Type: "error",
Error: err.Error(),
}
return err
}

// Broadcast start event
events <- RebuildEvent{
Type: "started",
Timestamp: time.Now(),
Type: "started",
}

if sm.server != nil {
Expand All @@ -232,9 +228,8 @@ func (sm *ShellManager) Rebuild(
// Start the command
if err := cmd.Start(); err != nil {
events <- RebuildEvent{
Type: "error",
Error: err.Error(),
Timestamp: time.Now(),
Type: "error",
Error: err.Error(),
}
return err
}
Expand All @@ -247,9 +242,8 @@ func (sm *ShellManager) Rebuild(
for scanner.Scan() {
line := scanner.Text()
events <- RebuildEvent{
Type: "output",
Output: line,
Timestamp: time.Now(),
Type: "output",
Output: line,
}
}
}
Expand All @@ -270,9 +264,8 @@ func (sm *ShellManager) Rebuild(
exitCode = exitErr.ExitCode()
} else {
events <- RebuildEvent{
Type: "error",
Error: err.Error(),
Timestamp: time.Now(),
Type: "error",
Error: err.Error(),
}
return err
}
Expand All @@ -296,9 +289,8 @@ func (sm *ShellManager) Rebuild(
}

events <- RebuildEvent{
Type: "completed",
ExitCode: exitCode,
Timestamp: time.Now(),
Type: "completed",
ExitCode: exitCode,
}

return nil
Expand Down
30 changes: 27 additions & 3 deletions apps/web/src/components/project-selector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import { Code, ConnectError, createRouterTransport } from "@connectrpc/connect";
import { TransportProvider } from "@connectrpc/connect-query";
import { ProjectService } from "@stackpanel/proto/agent/v1/project";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ProjectSelector } from "./project-selector";

const { agentClient, endpoint } = vi.hoisted(() => ({
const { agentClient, endpoint, selection } = vi.hoisted(() => ({
agentClient: { getCurrentProject: vi.fn(), openProject: vi.fn() },
endpoint: { isDemo: false, useLocal: () => {} },
selection: { selectedProjectId: null as string | null, selectProject: vi.fn() },
}));

vi.mock("@tanstack/react-router", () => ({ useNavigate: () => () => {} }));
Expand All @@ -20,6 +21,7 @@ vi.mock("@/lib/agent-provider", () => ({
token: "paired-token",
healthStatus: "available",
isAuthenticated: true,
...selection,
}),
useAgentClient: () => agentClient,
}));
Expand Down Expand Up @@ -72,6 +74,7 @@ async function openPicker() {
describe("ProjectSelector", () => {
beforeEach(() => {
endpoint.isDemo = false;
selection.selectedProjectId = null;
agentClient.getCurrentProject.mockResolvedValue({
has_project: true,
project: { id: shop.id, name: shop.name, path: shop.path },
Expand All @@ -91,16 +94,37 @@ describe("ProjectSelector", () => {
expect(screen.getByRole("option", { name: /api/ }).getAttribute("aria-disabled")).toBeNull();
});

it("switches projects over REST and refreshes the registry", async () => {
it("opens the project over REST, then selects it for this tab", async () => {
let finishOpen!: () => void;
agentClient.openProject.mockImplementation(
(path: string) =>
new Promise((resolve) => {
finishOpen = () =>
resolve({ success: true, project: { id: api.id, name: api.name, path } });
}),
);
const calls = renderSelector();
await openPicker();

fireEvent.click(screen.getByRole("option", { name: /api/ }));

await waitFor(() => expect(agentClient.openProject).toHaveBeenCalledWith(api.path));
// Selecting refetches the REST reads, so it waits for the agent to switch.
expect(selection.selectProject).not.toHaveBeenCalled();
finishOpen();
await waitFor(() => expect(selection.selectProject).toHaveBeenCalledWith(api.id));
await waitFor(() => expect(calls.listProjects).toBe(2));
});

it("shows the project this tab selected rather than the agent's current one", async () => {
selection.selectedProjectId = api.id;
renderSelector();

const trigger = await screen.findByRole("combobox");
expect(await within(trigger).findByText("api")).toBeTruthy();
expect(within(trigger).queryByText("shop")).toBeNull();
});

it("shows why AddProject rejected the path", async () => {
renderSelector();
await openPicker();
Expand Down
25 changes: 16 additions & 9 deletions apps/web/src/components/project-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ interface ProjectSelectorProps {
}

export function ProjectSelector(_props: ProjectSelectorProps) {
const { host, port, token, healthStatus, isAuthenticated } = useAgentContext();
const { host, port, token, healthStatus, isAuthenticated, selectedProjectId, selectProject } =
useAgentContext();
const agentClient = useAgentClient();
const transport = useTransport();
const { isDemo, useLocal } = useAgentEndpoint();
Expand All @@ -67,9 +68,9 @@ export function ProjectSelector(_props: ProjectSelectorProps) {
cardinality: "finite",
});

// The rest of the Studio still runs against the agent's single current
// project, so reading and switching it stays on REST until requests select
// their project (ADR 0005, stackpanel-thq.8.1).
// Opening selects the project for this tab's Connect calls (ADR 0005) and
// also makes it the agent's current project, which the REST routes, and a
// tab that has not selected a project yet, still act on.
const currentProjectKey = ["agent", "project", "current", host, port];
const currentProjectQuery = useQuery({
queryKey: currentProjectKey,
Expand All @@ -79,8 +80,12 @@ export function ProjectSelector(_props: ProjectSelectorProps) {
});

const openProjectMutation = useMutation({
mutationFn: (path: string) => agentClient.openProject(path),
onSuccess: ({ project }) => {
mutationFn: ({ path }: { id?: string; path: string }) => agentClient.openProject(path),
onSuccess: ({ project }, { id }) => {
// Select only once the agent has switched, so the REST reads that
// selecting refetches already return the new project.
const projectId = id ?? project.id;
if (projectId) selectProject(projectId);
toast.success(`Opened project: ${project.name}`);
void queryClient.invalidateQueries({ queryKey: listProjectsKey });
void queryClient.invalidateQueries({ queryKey: currentProjectKey });
Expand All @@ -106,7 +111,9 @@ export function ProjectSelector(_props: ProjectSelectorProps) {

const projects = projectsQuery.data?.projects ?? [];
const defaultProjectId = projectsQuery.data?.defaultProjectId;
const currentProject = currentProjectQuery.data?.project ?? null;
const currentProject = selectedProjectId
? (projects.find((p) => p.id === selectedProjectId) ?? null)
: (currentProjectQuery.data?.project ?? null);
const isLoading = projectsQuery.isLoading || currentProjectQuery.isLoading;
const isOpening = openProjectMutation.isPending;
const isAdding = addProjectMutation.isPending || openProjectMutation.isPending;
Expand All @@ -120,7 +127,7 @@ export function ProjectSelector(_props: ProjectSelectorProps) {
const selectedProject = projects.find((p) => p.path === path);
if (!selectedProject?.valid || selectedProject.path === currentProject?.path) return;

openProjectMutation.mutate(selectedProject.path);
openProjectMutation.mutate({ id: selectedProject.id, path: selectedProject.path });
};

const closeAddDialog = () => {
Expand All @@ -140,7 +147,7 @@ export function ProjectSelector(_props: ProjectSelectorProps) {

try {
const { project } = await addProjectMutation.mutateAsync({ path });
await openProjectMutation.mutateAsync(project?.path ?? path);
await openProjectMutation.mutateAsync({ id: project?.id, path: project?.path ?? path });
closeAddDialog();
} catch (err) {
setValidationError(
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/components/studio/dashboard-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "@ui/dropdown-menu";
import { Bell, LogOut, Settings, User } from "lucide-react";
import { AgentStatus } from "@/components/agent-connect";
import { useAgentContext } from "@/lib/agent-provider";
import { ShellStatus } from "./shell-status";
import { AgentConsoleDialog } from "./agent-console-dialog";
import type { PanelType } from "./dashboard-sidebar";
Expand Down Expand Up @@ -71,6 +72,7 @@ const pathToPanelMap: Record<string, PanelType> = {
};

export function DashboardHeader() {
const { selectedProjectId } = useAgentContext();
const routerState = useRouterState();
const pathname = routerState.location.pathname;
const activePanel = pathToPanelMap[pathname] ?? "overview";
Expand Down Expand Up @@ -116,7 +118,9 @@ export function DashboardHeader() {
</div>

<div className="flex items-center gap-3 rounded-lg border border-border bg-secondary/50 px-3 py-1.5 ml-auto">
<ShellStatus />
{/* Remounted per project so a rebuild's output and live shell
events never carry over to the next project. */}
<ShellStatus key={selectedProjectId} />
<div className="h-4 w-px bg-border" />
<AgentStatus />
</div>
Expand Down
Loading
Loading