From a8c532e94f450bf00a8fce97e3631e53c06bd663 Mon Sep 17 00:00:00 2001 From: cooper Date: Wed, 23 Sep 2026 07:22:59 -0700 Subject: [PATCH] feat(agent): resolve the project per request and add v1 ShellService Project-scoped v1 services now run behind a project interceptor, inside authentication. It resolves the X-Stackpanel-Project header (the one the Studio already sends and CORS already allows) to a registered project, falling back to the agent's current project and then the registry default. It attaches that project's runtime from the runtime registry, so v1 and the legacy handlers share the same instances. ShellService is the first project-scoped service: GetShellStatus with typed timestamps, and RebuildShell streaming a started/output/completed oneof. A failed rebuild ends the stream with a Connect error instead of an in-band error event, and an overlapping rebuild returns FailedPrecondition. ADRs 0004 and 0005 now match what shipped: GetAgentInfo is exempt from auth, requests are capped before decoding, the header is X-Stackpanel-Project, and header-less requests use the current project before the default. Refs: stackpanel-thq.8.1.1 --- .../internal/agent/server/connect_v1.go | 37 +- .../agent/server/connect_v1_project.go | 101 ++++ .../agent/server/connect_v1_project_test.go | 110 ++++ .../internal/agent/server/connect_v1_shell.go | 125 +++++ .../agent/server/connect_v1_shell_test.go | 140 +++++ .../internal/agent/server/connect_v1_test.go | 22 +- docs/adr/0004-agent-api-on-connect.md | 9 +- .../adr/0005-per-request-project-selection.md | 8 +- .../v1/agentv1connect/project.connect.go | 10 +- .../agent/v1/agentv1connect/shell.connect.go | 147 +++++ .../gopb/stackpanel/agent/v1/project.pb.go | 2 +- .../gen/gopb/stackpanel/agent/v1/shell.pb.go | 520 ++++++++++++++++++ .../project-ProjectService_connectquery.d.ts | 5 +- .../v1/project-ProjectService_connectquery.js | 5 +- .../ts/stackpanel/agent/v1/project_pb.d.ts | 13 +- .../gen/ts/stackpanel/agent/v1/project_pb.js | 6 +- .../v1/shell-ShellService_connectquery.d.ts | 10 + .../v1/shell-ShellService_connectquery.js | 10 + .../gen/ts/stackpanel/agent/v1/shell_pb.d.ts | 208 +++++++ .../gen/ts/stackpanel/agent/v1/shell_pb.js | 79 +++ .../proto/stackpanel/agent/v1/project.proto | 13 +- .../proto/stackpanel/agent/v1/shell.proto | 68 +++ 22 files changed, 1606 insertions(+), 42 deletions(-) create mode 100644 apps/stackpanel-go/internal/agent/server/connect_v1_project.go create mode 100644 apps/stackpanel-go/internal/agent/server/connect_v1_project_test.go create mode 100644 apps/stackpanel-go/internal/agent/server/connect_v1_shell.go create mode 100644 apps/stackpanel-go/internal/agent/server/connect_v1_shell_test.go create mode 100644 packages/proto/gen/gopb/stackpanel/agent/v1/agentv1connect/shell.connect.go create mode 100644 packages/proto/gen/gopb/stackpanel/agent/v1/shell.pb.go create mode 100644 packages/proto/gen/ts/stackpanel/agent/v1/shell-ShellService_connectquery.d.ts create mode 100644 packages/proto/gen/ts/stackpanel/agent/v1/shell-ShellService_connectquery.js create mode 100644 packages/proto/gen/ts/stackpanel/agent/v1/shell_pb.d.ts create mode 100644 packages/proto/gen/ts/stackpanel/agent/v1/shell_pb.js create mode 100644 packages/proto/proto/stackpanel/agent/v1/shell.proto diff --git a/apps/stackpanel-go/internal/agent/server/connect_v1.go b/apps/stackpanel-go/internal/agent/server/connect_v1.go index 77784004..a5c24646 100644 --- a/apps/stackpanel-go/internal/agent/server/connect_v1.go +++ b/apps/stackpanel-go/internal/agent/server/connect_v1.go @@ -20,7 +20,13 @@ import ( // allowlist stay at the HTTP layer; authentication is part of the Connect // interceptor chain so no procedure can be mounted without it. func (s *Server) mountV1(mux *http.ServeMux) { - routes := newV1Routes(s.config.Version, s.isValidToken, project.NewConnectHandler(s.projectMgr)) + routes := newV1Routes(v1Deps{ + version: s.config.Version, + validToken: s.isValidToken, + projects: project.NewConnectHandler(s.projectMgr), + shell: newShellService(), + scope: projectInterceptor{resolve: s.resolveProjectRoot, runtimes: s.runtimes}, + }) for _, r := range routes { mux.Handle(r.path, s.withCORS(s.withAllowedOrigin(r.handler.ServeHTTP))) } @@ -34,26 +40,37 @@ type v1Route struct { // Connect reads and decompresses the body before interceptors run, so the limit must apply before auth. const v1ReadMaxBytes = 2 << 20 // same 2 MiB cap as REST request bodies +// v1Deps are the service implementations and policies newV1Routes mounts. +type v1Deps struct { + version string + validToken func(token string) bool + projects agentv1connect.ProjectServiceHandler + shell agentv1connect.ShellServiceHandler + // scope resolves the request's project for project-scoped services. + scope connect.Interceptor +} + // newV1Routes builds every v1 service behind one interceptor chain and // reports exactly the procedures they serve through GetAgentInfo. -func newV1Routes( - version string, - validToken func(token string) bool, - projects agentv1connect.ProjectServiceHandler, -) []v1Route { +func newV1Routes(d v1Deps) []v1Route { opts := connect.WithHandlerOptions( connect.WithReadMaxBytes(v1ReadMaxBytes), - connect.WithInterceptors(authInterceptor{valid: validToken}), + connect.WithInterceptors(authInterceptor{valid: d.validToken}), connect.WithRecover(recoverRPC), ) - info := &agentInfoService{version: version} + // Project-scoped services also resolve the request's project. Auth stays + // the outermost interceptor, so unauthenticated calls never start a + // project runtime. + scoped := connect.WithHandlerOptions(opts, connect.WithInterceptors(d.scope)) + info := &agentInfoService{version: d.version} - routes := make([]v1Route, 0, 2) + routes := make([]v1Route, 0, 3) add := func(path string, handler http.Handler) { routes = append(routes, v1Route{path: path, handler: handler}) } add(agentv1connect.NewAgentServiceHandler(info, opts)) - add(agentv1connect.NewProjectServiceHandler(projects, opts)) + add(agentv1connect.NewProjectServiceHandler(d.projects, opts)) + add(agentv1connect.NewShellServiceHandler(d.shell, scoped)) info.procedures = procedurePaths(routes) return routes diff --git a/apps/stackpanel-go/internal/agent/server/connect_v1_project.go b/apps/stackpanel-go/internal/agent/server/connect_v1_project.go new file mode 100644 index 00000000..7f596a36 --- /dev/null +++ b/apps/stackpanel-go/internal/agent/server/connect_v1_project.go @@ -0,0 +1,101 @@ +package server + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "connectrpc.com/connect" + + "github.com/darkmatter/stackpanel/stackpanel-go/internal/agent/project" +) + +// projectHeader names the project a request runs against (ADR 0005). It is +// the header the Studio already sends and the CORS policy already allows. +const projectHeader = "X-Stackpanel-Project" + +type runtimeKey struct{} + +// runtimeFor returns the project runtime attached by projectInterceptor. +// Only project-scoped services are mounted behind that interceptor. +func runtimeFor(ctx context.Context) *projectRuntime { + rt, _ := ctx.Value(runtimeKey{}).(*projectRuntime) + return rt +} + +// projectInterceptor resolves the project for project-scoped v1 services and +// attaches its runtime to the request context. Runtimes come from the same +// registry the legacy handlers use, so both act on shared instances. +type projectInterceptor struct { + // resolve maps the request's project header (possibly empty) to a + // project root, returning a Connect error when it cannot. + resolve func(projectID string) (string, error) + runtimes *runtimeRegistry +} + +func (p projectInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + ctx, err := p.attach(ctx, req.Header()) + if err != nil { + return nil, err + } + return next(ctx, req) + } +} + +func (p projectInterceptor) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc { + return next +} + +func (p projectInterceptor) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc { + return func(ctx context.Context, conn connect.StreamingHandlerConn) error { + ctx, err := p.attach(ctx, conn.RequestHeader()) + if err != nil { + return err + } + return next(ctx, conn) + } +} + +func (p projectInterceptor) attach(ctx context.Context, header http.Header) (context.Context, error) { + root, err := p.resolve(strings.TrimSpace(header.Get(projectHeader))) + if err != nil { + return nil, err + } + rt, err := p.runtimes.get(root) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("start project runtime: %w", err)) + } + return context.WithValue(ctx, runtimeKey{}, rt), nil +} + +// resolveProjectRoot maps a request's project ID to a project root. Without +// an ID it uses the agent's current project, then the registry's default: +// current comes first so header-less v1 calls act on the same project as the +// legacy REST routes during the migration (ADR 0005). +func (s *Server) resolveProjectRoot(projectID string) (string, error) { + registry := s.projectMgr.UserConfigManager() + + if projectID != "" { + p := registry.GetProjectByID(projectID) + if p == nil { + return "", connect.NewError(connect.CodeNotFound, fmt.Errorf("unknown project %q", projectID)) + } + if err := project.QuickValidate(p.Path); err != nil { + return "", connect.NewError(connect.CodeFailedPrecondition, + fmt.Errorf("project %q is not usable: %w", projectID, err)) + } + return p.Path, nil + } + + if root := s.config.ProjectRoot; root != "" { + return root, nil + } + if def := registry.GetDefaultProject(); def != nil { + return def.Path, nil + } + return "", connect.NewError(connect.CodeFailedPrecondition, + errors.New("no project selected: send "+projectHeader+" or open a project")) +} diff --git a/apps/stackpanel-go/internal/agent/server/connect_v1_project_test.go b/apps/stackpanel-go/internal/agent/server/connect_v1_project_test.go new file mode 100644 index 00000000..831d176d --- /dev/null +++ b/apps/stackpanel-go/internal/agent/server/connect_v1_project_test.go @@ -0,0 +1,110 @@ +package server + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "connectrpc.com/connect" + + agentv1 "github.com/darkmatter/stackpanel/packages/proto/gen/gopb/stackpanel/agent/v1" + "github.com/darkmatter/stackpanel/packages/proto/gen/gopb/stackpanel/agent/v1/agentv1connect" + "github.com/darkmatter/stackpanel/stackpanel-go/internal/agent/config" + "github.com/darkmatter/stackpanel/stackpanel-go/internal/agent/project" +) + +// TestProjectInterceptorRunsRequestsAgainstTheResolvedProject mounts +// ShellService behind the project interceptor. Each fake runtime reports its +// own root as a changed file, so the response shows which project ran. +func TestProjectInterceptorRunsRequestsAgainstTheResolvedProject(t *testing.T) { + registry, _ := newTestRegistry() + scope := projectInterceptor{ + runtimes: registry, + resolve: func(id string) (string, error) { + switch id { + case "": + return "/p/current", nil + case "other": + return "/p/other", nil + default: + return "", connect.NewError(connect.CodeNotFound, fmt.Errorf("unknown project %q", id)) + } + }, + } + shells := map[string]devshell{} + svc := shellService{shellFor: func(ctx context.Context) devshell { + root := runtimeFor(ctx).root + if shells[root] == nil { + shells[root] = &fakeShell{status: ShellStatus{ChangedFiles: []string{root}}} + } + return shells[root] + }} + + mux := http.NewServeMux() + mux.Handle(agentv1connect.NewShellServiceHandler(svc, connect.WithInterceptors(scope))) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + client := agentv1connect.NewShellServiceClient(srv.Client(), srv.URL) + + status := func(projectID string) (string, error) { + req := connect.NewRequest(&agentv1.GetShellStatusRequest{}) + if projectID != "" { + req.Header().Set(projectHeader, projectID) + } + res, err := client.GetShellStatus(context.Background(), req) + if err != nil { + return "", err + } + return res.Msg.GetChangedFiles()[0], nil + } + + if got, err := status(""); err != nil || got != "/p/current" { + t.Errorf("no header: got %q, %v; want the current project", got, err) + } + if got, err := status("other"); err != nil || got != "/p/other" { + t.Errorf("header other: got %q, %v; want /p/other", got, err) + } + if _, err := status("missing"); connect.CodeOf(err) != connect.CodeNotFound { + t.Errorf("unknown project: code %v, want NotFound", connect.CodeOf(err)) + } + if n := len(registry.runtimes); n != 2 { + t.Errorf("registry holds %d runtimes, want one per resolved project", n) + } +} + +func TestResolveProjectRootFallsBackToCurrentThenDefault(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + mgr, err := project.NewManager(t.TempDir()) + if err != nil { + t.Fatal(err) + } + registry := mgr.UserConfigManager() + def, err := registry.AddProject(filepath.Join(t.TempDir(), "default"), "") + if err != nil { + t.Fatal(err) + } + s := &Server{config: &config.Config{}, projectMgr: mgr} + + if _, err := s.resolveProjectRoot(""); connect.CodeOf(err) != connect.CodeFailedPrecondition { + t.Errorf("nothing selected: code %v, want FailedPrecondition", connect.CodeOf(err)) + } + if _, err := s.resolveProjectRoot("no-such-id"); connect.CodeOf(err) != connect.CodeNotFound { + t.Errorf("unknown id: code %v, want NotFound", connect.CodeOf(err)) + } + + if err := registry.SetDefaultProject(def.Path); err != nil { + t.Fatal(err) + } + if got, err := s.resolveProjectRoot(""); err != nil || got != def.Path { + t.Errorf("no header, default set: got %q, %v; want %q", got, err, def.Path) + } + + s.config.ProjectRoot = "/p/current" + if got, err := s.resolveProjectRoot(""); err != nil || got != "/p/current" { + t.Errorf("no header, current set: got %q, %v; want the current project first", got, err) + } +} diff --git a/apps/stackpanel-go/internal/agent/server/connect_v1_shell.go b/apps/stackpanel-go/internal/agent/server/connect_v1_shell.go new file mode 100644 index 00000000..281faabf --- /dev/null +++ b/apps/stackpanel-go/internal/agent/server/connect_v1_shell.go @@ -0,0 +1,125 @@ +package server + +import ( + "context" + "errors" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" + + agentv1 "github.com/darkmatter/stackpanel/packages/proto/gen/gopb/stackpanel/agent/v1" + "github.com/darkmatter/stackpanel/packages/proto/gen/gopb/stackpanel/agent/v1/agentv1connect" +) + +var _ agentv1connect.ShellServiceHandler = shellService{} + +// devshell is the part of ShellManager that ShellService uses. +type devshell interface { + Status() ShellStatus + Rebuild(ctx context.Context, method string, events chan<- RebuildEvent) error +} + +// shellService implements stackpanel.agent.v1.ShellService against the +// request's project runtime. +type shellService struct { + shellFor func(ctx context.Context) devshell +} + +func newShellService() shellService { + return shellService{shellFor: func(ctx context.Context) devshell { return runtimeFor(ctx).shell }} +} + +func (s shellService) GetShellStatus( + ctx context.Context, + _ *connect.Request[agentv1.GetShellStatusRequest], +) (*connect.Response[agentv1.GetShellStatusResponse], error) { + st := s.shellFor(ctx).Status() + res := &agentv1.GetShellStatusResponse{ + Stale: st.Stale, + Rebuilding: st.Rebuilding, + ChangedFiles: st.ChangedFiles, + } + if !st.LastBuilt.IsZero() { + res.LastBuiltAt = timestamppb.New(st.LastBuilt) + } + if !st.LastNixChange.IsZero() { + res.LastNixChangeAt = timestamppb.New(st.LastNixChange) + } + return connect.NewResponse(res), nil +} + +func (s shellService) RebuildShell( + ctx context.Context, + req *connect.Request[agentv1.RebuildShellRequest], + stream *connect.ServerStream[agentv1.RebuildShellResponse], +) error { + method := "devshell" + if req.Msg.GetMethod() == agentv1.RebuildMethod_REBUILD_METHOD_NIX_DEVELOP { + method = "nix" + } + + events := make(chan RebuildEvent, 100) + done := make(chan error, 1) + shell := s.shellFor(ctx) + go func() { + defer close(events) + done <- shell.Rebuild(ctx, method, events) + }() + + // Keep draining after a failure so the rebuild goroutine never blocks on + // a full channel; report the failure once the stream of events ends. + var failure error + for ev := range events { + if failure != nil { + continue + } + msg, err := rebuildEventToProto(ev) + if err != nil { + failure = err + continue + } + if msg == nil { + continue + } + if err := stream.Send(msg); err != nil { + failure = err + } + } + + if err := <-done; err != nil { + var inProgress *RebuildInProgressError + if errors.As(err, &inProgress) { + return connect.NewError(connect.CodeFailedPrecondition, err) + } + if failure == nil { + return connect.NewError(connect.CodeInternal, err) + } + } + return failure +} + +// rebuildEventToProto maps a ShellManager event onto the stream. Error events +// become the stream's terminal error rather than a message; unknown event +// types are skipped. +func rebuildEventToProto(ev RebuildEvent) (*agentv1.RebuildShellResponse, error) { + switch ev.Type { + case "started": + return &agentv1.RebuildShellResponse{ + Event: &agentv1.RebuildShellResponse_Started{Started: &agentv1.RebuildStarted{}}, + }, nil + case "output": + return &agentv1.RebuildShellResponse{ + Event: &agentv1.RebuildShellResponse_OutputLine{OutputLine: ev.Output}, + }, nil + case "completed": + return &agentv1.RebuildShellResponse{ + Event: &agentv1.RebuildShellResponse_Completed{ + Completed: &agentv1.RebuildCompleted{ExitCode: int32(ev.ExitCode)}, + }, + }, nil + case "error": + return nil, connect.NewError(connect.CodeInternal, errors.New(ev.Error)) + default: + return nil, nil + } +} diff --git a/apps/stackpanel-go/internal/agent/server/connect_v1_shell_test.go b/apps/stackpanel-go/internal/agent/server/connect_v1_shell_test.go new file mode 100644 index 00000000..067a5512 --- /dev/null +++ b/apps/stackpanel-go/internal/agent/server/connect_v1_shell_test.go @@ -0,0 +1,140 @@ +package server + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "slices" + "testing" + "time" + + "connectrpc.com/connect" + + agentv1 "github.com/darkmatter/stackpanel/packages/proto/gen/gopb/stackpanel/agent/v1" + "github.com/darkmatter/stackpanel/packages/proto/gen/gopb/stackpanel/agent/v1/agentv1connect" +) + +// fakeShell replays scripted rebuild events and records the method it got. +type fakeShell struct { + status ShellStatus + events []RebuildEvent + err error + method string +} + +func (f *fakeShell) Status() ShellStatus { return f.status } + +func (f *fakeShell) Rebuild(_ context.Context, method string, events chan<- RebuildEvent) error { + f.method = method + for _, ev := range f.events { + events <- ev + } + return f.err +} + +func newShellTestClient(t *testing.T, shell devshell) agentv1connect.ShellServiceClient { + t.Helper() + mux := http.NewServeMux() + mux.Handle(agentv1connect.NewShellServiceHandler(shellService{ + shellFor: func(context.Context) devshell { return shell }, + })) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return agentv1connect.NewShellServiceClient(srv.Client(), srv.URL) +} + +// collect reads the whole rebuild stream. +func collect(t *testing.T, client agentv1connect.ShellServiceClient, method agentv1.RebuildMethod) ([]*agentv1.RebuildShellResponse, error) { + t.Helper() + stream, err := client.RebuildShell(context.Background(), connect.NewRequest(&agentv1.RebuildShellRequest{Method: method})) + if err != nil { + return nil, err + } + var got []*agentv1.RebuildShellResponse + for stream.Receive() { + got = append(got, stream.Msg()) + } + return got, stream.Err() +} + +func TestGetShellStatusReportsTheProjectShell(t *testing.T) { + built := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC) + client := newShellTestClient(t, &fakeShell{status: ShellStatus{ + Stale: true, + LastBuilt: built, + ChangedFiles: []string{"flake.nix", ".stack/config.nix"}, + }}) + + res, err := client.GetShellStatus(context.Background(), connect.NewRequest(&agentv1.GetShellStatusRequest{})) + if err != nil { + t.Fatalf("GetShellStatus: %v", err) + } + msg := res.Msg + if !msg.GetStale() || msg.GetRebuilding() { + t.Errorf("stale=%v rebuilding=%v, want true/false", msg.GetStale(), msg.GetRebuilding()) + } + if got := msg.GetLastBuiltAt().AsTime(); !got.Equal(built) { + t.Errorf("last_built_at = %v, want %v", got, built) + } + if msg.GetLastNixChangeAt() != nil { + t.Error("last_nix_change_at should be unset when the shell never saw a change") + } + if !slices.Equal(msg.GetChangedFiles(), []string{"flake.nix", ".stack/config.nix"}) { + t.Errorf("changed_files = %v", msg.GetChangedFiles()) + } +} + +func TestRebuildShellStreamsStartedOutputAndCompleted(t *testing.T) { + shell := &fakeShell{events: []RebuildEvent{ + {Type: "started"}, + {Type: "output", Output: "building devshell"}, + {Type: "completed", ExitCode: 3}, + }} + client := newShellTestClient(t, shell) + + got, err := collect(t, client, agentv1.RebuildMethod_REBUILD_METHOD_UNSPECIFIED) + if err != nil { + t.Fatalf("stream error: %v", err) + } + if len(got) != 3 || got[0].GetStarted() == nil || + got[1].GetOutputLine() != "building devshell" || + got[2].GetCompleted().GetExitCode() != 3 { + t.Fatalf("unexpected stream: %v", got) + } + if shell.method != "devshell" { + t.Errorf("unspecified method ran %q, want devshell", shell.method) + } +} + +func TestRebuildShellEndsWithAnErrorAfterEarlierOutput(t *testing.T) { + shell := &fakeShell{ + events: []RebuildEvent{ + {Type: "started"}, + {Type: "output", Output: "evaluating"}, + {Type: "error", Error: "nix develop exited early"}, + {Type: "output", Output: "never delivered"}, + }, + err: errors.New("nix develop exited early"), + } + client := newShellTestClient(t, shell) + + got, err := collect(t, client, agentv1.RebuildMethod_REBUILD_METHOD_NIX_DEVELOP) + if connect.CodeOf(err) != connect.CodeInternal { + t.Fatalf("stream error code = %v (%v), want Internal", connect.CodeOf(err), err) + } + if len(got) != 2 || got[1].GetOutputLine() != "evaluating" { + t.Errorf("messages before the error = %v, want started and the first output", got) + } + if shell.method != "nix" { + t.Errorf("NIX_DEVELOP ran %q, want nix", shell.method) + } +} + +func TestRebuildShellRejectsAConcurrentRebuild(t *testing.T) { + client := newShellTestClient(t, &fakeShell{err: &RebuildInProgressError{}}) + + if _, err := collect(t, client, agentv1.RebuildMethod_REBUILD_METHOD_DEVSHELL); connect.CodeOf(err) != connect.CodeFailedPrecondition { + t.Errorf("code = %v (%v), want FailedPrecondition", connect.CodeOf(err), err) + } +} diff --git a/apps/stackpanel-go/internal/agent/server/connect_v1_test.go b/apps/stackpanel-go/internal/agent/server/connect_v1_test.go index f516067d..d57754fb 100644 --- a/apps/stackpanel-go/internal/agent/server/connect_v1_test.go +++ b/apps/stackpanel-go/internal/agent/server/connect_v1_test.go @@ -22,8 +22,8 @@ import ( func newV1TestServer(t *testing.T) *httptest.Server { t.Helper() mux := http.NewServeMux() - routes := newV1Routes("1.2.3", func(token string) bool { return token == "good" }, - agentv1connect.UnimplementedProjectServiceHandler{}) + routes := newV1Routes(testV1Deps(func(token string) bool { return token == "good" }, + agentv1connect.UnimplementedProjectServiceHandler{})) for _, r := range routes { mux.Handle(r.path, r.handler) } @@ -119,7 +119,7 @@ func TestV1RejectsOversizedRequestsBeforeAuth(t *testing.T) { var wireBytes atomic.Int64 projects := &recordingProjects{} mux := http.NewServeMux() - routes := newV1Routes("1.2.3", func(string) bool { authRan.Store(true); return false }, projects) + routes := newV1Routes(testV1Deps(func(string) bool { authRan.Store(true); return false }, projects)) for _, r := range routes { mux.Handle(r.path, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { req.Body = countingBody{req.Body, &wireBytes} @@ -164,3 +164,19 @@ func TestV1RejectsOversizedRequestsBeforeAuth(t *testing.T) { t.Error("AddProject ran for an oversized request") } } + +// testV1Deps wires the v1 routes with the given token check and project +// service; project-scoped services resolve every request to /p/test. +func testV1Deps(validToken func(string) bool, projects agentv1connect.ProjectServiceHandler) v1Deps { + registry, _ := newTestRegistry() + return v1Deps{ + version: "1.2.3", + validToken: validToken, + projects: projects, + shell: agentv1connect.UnimplementedShellServiceHandler{}, + scope: projectInterceptor{ + resolve: func(string) (string, error) { return "/p/test", nil }, + runtimes: registry, + }, + } +} diff --git a/docs/adr/0004-agent-api-on-connect.md b/docs/adr/0004-agent-api-on-connect.md index ec221f49..b6608788 100644 --- a/docs/adr/0004-agent-api-on-connect.md +++ b/docs/adr/0004-agent-api-on-connect.md @@ -71,10 +71,15 @@ API only. **Server** - Cross-cutting concerns live in one interceptor chain applied to every - service: authentication (`Authorization: Bearer` only; `Pair` is - exempt), project resolution ([ADR 0005](./0005-per-request-project-selection.md)), + service: authentication (`Authorization: Bearer` only; `Pair` and + `GetAgentInfo` are exempt, the latter so the Studio can check + compatibility before pairing), project resolution on project-scoped + services ([ADR 0005](./0005-per-request-project-selection.md)), protovalidate (`connectrpc.com/validate`), panic recovery (`connect.WithRecover`), and logging. Handlers contain domain logic only. +- Requests are capped at 2 MiB before decoding, the same cap as REST. + connect-go reads and decompresses a message before interceptors run, so + the limit has to apply ahead of authentication. - Each domain lives in `internal/agent//`, which owns its logic and a thin Connect adapter. `internal/agent/server` only composes the mux, the interceptors, and the per-project runtime registry. diff --git a/docs/adr/0005-per-request-project-selection.md b/docs/adr/0005-per-request-project-selection.md index c83552b1..63d18e79 100644 --- a/docs/adr/0005-per-request-project-selection.md +++ b/docs/adr/0005-per-request-project-selection.md @@ -39,9 +39,11 @@ Each agent request names its project. The agent keeps no global "current project". - A Connect interceptor ([ADR 0004](./0004-agent-api-on-connect.md)) - resolves the project from the `Stackpanel-Project` request header. When - the header is absent it falls back to the registry's default project and - then its current project, so interactive CLI use stays convenient. + resolves the project from the `X-Stackpanel-Project` request header (the + name the Studio already sends and CORS already allows). When the header is + absent it falls back to the agent's current project, then the registry's + default project. Current comes first so that, while REST routes still act + on the current project, header-less v1 calls act on the same one. - The interceptor attaches that project's **runtime** to the request context. A runtime owns everything project-scoped: the command executor, the data store, the shell manager, the flake watcher, caches, and an diff --git a/packages/proto/gen/gopb/stackpanel/agent/v1/agentv1connect/project.connect.go b/packages/proto/gen/gopb/stackpanel/agent/v1/agentv1connect/project.connect.go index 743912b0..51e3a5d4 100644 --- a/packages/proto/gen/gopb/stackpanel/agent/v1/agentv1connect/project.connect.go +++ b/packages/proto/gen/gopb/stackpanel/agent/v1/agentv1connect/project.connect.go @@ -57,8 +57,9 @@ type ProjectServiceClient interface { AddProject(context.Context, *connect.Request[v1.AddProjectRequest]) (*connect.Response[v1.AddProjectResponse], error) // Removes a project from the registry. The directory is left untouched. RemoveProject(context.Context, *connect.Request[v1.RemoveProjectRequest]) (*connect.Response[v1.RemoveProjectResponse], error) - // Sets the project used for requests that carry no Stackpanel-Project - // header, such as CLI and TUI calls. + // Sets the project used for requests that carry no X-Stackpanel-Project + // header when the agent has no current project, such as calls to a global + // agent started outside any project. SetDefaultProject(context.Context, *connect.Request[v1.SetDefaultProjectRequest]) (*connect.Response[v1.SetDefaultProjectResponse], error) } @@ -138,8 +139,9 @@ type ProjectServiceHandler interface { AddProject(context.Context, *connect.Request[v1.AddProjectRequest]) (*connect.Response[v1.AddProjectResponse], error) // Removes a project from the registry. The directory is left untouched. RemoveProject(context.Context, *connect.Request[v1.RemoveProjectRequest]) (*connect.Response[v1.RemoveProjectResponse], error) - // Sets the project used for requests that carry no Stackpanel-Project - // header, such as CLI and TUI calls. + // Sets the project used for requests that carry no X-Stackpanel-Project + // header when the agent has no current project, such as calls to a global + // agent started outside any project. SetDefaultProject(context.Context, *connect.Request[v1.SetDefaultProjectRequest]) (*connect.Response[v1.SetDefaultProjectResponse], error) } diff --git a/packages/proto/gen/gopb/stackpanel/agent/v1/agentv1connect/shell.connect.go b/packages/proto/gen/gopb/stackpanel/agent/v1/agentv1connect/shell.connect.go new file mode 100644 index 00000000..d12b1f67 --- /dev/null +++ b/packages/proto/gen/gopb/stackpanel/agent/v1/agentv1connect/shell.connect.go @@ -0,0 +1,147 @@ +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: stackpanel/agent/v1/shell.proto + +package agentv1connect + +import ( + context "context" + errors "errors" + http "net/http" + strings "strings" + + connect "connectrpc.com/connect" + v1 "github.com/darkmatter/stackpanel/packages/proto/gen/gopb/stackpanel/agent/v1" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // ShellServiceName is the fully-qualified name of the ShellService service. + ShellServiceName = "stackpanel.agent.v1.ShellService" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // ShellServiceGetShellStatusProcedure is the fully-qualified name of the ShellService's + // GetShellStatus RPC. + ShellServiceGetShellStatusProcedure = "/stackpanel.agent.v1.ShellService/GetShellStatus" + // ShellServiceRebuildShellProcedure is the fully-qualified name of the ShellService's RebuildShell + // RPC. + ShellServiceRebuildShellProcedure = "/stackpanel.agent.v1.ShellService/RebuildShell" +) + +// ShellServiceClient is a client for the stackpanel.agent.v1.ShellService service. +type ShellServiceClient interface { + GetShellStatus(context.Context, *connect.Request[v1.GetShellStatusRequest]) (*connect.Response[v1.GetShellStatusResponse], error) + // Rebuilds the devshell and streams its output. The stream starts with a + // started event and ends with a completed event carrying the exit code. A + // rebuild that cannot run ends the stream with an error instead. + RebuildShell(context.Context, *connect.Request[v1.RebuildShellRequest]) (*connect.ServerStreamForClient[v1.RebuildShellResponse], error) +} + +// NewShellServiceClient constructs a client for the stackpanel.agent.v1.ShellService service. By +// default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, +// and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the +// connect.WithGRPC() or connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewShellServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) ShellServiceClient { + baseURL = strings.TrimRight(baseURL, "/") + shellServiceMethods := v1.File_stackpanel_agent_v1_shell_proto.Services().ByName("ShellService").Methods() + return &shellServiceClient{ + getShellStatus: connect.NewClient[v1.GetShellStatusRequest, v1.GetShellStatusResponse]( + httpClient, + baseURL+ShellServiceGetShellStatusProcedure, + connect.WithSchema(shellServiceMethods.ByName("GetShellStatus")), + connect.WithIdempotency(connect.IdempotencyNoSideEffects), + connect.WithClientOptions(opts...), + ), + rebuildShell: connect.NewClient[v1.RebuildShellRequest, v1.RebuildShellResponse]( + httpClient, + baseURL+ShellServiceRebuildShellProcedure, + connect.WithSchema(shellServiceMethods.ByName("RebuildShell")), + connect.WithClientOptions(opts...), + ), + } +} + +// shellServiceClient implements ShellServiceClient. +type shellServiceClient struct { + getShellStatus *connect.Client[v1.GetShellStatusRequest, v1.GetShellStatusResponse] + rebuildShell *connect.Client[v1.RebuildShellRequest, v1.RebuildShellResponse] +} + +// GetShellStatus calls stackpanel.agent.v1.ShellService.GetShellStatus. +func (c *shellServiceClient) GetShellStatus(ctx context.Context, req *connect.Request[v1.GetShellStatusRequest]) (*connect.Response[v1.GetShellStatusResponse], error) { + return c.getShellStatus.CallUnary(ctx, req) +} + +// RebuildShell calls stackpanel.agent.v1.ShellService.RebuildShell. +func (c *shellServiceClient) RebuildShell(ctx context.Context, req *connect.Request[v1.RebuildShellRequest]) (*connect.ServerStreamForClient[v1.RebuildShellResponse], error) { + return c.rebuildShell.CallServerStream(ctx, req) +} + +// ShellServiceHandler is an implementation of the stackpanel.agent.v1.ShellService service. +type ShellServiceHandler interface { + GetShellStatus(context.Context, *connect.Request[v1.GetShellStatusRequest]) (*connect.Response[v1.GetShellStatusResponse], error) + // Rebuilds the devshell and streams its output. The stream starts with a + // started event and ends with a completed event carrying the exit code. A + // rebuild that cannot run ends the stream with an error instead. + RebuildShell(context.Context, *connect.Request[v1.RebuildShellRequest], *connect.ServerStream[v1.RebuildShellResponse]) error +} + +// NewShellServiceHandler builds an HTTP handler from the service implementation. It returns the +// path on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewShellServiceHandler(svc ShellServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + shellServiceMethods := v1.File_stackpanel_agent_v1_shell_proto.Services().ByName("ShellService").Methods() + shellServiceGetShellStatusHandler := connect.NewUnaryHandler( + ShellServiceGetShellStatusProcedure, + svc.GetShellStatus, + connect.WithSchema(shellServiceMethods.ByName("GetShellStatus")), + connect.WithIdempotency(connect.IdempotencyNoSideEffects), + connect.WithHandlerOptions(opts...), + ) + shellServiceRebuildShellHandler := connect.NewServerStreamHandler( + ShellServiceRebuildShellProcedure, + svc.RebuildShell, + connect.WithSchema(shellServiceMethods.ByName("RebuildShell")), + connect.WithHandlerOptions(opts...), + ) + return "/stackpanel.agent.v1.ShellService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case ShellServiceGetShellStatusProcedure: + shellServiceGetShellStatusHandler.ServeHTTP(w, r) + case ShellServiceRebuildShellProcedure: + shellServiceRebuildShellHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedShellServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedShellServiceHandler struct{} + +func (UnimplementedShellServiceHandler) GetShellStatus(context.Context, *connect.Request[v1.GetShellStatusRequest]) (*connect.Response[v1.GetShellStatusResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("stackpanel.agent.v1.ShellService.GetShellStatus is not implemented")) +} + +func (UnimplementedShellServiceHandler) RebuildShell(context.Context, *connect.Request[v1.RebuildShellRequest], *connect.ServerStream[v1.RebuildShellResponse]) error { + return connect.NewError(connect.CodeUnimplemented, errors.New("stackpanel.agent.v1.ShellService.RebuildShell is not implemented")) +} diff --git a/packages/proto/gen/gopb/stackpanel/agent/v1/project.pb.go b/packages/proto/gen/gopb/stackpanel/agent/v1/project.pb.go index 1af9f4ed..96be719e 100644 --- a/packages/proto/gen/gopb/stackpanel/agent/v1/project.pb.go +++ b/packages/proto/gen/gopb/stackpanel/agent/v1/project.pb.go @@ -26,7 +26,7 @@ const ( type Project struct { state protoimpl.MessageState `protogen:"open.v1"` // Stable identifier derived from the project path; the value clients send - // in the Stackpanel-Project header. + // in the X-Stackpanel-Project header. Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // Display name. Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` diff --git a/packages/proto/gen/gopb/stackpanel/agent/v1/shell.pb.go b/packages/proto/gen/gopb/stackpanel/agent/v1/shell.pb.go new file mode 100644 index 00000000..55840844 --- /dev/null +++ b/packages/proto/gen/gopb/stackpanel/agent/v1/shell.pb.go @@ -0,0 +1,520 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc (unknown) +// source: stackpanel/agent/v1/shell.proto + +package agentv1 + +import ( + reflect "reflect" + sync "sync" + unsafe "unsafe" + + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type RebuildMethod int32 + +const ( + // Treated as REBUILD_METHOD_DEVSHELL. + RebuildMethod_REBUILD_METHOD_UNSPECIFIED RebuildMethod = 0 + // Runs the project's ./devshell entrypoint, falling back to nix develop. + RebuildMethod_REBUILD_METHOD_DEVSHELL RebuildMethod = 1 + // Always runs nix develop. + RebuildMethod_REBUILD_METHOD_NIX_DEVELOP RebuildMethod = 2 +) + +// Enum value maps for RebuildMethod. +var ( + RebuildMethod_name = map[int32]string{ + 0: "REBUILD_METHOD_UNSPECIFIED", + 1: "REBUILD_METHOD_DEVSHELL", + 2: "REBUILD_METHOD_NIX_DEVELOP", + } + RebuildMethod_value = map[string]int32{ + "REBUILD_METHOD_UNSPECIFIED": 0, + "REBUILD_METHOD_DEVSHELL": 1, + "REBUILD_METHOD_NIX_DEVELOP": 2, + } +) + +func (x RebuildMethod) Enum() *RebuildMethod { + p := new(RebuildMethod) + *p = x + return p +} + +func (x RebuildMethod) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (RebuildMethod) Descriptor() protoreflect.EnumDescriptor { + return file_stackpanel_agent_v1_shell_proto_enumTypes[0].Descriptor() +} + +func (RebuildMethod) Type() protoreflect.EnumType { + return &file_stackpanel_agent_v1_shell_proto_enumTypes[0] +} + +func (x RebuildMethod) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use RebuildMethod.Descriptor instead. +func (RebuildMethod) EnumDescriptor() ([]byte, []int) { + return file_stackpanel_agent_v1_shell_proto_rawDescGZIP(), []int{0} +} + +type GetShellStatusRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetShellStatusRequest) Reset() { + *x = GetShellStatusRequest{} + mi := &file_stackpanel_agent_v1_shell_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetShellStatusRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetShellStatusRequest) ProtoMessage() {} + +func (x *GetShellStatusRequest) ProtoReflect() protoreflect.Message { + mi := &file_stackpanel_agent_v1_shell_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetShellStatusRequest.ProtoReflect.Descriptor instead. +func (*GetShellStatusRequest) Descriptor() ([]byte, []int) { + return file_stackpanel_agent_v1_shell_proto_rawDescGZIP(), []int{0} +} + +type GetShellStatusResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // True when Nix files changed after the shell was last built. + Stale bool `protobuf:"varint,1,opt,name=stale,proto3" json:"stale,omitempty"` + Rebuilding bool `protobuf:"varint,2,opt,name=rebuilding,proto3" json:"rebuilding,omitempty"` + LastBuiltAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=last_built_at,json=lastBuiltAt,proto3" json:"last_built_at,omitempty"` + LastNixChangeAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=last_nix_change_at,json=lastNixChangeAt,proto3" json:"last_nix_change_at,omitempty"` + // Files changed since the last build, relative to the project root. + ChangedFiles []string `protobuf:"bytes,5,rep,name=changed_files,json=changedFiles,proto3" json:"changed_files,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetShellStatusResponse) Reset() { + *x = GetShellStatusResponse{} + mi := &file_stackpanel_agent_v1_shell_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetShellStatusResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetShellStatusResponse) ProtoMessage() {} + +func (x *GetShellStatusResponse) ProtoReflect() protoreflect.Message { + mi := &file_stackpanel_agent_v1_shell_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetShellStatusResponse.ProtoReflect.Descriptor instead. +func (*GetShellStatusResponse) Descriptor() ([]byte, []int) { + return file_stackpanel_agent_v1_shell_proto_rawDescGZIP(), []int{1} +} + +func (x *GetShellStatusResponse) GetStale() bool { + if x != nil { + return x.Stale + } + return false +} + +func (x *GetShellStatusResponse) GetRebuilding() bool { + if x != nil { + return x.Rebuilding + } + return false +} + +func (x *GetShellStatusResponse) GetLastBuiltAt() *timestamppb.Timestamp { + if x != nil { + return x.LastBuiltAt + } + return nil +} + +func (x *GetShellStatusResponse) GetLastNixChangeAt() *timestamppb.Timestamp { + if x != nil { + return x.LastNixChangeAt + } + return nil +} + +func (x *GetShellStatusResponse) GetChangedFiles() []string { + if x != nil { + return x.ChangedFiles + } + return nil +} + +type RebuildShellRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Method RebuildMethod `protobuf:"varint,1,opt,name=method,proto3,enum=stackpanel.agent.v1.RebuildMethod" json:"method,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RebuildShellRequest) Reset() { + *x = RebuildShellRequest{} + mi := &file_stackpanel_agent_v1_shell_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RebuildShellRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RebuildShellRequest) ProtoMessage() {} + +func (x *RebuildShellRequest) ProtoReflect() protoreflect.Message { + mi := &file_stackpanel_agent_v1_shell_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RebuildShellRequest.ProtoReflect.Descriptor instead. +func (*RebuildShellRequest) Descriptor() ([]byte, []int) { + return file_stackpanel_agent_v1_shell_proto_rawDescGZIP(), []int{2} +} + +func (x *RebuildShellRequest) GetMethod() RebuildMethod { + if x != nil { + return x.Method + } + return RebuildMethod_REBUILD_METHOD_UNSPECIFIED +} + +type RebuildShellResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Event: + // + // *RebuildShellResponse_Started + // *RebuildShellResponse_OutputLine + // *RebuildShellResponse_Completed + Event isRebuildShellResponse_Event `protobuf_oneof:"event"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RebuildShellResponse) Reset() { + *x = RebuildShellResponse{} + mi := &file_stackpanel_agent_v1_shell_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RebuildShellResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RebuildShellResponse) ProtoMessage() {} + +func (x *RebuildShellResponse) ProtoReflect() protoreflect.Message { + mi := &file_stackpanel_agent_v1_shell_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RebuildShellResponse.ProtoReflect.Descriptor instead. +func (*RebuildShellResponse) Descriptor() ([]byte, []int) { + return file_stackpanel_agent_v1_shell_proto_rawDescGZIP(), []int{3} +} + +func (x *RebuildShellResponse) GetEvent() isRebuildShellResponse_Event { + if x != nil { + return x.Event + } + return nil +} + +func (x *RebuildShellResponse) GetStarted() *RebuildStarted { + if x != nil { + if x, ok := x.Event.(*RebuildShellResponse_Started); ok { + return x.Started + } + } + return nil +} + +func (x *RebuildShellResponse) GetOutputLine() string { + if x != nil { + if x, ok := x.Event.(*RebuildShellResponse_OutputLine); ok { + return x.OutputLine + } + } + return "" +} + +func (x *RebuildShellResponse) GetCompleted() *RebuildCompleted { + if x != nil { + if x, ok := x.Event.(*RebuildShellResponse_Completed); ok { + return x.Completed + } + } + return nil +} + +type isRebuildShellResponse_Event interface { + isRebuildShellResponse_Event() +} + +type RebuildShellResponse_Started struct { + Started *RebuildStarted `protobuf:"bytes,1,opt,name=started,proto3,oneof"` +} + +type RebuildShellResponse_OutputLine struct { + // One line of rebuild output, stdout and stderr interleaved in order. + OutputLine string `protobuf:"bytes,2,opt,name=output_line,json=outputLine,proto3,oneof"` +} + +type RebuildShellResponse_Completed struct { + Completed *RebuildCompleted `protobuf:"bytes,3,opt,name=completed,proto3,oneof"` +} + +func (*RebuildShellResponse_Started) isRebuildShellResponse_Event() {} + +func (*RebuildShellResponse_OutputLine) isRebuildShellResponse_Event() {} + +func (*RebuildShellResponse_Completed) isRebuildShellResponse_Event() {} + +type RebuildStarted struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RebuildStarted) Reset() { + *x = RebuildStarted{} + mi := &file_stackpanel_agent_v1_shell_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RebuildStarted) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RebuildStarted) ProtoMessage() {} + +func (x *RebuildStarted) ProtoReflect() protoreflect.Message { + mi := &file_stackpanel_agent_v1_shell_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RebuildStarted.ProtoReflect.Descriptor instead. +func (*RebuildStarted) Descriptor() ([]byte, []int) { + return file_stackpanel_agent_v1_shell_proto_rawDescGZIP(), []int{4} +} + +type RebuildCompleted struct { + state protoimpl.MessageState `protogen:"open.v1"` + ExitCode int32 `protobuf:"varint,1,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RebuildCompleted) Reset() { + *x = RebuildCompleted{} + mi := &file_stackpanel_agent_v1_shell_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RebuildCompleted) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RebuildCompleted) ProtoMessage() {} + +func (x *RebuildCompleted) ProtoReflect() protoreflect.Message { + mi := &file_stackpanel_agent_v1_shell_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RebuildCompleted.ProtoReflect.Descriptor instead. +func (*RebuildCompleted) Descriptor() ([]byte, []int) { + return file_stackpanel_agent_v1_shell_proto_rawDescGZIP(), []int{5} +} + +func (x *RebuildCompleted) GetExitCode() int32 { + if x != nil { + return x.ExitCode + } + return 0 +} + +var File_stackpanel_agent_v1_shell_proto protoreflect.FileDescriptor + +const file_stackpanel_agent_v1_shell_proto_rawDesc = "" + + "\n" + + "\x1fstackpanel/agent/v1/shell.proto\x12\x13stackpanel.agent.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x17\n" + + "\x15GetShellStatusRequest\"\xfc\x01\n" + + "\x16GetShellStatusResponse\x12\x14\n" + + "\x05stale\x18\x01 \x01(\bR\x05stale\x12\x1e\n" + + "\n" + + "rebuilding\x18\x02 \x01(\bR\n" + + "rebuilding\x12>\n" + + "\rlast_built_at\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\vlastBuiltAt\x12G\n" + + "\x12last_nix_change_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x0flastNixChangeAt\x12#\n" + + "\rchanged_files\x18\x05 \x03(\tR\fchangedFiles\"Q\n" + + "\x13RebuildShellRequest\x12:\n" + + "\x06method\x18\x01 \x01(\x0e2\".stackpanel.agent.v1.RebuildMethodR\x06method\"\xca\x01\n" + + "\x14RebuildShellResponse\x12?\n" + + "\astarted\x18\x01 \x01(\v2#.stackpanel.agent.v1.RebuildStartedH\x00R\astarted\x12!\n" + + "\voutput_line\x18\x02 \x01(\tH\x00R\n" + + "outputLine\x12E\n" + + "\tcompleted\x18\x03 \x01(\v2%.stackpanel.agent.v1.RebuildCompletedH\x00R\tcompletedB\a\n" + + "\x05event\"\x10\n" + + "\x0eRebuildStarted\"/\n" + + "\x10RebuildCompleted\x12\x1b\n" + + "\texit_code\x18\x01 \x01(\x05R\bexitCode*l\n" + + "\rRebuildMethod\x12\x1e\n" + + "\x1aREBUILD_METHOD_UNSPECIFIED\x10\x00\x12\x1b\n" + + "\x17REBUILD_METHOD_DEVSHELL\x10\x01\x12\x1e\n" + + "\x1aREBUILD_METHOD_NIX_DEVELOP\x10\x022\xe5\x01\n" + + "\fShellService\x12n\n" + + "\x0eGetShellStatus\x12*.stackpanel.agent.v1.GetShellStatusRequest\x1a+.stackpanel.agent.v1.GetShellStatusResponse\"\x03\x90\x02\x01\x12e\n" + + "\fRebuildShell\x12(.stackpanel.agent.v1.RebuildShellRequest\x1a).stackpanel.agent.v1.RebuildShellResponse0\x01BVZTgithub.com/darkmatter/stackpanel/packages/proto/gen/gopb/stackpanel/agent/v1;agentv1b\x06proto3" + +var ( + file_stackpanel_agent_v1_shell_proto_rawDescOnce sync.Once + file_stackpanel_agent_v1_shell_proto_rawDescData []byte +) + +func file_stackpanel_agent_v1_shell_proto_rawDescGZIP() []byte { + file_stackpanel_agent_v1_shell_proto_rawDescOnce.Do(func() { + file_stackpanel_agent_v1_shell_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_stackpanel_agent_v1_shell_proto_rawDesc), len(file_stackpanel_agent_v1_shell_proto_rawDesc))) + }) + return file_stackpanel_agent_v1_shell_proto_rawDescData +} + +var ( + file_stackpanel_agent_v1_shell_proto_enumTypes = make([]protoimpl.EnumInfo, 1) + file_stackpanel_agent_v1_shell_proto_msgTypes = make([]protoimpl.MessageInfo, 6) + file_stackpanel_agent_v1_shell_proto_goTypes = []any{ + RebuildMethod(0), // 0: stackpanel.agent.v1.RebuildMethod + (*GetShellStatusRequest)(nil), // 1: stackpanel.agent.v1.GetShellStatusRequest + (*GetShellStatusResponse)(nil), // 2: stackpanel.agent.v1.GetShellStatusResponse + (*RebuildShellRequest)(nil), // 3: stackpanel.agent.v1.RebuildShellRequest + (*RebuildShellResponse)(nil), // 4: stackpanel.agent.v1.RebuildShellResponse + (*RebuildStarted)(nil), // 5: stackpanel.agent.v1.RebuildStarted + (*RebuildCompleted)(nil), // 6: stackpanel.agent.v1.RebuildCompleted + (*timestamppb.Timestamp)(nil), // 7: google.protobuf.Timestamp + } +) + +var file_stackpanel_agent_v1_shell_proto_depIdxs = []int32{ + 7, // 0: stackpanel.agent.v1.GetShellStatusResponse.last_built_at:type_name -> google.protobuf.Timestamp + 7, // 1: stackpanel.agent.v1.GetShellStatusResponse.last_nix_change_at:type_name -> google.protobuf.Timestamp + 0, // 2: stackpanel.agent.v1.RebuildShellRequest.method:type_name -> stackpanel.agent.v1.RebuildMethod + 5, // 3: stackpanel.agent.v1.RebuildShellResponse.started:type_name -> stackpanel.agent.v1.RebuildStarted + 6, // 4: stackpanel.agent.v1.RebuildShellResponse.completed:type_name -> stackpanel.agent.v1.RebuildCompleted + 1, // 5: stackpanel.agent.v1.ShellService.GetShellStatus:input_type -> stackpanel.agent.v1.GetShellStatusRequest + 3, // 6: stackpanel.agent.v1.ShellService.RebuildShell:input_type -> stackpanel.agent.v1.RebuildShellRequest + 2, // 7: stackpanel.agent.v1.ShellService.GetShellStatus:output_type -> stackpanel.agent.v1.GetShellStatusResponse + 4, // 8: stackpanel.agent.v1.ShellService.RebuildShell:output_type -> stackpanel.agent.v1.RebuildShellResponse + 7, // [7:9] is the sub-list for method output_type + 5, // [5:7] is the sub-list for method input_type + 5, // [5:5] is the sub-list for extension type_name + 5, // [5:5] is the sub-list for extension extendee + 0, // [0:5] is the sub-list for field type_name +} + +func init() { file_stackpanel_agent_v1_shell_proto_init() } +func file_stackpanel_agent_v1_shell_proto_init() { + if File_stackpanel_agent_v1_shell_proto != nil { + return + } + file_stackpanel_agent_v1_shell_proto_msgTypes[3].OneofWrappers = []any{ + (*RebuildShellResponse_Started)(nil), + (*RebuildShellResponse_OutputLine)(nil), + (*RebuildShellResponse_Completed)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_stackpanel_agent_v1_shell_proto_rawDesc), len(file_stackpanel_agent_v1_shell_proto_rawDesc)), + NumEnums: 1, + NumMessages: 6, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_stackpanel_agent_v1_shell_proto_goTypes, + DependencyIndexes: file_stackpanel_agent_v1_shell_proto_depIdxs, + EnumInfos: file_stackpanel_agent_v1_shell_proto_enumTypes, + MessageInfos: file_stackpanel_agent_v1_shell_proto_msgTypes, + }.Build() + File_stackpanel_agent_v1_shell_proto = out.File + file_stackpanel_agent_v1_shell_proto_goTypes = nil + file_stackpanel_agent_v1_shell_proto_depIdxs = nil +} diff --git a/packages/proto/gen/ts/stackpanel/agent/v1/project-ProjectService_connectquery.d.ts b/packages/proto/gen/ts/stackpanel/agent/v1/project-ProjectService_connectquery.d.ts index c2d14a20..70b5737c 100644 --- a/packages/proto/gen/ts/stackpanel/agent/v1/project-ProjectService_connectquery.d.ts +++ b/packages/proto/gen/ts/stackpanel/agent/v1/project-ProjectService_connectquery.d.ts @@ -23,8 +23,9 @@ export const addProject: typeof ProjectService["method"]["addProject"]; */ export const removeProject: typeof ProjectService["method"]["removeProject"]; /** - * Sets the project used for requests that carry no Stackpanel-Project - * header, such as CLI and TUI calls. + * Sets the project used for requests that carry no X-Stackpanel-Project + * header when the agent has no current project, such as calls to a global + * agent started outside any project. * * @generated from rpc stackpanel.agent.v1.ProjectService.SetDefaultProject */ diff --git a/packages/proto/gen/ts/stackpanel/agent/v1/project-ProjectService_connectquery.js b/packages/proto/gen/ts/stackpanel/agent/v1/project-ProjectService_connectquery.js index d6016c2f..bcd70cf4 100644 --- a/packages/proto/gen/ts/stackpanel/agent/v1/project-ProjectService_connectquery.js +++ b/packages/proto/gen/ts/stackpanel/agent/v1/project-ProjectService_connectquery.js @@ -26,8 +26,9 @@ export const addProject = ProjectService.method.addProject; export const removeProject = ProjectService.method.removeProject; /** - * Sets the project used for requests that carry no Stackpanel-Project - * header, such as CLI and TUI calls. + * Sets the project used for requests that carry no X-Stackpanel-Project + * header when the agent has no current project, such as calls to a global + * agent started outside any project. * * @generated from rpc stackpanel.agent.v1.ProjectService.SetDefaultProject */ diff --git a/packages/proto/gen/ts/stackpanel/agent/v1/project_pb.d.ts b/packages/proto/gen/ts/stackpanel/agent/v1/project_pb.d.ts index 3eff3e3f..ff5d0ca6 100644 --- a/packages/proto/gen/ts/stackpanel/agent/v1/project_pb.d.ts +++ b/packages/proto/gen/ts/stackpanel/agent/v1/project_pb.d.ts @@ -17,7 +17,7 @@ export declare const file_stackpanel_agent_v1_project: GenFile; export declare type Project = Message<"stackpanel.agent.v1.Project"> & { /** * Stable identifier derived from the project path; the value clients send - * in the Stackpanel-Project header. + * in the X-Stackpanel-Project header. * * @generated from field: string id = 1; */ @@ -196,9 +196,9 @@ export declare const SetDefaultProjectResponseSchema: GenMessage & { +}; + +/** + * Describes the message stackpanel.agent.v1.GetShellStatusRequest. + * Use `create(GetShellStatusRequestSchema)` to create a new message. + */ +export declare const GetShellStatusRequestSchema: GenMessage; + +/** + * @generated from message stackpanel.agent.v1.GetShellStatusResponse + */ +export declare type GetShellStatusResponse = Message<"stackpanel.agent.v1.GetShellStatusResponse"> & { + /** + * True when Nix files changed after the shell was last built. + * + * @generated from field: bool stale = 1; + */ + stale: boolean; + + /** + * @generated from field: bool rebuilding = 2; + */ + rebuilding: boolean; + + /** + * @generated from field: google.protobuf.Timestamp last_built_at = 3; + */ + lastBuiltAt?: Timestamp; + + /** + * @generated from field: google.protobuf.Timestamp last_nix_change_at = 4; + */ + lastNixChangeAt?: Timestamp; + + /** + * Files changed since the last build, relative to the project root. + * + * @generated from field: repeated string changed_files = 5; + */ + changedFiles: string[]; +}; + +/** + * Describes the message stackpanel.agent.v1.GetShellStatusResponse. + * Use `create(GetShellStatusResponseSchema)` to create a new message. + */ +export declare const GetShellStatusResponseSchema: GenMessage; + +/** + * @generated from message stackpanel.agent.v1.RebuildShellRequest + */ +export declare type RebuildShellRequest = Message<"stackpanel.agent.v1.RebuildShellRequest"> & { + /** + * @generated from field: stackpanel.agent.v1.RebuildMethod method = 1; + */ + method: RebuildMethod; +}; + +/** + * Describes the message stackpanel.agent.v1.RebuildShellRequest. + * Use `create(RebuildShellRequestSchema)` to create a new message. + */ +export declare const RebuildShellRequestSchema: GenMessage; + +/** + * @generated from message stackpanel.agent.v1.RebuildShellResponse + */ +export declare type RebuildShellResponse = Message<"stackpanel.agent.v1.RebuildShellResponse"> & { + /** + * @generated from oneof stackpanel.agent.v1.RebuildShellResponse.event + */ + event: { + /** + * @generated from field: stackpanel.agent.v1.RebuildStarted started = 1; + */ + value: RebuildStarted; + case: "started"; + } | { + /** + * One line of rebuild output, stdout and stderr interleaved in order. + * + * @generated from field: string output_line = 2; + */ + value: string; + case: "outputLine"; + } | { + /** + * @generated from field: stackpanel.agent.v1.RebuildCompleted completed = 3; + */ + value: RebuildCompleted; + case: "completed"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message stackpanel.agent.v1.RebuildShellResponse. + * Use `create(RebuildShellResponseSchema)` to create a new message. + */ +export declare const RebuildShellResponseSchema: GenMessage; + +/** + * @generated from message stackpanel.agent.v1.RebuildStarted + */ +export declare type RebuildStarted = Message<"stackpanel.agent.v1.RebuildStarted"> & { +}; + +/** + * Describes the message stackpanel.agent.v1.RebuildStarted. + * Use `create(RebuildStartedSchema)` to create a new message. + */ +export declare const RebuildStartedSchema: GenMessage; + +/** + * @generated from message stackpanel.agent.v1.RebuildCompleted + */ +export declare type RebuildCompleted = Message<"stackpanel.agent.v1.RebuildCompleted"> & { + /** + * @generated from field: int32 exit_code = 1; + */ + exitCode: number; +}; + +/** + * Describes the message stackpanel.agent.v1.RebuildCompleted. + * Use `create(RebuildCompletedSchema)` to create a new message. + */ +export declare const RebuildCompletedSchema: GenMessage; + +/** + * @generated from enum stackpanel.agent.v1.RebuildMethod + */ +export enum RebuildMethod { + /** + * Treated as REBUILD_METHOD_DEVSHELL. + * + * @generated from enum value: REBUILD_METHOD_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Runs the project's ./devshell entrypoint, falling back to nix develop. + * + * @generated from enum value: REBUILD_METHOD_DEVSHELL = 1; + */ + DEVSHELL = 1, + + /** + * Always runs nix develop. + * + * @generated from enum value: REBUILD_METHOD_NIX_DEVELOP = 2; + */ + NIX_DEVELOP = 2, +} + +/** + * Describes the enum stackpanel.agent.v1.RebuildMethod. + */ +export declare const RebuildMethodSchema: GenEnum; + +/** + * ShellService reports and rebuilds a project's devshell. + * + * Like every project-scoped service, it runs against the project named by + * the X-Stackpanel-Project request header, or the agent's current project + * when the header is absent (ADR 0005). + * + * @generated from service stackpanel.agent.v1.ShellService + */ +export declare const ShellService: GenService<{ + /** + * @generated from rpc stackpanel.agent.v1.ShellService.GetShellStatus + */ + getShellStatus: { + methodKind: "unary"; + input: typeof GetShellStatusRequestSchema; + output: typeof GetShellStatusResponseSchema; + }, + /** + * Rebuilds the devshell and streams its output. The stream starts with a + * started event and ends with a completed event carrying the exit code. A + * rebuild that cannot run ends the stream with an error instead. + * + * @generated from rpc stackpanel.agent.v1.ShellService.RebuildShell + */ + rebuildShell: { + methodKind: "server_streaming"; + input: typeof RebuildShellRequestSchema; + output: typeof RebuildShellResponseSchema; + }, +}>; + diff --git a/packages/proto/gen/ts/stackpanel/agent/v1/shell_pb.js b/packages/proto/gen/ts/stackpanel/agent/v1/shell_pb.js new file mode 100644 index 00000000..cef2fd77 --- /dev/null +++ b/packages/proto/gen/ts/stackpanel/agent/v1/shell_pb.js @@ -0,0 +1,79 @@ +// @generated by protoc-gen-es v2.7.0 +// @generated from file stackpanel/agent/v1/shell.proto (package stackpanel.agent.v1, syntax proto3) +/* eslint-disable */ + +import { enumDesc, fileDesc, messageDesc, serviceDesc, tsEnum } from "@bufbuild/protobuf/codegenv2"; +import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; + +/** + * Describes the file stackpanel/agent/v1/shell.proto. + */ +export const file_stackpanel_agent_v1_shell = /*@__PURE__*/ + fileDesc("Ch9zdGFja3BhbmVsL2FnZW50L3YxL3NoZWxsLnByb3RvEhNzdGFja3BhbmVsLmFnZW50LnYxIhcKFUdldFNoZWxsU3RhdHVzUmVxdWVzdCK9AQoWR2V0U2hlbGxTdGF0dXNSZXNwb25zZRINCgVzdGFsZRgBIAEoCBISCgpyZWJ1aWxkaW5nGAIgASgIEjEKDWxhc3RfYnVpbHRfYXQYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjYKEmxhc3Rfbml4X2NoYW5nZV9hdBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASFQoNY2hhbmdlZF9maWxlcxgFIAMoCSJJChNSZWJ1aWxkU2hlbGxSZXF1ZXN0EjIKBm1ldGhvZBgBIAEoDjIiLnN0YWNrcGFuZWwuYWdlbnQudjEuUmVidWlsZE1ldGhvZCKqAQoUUmVidWlsZFNoZWxsUmVzcG9uc2USNgoHc3RhcnRlZBgBIAEoCzIjLnN0YWNrcGFuZWwuYWdlbnQudjEuUmVidWlsZFN0YXJ0ZWRIABIVCgtvdXRwdXRfbGluZRgCIAEoCUgAEjoKCWNvbXBsZXRlZBgDIAEoCzIlLnN0YWNrcGFuZWwuYWdlbnQudjEuUmVidWlsZENvbXBsZXRlZEgAQgcKBWV2ZW50IhAKDlJlYnVpbGRTdGFydGVkIiUKEFJlYnVpbGRDb21wbGV0ZWQSEQoJZXhpdF9jb2RlGAEgASgFKmwKDVJlYnVpbGRNZXRob2QSHgoaUkVCVUlMRF9NRVRIT0RfVU5TUEVDSUZJRUQQABIbChdSRUJVSUxEX01FVEhPRF9ERVZTSEVMTBABEh4KGlJFQlVJTERfTUVUSE9EX05JWF9ERVZFTE9QEAIy5QEKDFNoZWxsU2VydmljZRJuCg5HZXRTaGVsbFN0YXR1cxIqLnN0YWNrcGFuZWwuYWdlbnQudjEuR2V0U2hlbGxTdGF0dXNSZXF1ZXN0Gisuc3RhY2twYW5lbC5hZ2VudC52MS5HZXRTaGVsbFN0YXR1c1Jlc3BvbnNlIgOQAgESZQoMUmVidWlsZFNoZWxsEiguc3RhY2twYW5lbC5hZ2VudC52MS5SZWJ1aWxkU2hlbGxSZXF1ZXN0Gikuc3RhY2twYW5lbC5hZ2VudC52MS5SZWJ1aWxkU2hlbGxSZXNwb25zZTABQlZaVGdpdGh1Yi5jb20vZGFya21hdHRlci9zdGFja3BhbmVsL3BhY2thZ2VzL3Byb3RvL2dlbi9nb3BiL3N0YWNrcGFuZWwvYWdlbnQvdjE7YWdlbnR2MWIGcHJvdG8z", [file_google_protobuf_timestamp]); + +/** + * Describes the message stackpanel.agent.v1.GetShellStatusRequest. + * Use `create(GetShellStatusRequestSchema)` to create a new message. + */ +export const GetShellStatusRequestSchema = /*@__PURE__*/ + messageDesc(file_stackpanel_agent_v1_shell, 0); + +/** + * Describes the message stackpanel.agent.v1.GetShellStatusResponse. + * Use `create(GetShellStatusResponseSchema)` to create a new message. + */ +export const GetShellStatusResponseSchema = /*@__PURE__*/ + messageDesc(file_stackpanel_agent_v1_shell, 1); + +/** + * Describes the message stackpanel.agent.v1.RebuildShellRequest. + * Use `create(RebuildShellRequestSchema)` to create a new message. + */ +export const RebuildShellRequestSchema = /*@__PURE__*/ + messageDesc(file_stackpanel_agent_v1_shell, 2); + +/** + * Describes the message stackpanel.agent.v1.RebuildShellResponse. + * Use `create(RebuildShellResponseSchema)` to create a new message. + */ +export const RebuildShellResponseSchema = /*@__PURE__*/ + messageDesc(file_stackpanel_agent_v1_shell, 3); + +/** + * Describes the message stackpanel.agent.v1.RebuildStarted. + * Use `create(RebuildStartedSchema)` to create a new message. + */ +export const RebuildStartedSchema = /*@__PURE__*/ + messageDesc(file_stackpanel_agent_v1_shell, 4); + +/** + * Describes the message stackpanel.agent.v1.RebuildCompleted. + * Use `create(RebuildCompletedSchema)` to create a new message. + */ +export const RebuildCompletedSchema = /*@__PURE__*/ + messageDesc(file_stackpanel_agent_v1_shell, 5); + +/** + * Describes the enum stackpanel.agent.v1.RebuildMethod. + */ +export const RebuildMethodSchema = /*@__PURE__*/ + enumDesc(file_stackpanel_agent_v1_shell, 0); + +/** + * @generated from enum stackpanel.agent.v1.RebuildMethod + */ +export const RebuildMethod = /*@__PURE__*/ + tsEnum(RebuildMethodSchema); + +/** + * ShellService reports and rebuilds a project's devshell. + * + * Like every project-scoped service, it runs against the project named by + * the X-Stackpanel-Project request header, or the agent's current project + * when the header is absent (ADR 0005). + * + * @generated from service stackpanel.agent.v1.ShellService + */ +export const ShellService = /*@__PURE__*/ + serviceDesc(file_stackpanel_agent_v1_shell, 0); + diff --git a/packages/proto/proto/stackpanel/agent/v1/project.proto b/packages/proto/proto/stackpanel/agent/v1/project.proto index cdf01083..763d85ae 100644 --- a/packages/proto/proto/stackpanel/agent/v1/project.proto +++ b/packages/proto/proto/stackpanel/agent/v1/project.proto @@ -10,9 +10,9 @@ option go_package = "github.com/darkmatter/stackpanel/packages/proto/gen/gopb/st // ~/.config/stackpanel/stackpanel.yaml. // // It is the one service that does not run inside a project. Every other -// service resolves its project per request from the Stackpanel-Project -// header, falling back to the registry's default project (ADR 0005), so -// "opening" a project is a client-side choice, not agent state. +// service resolves its project per request from the X-Stackpanel-Project +// header (ADR 0005), so "opening" a project is a client-side choice, not +// agent state. service ProjectService { rpc ListProjects(ListProjectsRequest) returns (ListProjectsResponse) { option idempotency_level = NO_SIDE_EFFECTS; @@ -26,14 +26,15 @@ service ProjectService { // Removes a project from the registry. The directory is left untouched. rpc RemoveProject(RemoveProjectRequest) returns (RemoveProjectResponse); - // Sets the project used for requests that carry no Stackpanel-Project - // header, such as CLI and TUI calls. + // Sets the project used for requests that carry no X-Stackpanel-Project + // header when the agent has no current project, such as calls to a global + // agent started outside any project. rpc SetDefaultProject(SetDefaultProjectRequest) returns (SetDefaultProjectResponse); } message Project { // Stable identifier derived from the project path; the value clients send - // in the Stackpanel-Project header. + // in the X-Stackpanel-Project header. string id = 1; // Display name. diff --git a/packages/proto/proto/stackpanel/agent/v1/shell.proto b/packages/proto/proto/stackpanel/agent/v1/shell.proto new file mode 100644 index 00000000..b1ca0aea --- /dev/null +++ b/packages/proto/proto/stackpanel/agent/v1/shell.proto @@ -0,0 +1,68 @@ +syntax = "proto3"; + +package stackpanel.agent.v1; + +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/darkmatter/stackpanel/packages/proto/gen/gopb/stackpanel/agent/v1;agentv1"; + +// ShellService reports and rebuilds a project's devshell. +// +// Like every project-scoped service, it runs against the project named by +// the X-Stackpanel-Project request header, or the agent's current project +// when the header is absent (ADR 0005). +service ShellService { + rpc GetShellStatus(GetShellStatusRequest) returns (GetShellStatusResponse) { + option idempotency_level = NO_SIDE_EFFECTS; + } + + // Rebuilds the devshell and streams its output. The stream starts with a + // started event and ends with a completed event carrying the exit code. A + // rebuild that cannot run ends the stream with an error instead. + rpc RebuildShell(RebuildShellRequest) returns (stream RebuildShellResponse); +} + +message GetShellStatusRequest {} + +message GetShellStatusResponse { + // True when Nix files changed after the shell was last built. + bool stale = 1; + bool rebuilding = 2; + google.protobuf.Timestamp last_built_at = 3; + google.protobuf.Timestamp last_nix_change_at = 4; + + // Files changed since the last build, relative to the project root. + repeated string changed_files = 5; +} + +enum RebuildMethod { + // Treated as REBUILD_METHOD_DEVSHELL. + REBUILD_METHOD_UNSPECIFIED = 0; + + // Runs the project's ./devshell entrypoint, falling back to nix develop. + REBUILD_METHOD_DEVSHELL = 1; + + // Always runs nix develop. + REBUILD_METHOD_NIX_DEVELOP = 2; +} + +message RebuildShellRequest { + RebuildMethod method = 1; +} + +message RebuildShellResponse { + oneof event { + RebuildStarted started = 1; + + // One line of rebuild output, stdout and stderr interleaved in order. + string output_line = 2; + + RebuildCompleted completed = 3; + } +} + +message RebuildStarted {} + +message RebuildCompleted { + int32 exit_code = 1; +}