diff --git a/apps/stackpanel-go/cmd/cli/agent.go b/apps/stackpanel-go/cmd/cli/agent.go index c4307eb6..22196848 100644 --- a/apps/stackpanel-go/cmd/cli/agent.go +++ b/apps/stackpanel-go/cmd/cli/agent.go @@ -103,6 +103,8 @@ func runAgent(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } + cfg.Version = Version + // Only override when the user explicitly passed --port; both the flag // default and config.Load default to 9876, so we can't distinguish // "user typed --port 9876" from "user omitted --port". This means diff --git a/apps/stackpanel-go/internal/agent/config/config.go b/apps/stackpanel-go/internal/agent/config/config.go index ee73fd6a..55c51e3b 100644 --- a/apps/stackpanel-go/internal/agent/config/config.go +++ b/apps/stackpanel-go/internal/agent/config/config.go @@ -34,6 +34,10 @@ type Config struct { // Authentication token (from STACKPANEL_AUTH_TOKEN) AuthToken string + // Version is the agent build version reported by + // stackpanel.agent.v1.AgentService/GetAgentInfo. + Version string + // TestPairingToken enables deterministic pairing for integration tests. // Normally the agent generates a random signing key on startup, making // the JWT pairing token unpredictable. This field pins both the signing diff --git a/apps/stackpanel-go/internal/agent/project/connect.go b/apps/stackpanel-go/internal/agent/project/connect.go new file mode 100644 index 00000000..d380d67b --- /dev/null +++ b/apps/stackpanel-go/internal/agent/project/connect.go @@ -0,0 +1,158 @@ +package project + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "sort" + + "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" + "github.com/darkmatter/stackpanel/stackpanel-go/pkg/userconfig" +) + +var _ agentv1connect.ProjectServiceHandler = (*ConnectHandler)(nil) + +// ConnectHandler serves stackpanel.agent.v1.ProjectService over the +// user-level project registry. +// +// Projects are selected per request (ADR 0005), so this service only +// maintains the registry; it never changes which project other requests +// run against. +type ConnectHandler struct { + registry *userconfig.Manager + + // checkListed reports whether a listed project is still usable; it runs + // for every project on every list, so it must stay cheap. + checkListed func(path string) error + + // checkAdded fully validates a project before it is registered. + checkAdded func(path string) error +} + +// NewConnectHandler returns a ProjectService backed by the manager's registry. +func NewConnectHandler(m *Manager) *ConnectHandler { + return &ConnectHandler{ + registry: m.UserConfigManager(), + checkListed: QuickValidate, + checkAdded: ValidateProject, + } +} + +func (h *ConnectHandler) ListProjects( + _ context.Context, + _ *connect.Request[agentv1.ListProjectsRequest], +) (*connect.Response[agentv1.ListProjectsResponse], error) { + projects := h.registry.ListProjects() + sort.SliceStable(projects, func(i, j int) bool { + return projects[i].LastOpened.After(projects[j].LastOpened) + }) + + res := &agentv1.ListProjectsResponse{ + Projects: make([]*agentv1.Project, 0, len(projects)), + } + for _, p := range projects { + res.Projects = append(res.Projects, h.toProto(p)) + } + if def := h.registry.GetDefaultProject(); def != nil { + res.DefaultProjectId = projectID(*def) + } + return connect.NewResponse(res), nil +} + +func (h *ConnectHandler) AddProject( + _ context.Context, + req *connect.Request[agentv1.AddProjectRequest], +) (*connect.Response[agentv1.AddProjectResponse], error) { + path := req.Msg.GetPath() + if !filepath.IsAbs(path) { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("path must be absolute: %q", path)) + } + path = filepath.Clean(path) + if err := h.checkAdded(path); err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + + p, err := h.registry.AddProject(path, "") + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return connect.NewResponse(&agentv1.AddProjectResponse{Project: h.toProto(*p)}), nil +} + +func (h *ConnectHandler) RemoveProject( + _ context.Context, + req *connect.Request[agentv1.RemoveProjectRequest], +) (*connect.Response[agentv1.RemoveProjectResponse], error) { + p, err := h.lookup(req.Msg.GetProjectId()) + if err != nil { + return nil, err + } + if err := h.registry.RemoveProject(p.Path); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return connect.NewResponse(&agentv1.RemoveProjectResponse{}), nil +} + +func (h *ConnectHandler) SetDefaultProject( + _ context.Context, + req *connect.Request[agentv1.SetDefaultProjectRequest], +) (*connect.Response[agentv1.SetDefaultProjectResponse], error) { + if req.Msg.GetProjectId() == "" { + if err := h.registry.ClearDefaultProject(); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return connect.NewResponse(&agentv1.SetDefaultProjectResponse{}), nil + } + + p, err := h.lookup(req.Msg.GetProjectId()) + if err != nil { + return nil, err + } + if err := h.registry.SetDefaultProject(p.Path); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return connect.NewResponse(&agentv1.SetDefaultProjectResponse{}), nil +} + +func (h *ConnectHandler) lookup(id string) (*userconfig.Project, error) { + if id == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("project_id is required")) + } + p := h.registry.GetProjectByID(id) + if p == nil { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("unknown project %q", id)) + } + return p, nil +} + +func (h *ConnectHandler) toProto(p userconfig.Project) *agentv1.Project { + out := &agentv1.Project{ + Id: projectID(p), + Name: p.Name, + Path: p.Path, + Valid: true, + } + if !p.LastOpened.IsZero() { + out.LastOpenedAt = timestamppb.New(p.LastOpened) + } + if err := h.checkListed(p.Path); err != nil { + out.Valid = false + out.InvalidReason = err.Error() + } + return out +} + +// projectID is the identifier clients send in the Stackpanel-Project header. +// Older registry entries have no stored ID, so it falls back to the one +// derived from the path, matching userconfig.Manager.GetProjectByID. +func projectID(p userconfig.Project) string { + if p.ID != "" { + return p.ID + } + return userconfig.GenerateProjectID(p.Path) +} diff --git a/apps/stackpanel-go/internal/agent/project/connect_test.go b/apps/stackpanel-go/internal/agent/project/connect_test.go new file mode 100644 index 00000000..fb8e05fe --- /dev/null +++ b/apps/stackpanel-go/internal/agent/project/connect_test.go @@ -0,0 +1,150 @@ +package project + +import ( + "context" + "errors" + "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/pkg/userconfig" +) + +var errGone = errors.New("project not found") + +// newTestClient serves a ProjectService over a registry in a temp config dir. +// Validity is decided by the `invalid` set so tests don't depend on the real +// validators (which reject temp dirs as suspicious and evaluate Nix). +func newTestClient(t *testing.T, invalid map[string]bool) (agentv1connect.ProjectServiceClient, *userconfig.Manager) { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + reg, err := userconfig.NewManager() + if err != nil { + t.Fatalf("userconfig.NewManager: %v", err) + } + + check := func(path string) error { + if invalid[path] { + return errGone + } + return nil + } + mux := http.NewServeMux() + mux.Handle(agentv1connect.NewProjectServiceHandler(&ConnectHandler{ + registry: reg, + checkListed: check, + checkAdded: check, + })) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return agentv1connect.NewProjectServiceClient(srv.Client(), srv.URL), reg +} + +func TestListProjectsReportsValidityAndDefault(t *testing.T) { + good := filepath.Join(t.TempDir(), "good") + gone := filepath.Join(t.TempDir(), "gone") + client, reg := newTestClient(t, map[string]bool{gone: true}) + + goodProject, err := reg.AddProject(good, "") + if err != nil { + t.Fatal(err) + } + if _, err := reg.AddProject(gone, ""); err != nil { + t.Fatal(err) + } + if err := reg.SetDefaultProject(goodProject.Path); err != nil { + t.Fatal(err) + } + + res, err := client.ListProjects(context.Background(), connect.NewRequest(&agentv1.ListProjectsRequest{})) + if err != nil { + t.Fatalf("ListProjects: %v", err) + } + + byPath := map[string]*agentv1.Project{} + for _, p := range res.Msg.GetProjects() { + byPath[p.GetPath()] = p + } + if len(byPath) != 2 { + t.Fatalf("got %d projects, want 2", len(byPath)) + } + if p := byPath[goodProject.Path]; !p.GetValid() || p.GetInvalidReason() != "" { + t.Errorf("good project: valid=%v reason=%q, want valid", p.GetValid(), p.GetInvalidReason()) + } + if p := byPath[filepath.Clean(gone)]; p.GetValid() || p.GetInvalidReason() != errGone.Error() { + t.Errorf("gone project: valid=%v reason=%q, want invalid with reason", p.GetValid(), p.GetInvalidReason()) + } + if got, want := res.Msg.GetDefaultProjectId(), byPath[goodProject.Path].GetId(); got != want { + t.Errorf("default project id = %q, want %q", got, want) + } +} + +func TestAddProjectValidatesBeforeRegistering(t *testing.T) { + bad := filepath.Join(t.TempDir(), "bad") + client, reg := newTestClient(t, map[string]bool{bad: true}) + ctx := context.Background() + + for _, path := range []string{"relative/dir", bad} { + _, err := client.AddProject(ctx, connect.NewRequest(&agentv1.AddProjectRequest{Path: path})) + if connect.CodeOf(err) != connect.CodeInvalidArgument { + t.Errorf("AddProject(%q) code = %v, want InvalidArgument", path, connect.CodeOf(err)) + } + } + if n := len(reg.ListProjects()); n != 0 { + t.Fatalf("rejected paths were registered: %d projects", n) + } + + dir := filepath.Join(t.TempDir(), "app") + first, err := client.AddProject(ctx, connect.NewRequest(&agentv1.AddProjectRequest{Path: dir})) + if err != nil { + t.Fatalf("AddProject: %v", err) + } + again, err := client.AddProject(ctx, connect.NewRequest(&agentv1.AddProjectRequest{Path: dir})) + if err != nil { + t.Fatalf("AddProject again: %v", err) + } + if first.Msg.GetProject().GetId() != again.Msg.GetProject().GetId() || len(reg.ListProjects()) != 1 { + t.Errorf("re-adding a registered path should return the existing project") + } +} + +func TestDefaultAndRemoveResolveProjectsByID(t *testing.T) { + client, reg := newTestClient(t, nil) + ctx := context.Background() + + p, err := reg.AddProject(filepath.Join(t.TempDir(), "app"), "") + if err != nil { + t.Fatal(err) + } + id := projectID(*p) + + if _, err := client.SetDefaultProject(ctx, connect.NewRequest(&agentv1.SetDefaultProjectRequest{ProjectId: id})); err != nil { + t.Fatalf("SetDefaultProject: %v", err) + } + if got := reg.GetDefaultProjectPath(); got != p.Path { + t.Errorf("default path = %q, want %q", got, p.Path) + } + if _, err := client.SetDefaultProject(ctx, connect.NewRequest(&agentv1.SetDefaultProjectRequest{})); err != nil { + t.Fatalf("clear default: %v", err) + } + if reg.GetDefaultProject() != nil { + t.Error("default project was not cleared") + } + + _, err = client.RemoveProject(ctx, connect.NewRequest(&agentv1.RemoveProjectRequest{ProjectId: "no-such-id"})) + if connect.CodeOf(err) != connect.CodeNotFound { + t.Errorf("RemoveProject(unknown) code = %v, want NotFound", connect.CodeOf(err)) + } + if _, err := client.RemoveProject(ctx, connect.NewRequest(&agentv1.RemoveProjectRequest{ProjectId: id})); err != nil { + t.Fatalf("RemoveProject: %v", err) + } + if n := len(reg.ListProjects()); n != 0 { + t.Errorf("project still registered after RemoveProject: %d", n) + } +} diff --git a/apps/stackpanel-go/internal/agent/server/connect_v1.go b/apps/stackpanel-go/internal/agent/server/connect_v1.go new file mode 100644 index 00000000..77784004 --- /dev/null +++ b/apps/stackpanel-go/internal/agent/server/connect_v1.go @@ -0,0 +1,150 @@ +package server + +import ( + "context" + "errors" + "net/http" + "strings" + + "connectrpc.com/connect" + "github.com/rs/zerolog/log" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" + + 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/project" +) + +// mountV1 serves the v1 Connect services (ADR 0004). CORS and the origin +// 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)) + for _, r := range routes { + mux.Handle(r.path, s.withCORS(s.withAllowedOrigin(r.handler.ServeHTTP))) + } +} + +type v1Route struct { + path string + handler http.Handler +} + +// 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 + +// 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 { + opts := connect.WithHandlerOptions( + connect.WithReadMaxBytes(v1ReadMaxBytes), + connect.WithInterceptors(authInterceptor{valid: validToken}), + connect.WithRecover(recoverRPC), + ) + info := &agentInfoService{version: version} + + routes := make([]v1Route, 0, 2) + 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)) + + info.procedures = procedurePaths(routes) + return routes +} + +// procedurePaths lists the procedures behind each mounted service path +// ("/./"), read from the registered descriptors so the +// list cannot drift from what is actually served. +func procedurePaths(routes []v1Route) []string { + var out []string + for _, r := range routes { + name := protoreflect.FullName(strings.Trim(r.path, "/")) + desc, err := protoregistry.GlobalFiles.FindDescriptorByName(name) + if err != nil { + // Generated handlers register their descriptors at init, so a + // miss means the route table itself is wrong. + panic("v1 route without a registered service descriptor: " + r.path) + } + methods := desc.(protoreflect.ServiceDescriptor).Methods() + for i := range methods.Len() { + out = append(out, r.path+string(methods.Get(i).Name())) + } + } + return out +} + +// agentInfoService implements stackpanel.agent.v1.AgentService. +type agentInfoService struct { + version string + procedures []string +} + +func (a *agentInfoService) GetAgentInfo( + context.Context, + *connect.Request[agentv1.GetAgentInfoRequest], +) (*connect.Response[agentv1.GetAgentInfoResponse], error) { + return connect.NewResponse(&agentv1.GetAgentInfoResponse{ + Version: a.version, + Procedures: a.procedures, + }), nil +} + +var errUnauthenticated = errors.New("missing or invalid token") + +// authInterceptor requires a valid agent token as an Authorization Bearer +// credential on every procedure except GetAgentInfo, which the Studio calls +// before pairing to learn whether this agent build supports it. +type authInterceptor struct { + valid func(token string) bool +} + +func (a authInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + if err := a.check(req.Spec().Procedure, req.Header()); err != nil { + return nil, err + } + return next(ctx, req) + } +} + +func (a authInterceptor) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc { + return next +} + +func (a authInterceptor) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc { + return func(ctx context.Context, conn connect.StreamingHandlerConn) error { + if err := a.check(conn.Spec().Procedure, conn.RequestHeader()); err != nil { + return err + } + return next(ctx, conn) + } +} + +func (a authInterceptor) check(procedure string, header http.Header) error { + if procedure == agentv1connect.AgentServiceGetAgentInfoProcedure { + return nil + } + const scheme = "bearer " + auth := strings.TrimSpace(header.Get("Authorization")) + if len(auth) <= len(scheme) || !strings.EqualFold(auth[:len(scheme)], scheme) { + return connect.NewError(connect.CodeUnauthenticated, errUnauthenticated) + } + if !a.valid(strings.TrimSpace(auth[len(scheme):])) { + return connect.NewError(connect.CodeUnauthenticated, errUnauthenticated) + } + return nil +} + +// recoverRPC reports a handler panic to the caller as Internal and logs it +// with the procedure, instead of dropping the connection. +func recoverRPC(_ context.Context, spec connect.Spec, _ http.Header, p any) error { + log.Error().Str("procedure", spec.Procedure).Interface("panic", p).Msg("v1 handler panicked") + return connect.NewError(connect.CodeInternal, errors.New("internal error")) +} diff --git a/apps/stackpanel-go/internal/agent/server/connect_v1_test.go b/apps/stackpanel-go/internal/agent/server/connect_v1_test.go new file mode 100644 index 00000000..f516067d --- /dev/null +++ b/apps/stackpanel-go/internal/agent/server/connect_v1_test.go @@ -0,0 +1,166 @@ +package server + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "slices" + "strings" + "sync/atomic" + "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" +) + +// newV1TestServer serves the v1 routes with a token validator that accepts +// only "good" and a ProjectService whose methods all return Unimplemented, +// so reaching a handler is distinguishable from being rejected by auth. +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{}) + for _, r := range routes { + mux.Handle(r.path, r.handler) + } + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func TestGetAgentInfoIsPublicAndListsServedProcedures(t *testing.T) { + srv := newV1TestServer(t) + client := agentv1connect.NewAgentServiceClient(srv.Client(), srv.URL) + + res, err := client.GetAgentInfo(context.Background(), connect.NewRequest(&agentv1.GetAgentInfoRequest{})) + if err != nil { + t.Fatalf("GetAgentInfo without a token: %v", err) + } + if got := res.Msg.GetVersion(); got != "1.2.3" { + t.Errorf("version = %q, want 1.2.3", got) + } + for _, want := range []string{ + agentv1connect.AgentServiceGetAgentInfoProcedure, + agentv1connect.ProjectServiceListProjectsProcedure, + agentv1connect.ProjectServiceAddProjectProcedure, + agentv1connect.ProjectServiceRemoveProjectProcedure, + agentv1connect.ProjectServiceSetDefaultProjectProcedure, + } { + if !slices.Contains(res.Msg.GetProcedures(), want) { + t.Errorf("procedures missing %s; got %v", want, res.Msg.GetProcedures()) + } + } +} + +func TestV1ProceduresRequireBearerToken(t *testing.T) { + srv := newV1TestServer(t) + client := agentv1connect.NewProjectServiceClient(srv.Client(), srv.URL) + + cases := []struct { + name string + authorization string + want connect.Code + }{ + {"no header", "", connect.CodeUnauthenticated}, + {"wrong scheme", "Token good", connect.CodeUnauthenticated}, + {"invalid token", "Bearer bad", connect.CodeUnauthenticated}, + // A valid token reaches the (unimplemented) handler. + {"valid token", "Bearer good", connect.CodeUnimplemented}, + {"scheme is case-insensitive", "bearer good", connect.CodeUnimplemented}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := connect.NewRequest(&agentv1.ListProjectsRequest{}) + if tc.authorization != "" { + req.Header().Set("Authorization", tc.authorization) + } + _, err := client.ListProjects(context.Background(), req) + if got := connect.CodeOf(err); got != tc.want { + t.Errorf("code = %v, want %v (err: %v)", got, tc.want, err) + } + }) + } +} + +// recordingProjects is a ProjectService whose AddProject records that it ran, +// so a request rejected before the handler is distinguishable from one that +// reached it. +type recordingProjects struct { + agentv1connect.UnimplementedProjectServiceHandler + called atomic.Bool +} + +func (p *recordingProjects) AddProject( + context.Context, + *connect.Request[agentv1.AddProjectRequest], +) (*connect.Response[agentv1.AddProjectResponse], error) { + p.called.Store(true) + return connect.NewResponse(&agentv1.AddProjectResponse{}), nil +} + +// countingBody counts the request bytes the server reads off the wire. +type countingBody struct { + io.ReadCloser + n *atomic.Int64 +} + +func (b countingBody) Read(p []byte) (int, error) { + n, err := b.ReadCloser.Read(p) + b.n.Add(int64(n)) + return n, err +} + +func TestV1RejectsOversizedRequestsBeforeAuth(t *testing.T) { + var authRan atomic.Bool + var wireBytes atomic.Int64 + projects := &recordingProjects{} + mux := http.NewServeMux() + routes := newV1Routes("1.2.3", 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} + r.handler.ServeHTTP(w, req) + })) + } + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + // Just over the limit, and compressible enough that the gzip case sends a + // few KiB that only inflate past it on the server. + path := strings.Repeat("a", v1ReadMaxBytes+1024) + cases := []struct { + name string + opts []connect.ClientOption + maxWireBytes int64 + }{ + {"uncompressed", nil, 0}, + {"gzip inflates past the limit", []connect.ClientOption{connect.WithSendGzip()}, 64 << 10}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + wireBytes.Store(0) + client := agentv1connect.NewProjectServiceClient(srv.Client(), srv.URL, tc.opts...) + // An invalid token: if auth ran first, the call would fail with + // Unauthenticated instead. + req := connect.NewRequest(&agentv1.AddProjectRequest{Path: path}) + req.Header().Set("Authorization", "Bearer bad") + _, err := client.AddProject(context.Background(), req) + if got := connect.CodeOf(err); got != connect.CodeResourceExhausted { + t.Fatalf("code = %v, want %v (err: %v)", got, connect.CodeResourceExhausted, err) + } + if tc.maxWireBytes > 0 && wireBytes.Load() > tc.maxWireBytes { + t.Errorf("sent %d bytes; the gzip case must fail on the inflated size", wireBytes.Load()) + } + }) + } + if authRan.Load() { + t.Error("token validation ran for an oversized request") + } + if projects.called.Load() { + t.Error("AddProject ran for an oversized request") + } +} diff --git a/apps/stackpanel-go/internal/agent/server/cors_auth.go b/apps/stackpanel-go/internal/agent/server/cors_auth.go index f25695ad..7bd42210 100644 --- a/apps/stackpanel-go/internal/agent/server/cors_auth.go +++ b/apps/stackpanel-go/internal/agent/server/cors_auth.go @@ -102,18 +102,24 @@ func (s *Server) withCORS(next http.HandlerFunc) http.HandlerFunc { // requireAuth enforces both origin allowlist and JWT token validation. // Origin is checked first to fast-fail cross-origin attacks before token parsing. func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - origin := r.Header.Get("Origin") - if origin != "" && !s.isOriginAllowed(origin) { - s.writeAPIError(w, http.StatusForbidden, "origin not allowed") - return - } - + return s.withAllowedOrigin(func(w http.ResponseWriter, r *http.Request) { if !s.hasValidToken(r) { s.writeAPIError(w, http.StatusUnauthorized, "missing or invalid token") return } + next(w, r) + }) +} + +// withAllowedOrigin rejects browser requests from origins outside the +// allowlist. Requests without an Origin header (CLI, scripts) pass through. +func (s *Server) withAllowedOrigin(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if origin := r.Header.Get("Origin"); origin != "" && !s.isOriginAllowed(origin) { + s.writeAPIError(w, http.StatusForbidden, "origin not allowed") + return + } next(w, r) } } diff --git a/apps/stackpanel-go/internal/agent/server/server.go b/apps/stackpanel-go/internal/agent/server/server.go index 8042e410..c3269486 100644 --- a/apps/stackpanel-go/internal/agent/server/server.go +++ b/apps/stackpanel-go/internal/agent/server/server.go @@ -470,6 +470,10 @@ func New(cfg *config.Config) (*Server, error) { path, handler := gopbconnect.NewAgentServiceHandler(agentService) mux.Handle(path, s.withCORS(s.requireAuth(handler.ServeHTTP))) + // v1 Connect services (ADR 0004). They replace the REST endpoints and the + // AgentService above one domain at a time. + s.mountV1(mux) + // SSE endpoint for real-time config updates mux.HandleFunc("/api/events", s.withCORS(s.requireAuth(s.handleSSE))) diff --git a/packages/proto/buf.gen.yaml b/packages/proto/buf.gen.yaml index 23ef6e06..b6111f67 100644 --- a/packages/proto/buf.gen.yaml +++ b/packages/proto/buf.gen.yaml @@ -5,6 +5,10 @@ plugins: # JSON - remote: buf.build/bufbuild/protoschema-jsonschema:v0.5.2 out: gen/json + # Legacy outputs; the v1 agent API (ADR 0004) is generated only by the + # protobuf-es, connect-go and connect-query plugins. + exclude_types: + - stackpanel.agent.v1 # Go - remote: buf.build/protocolbuffers/go out: gen/gopb @@ -35,6 +39,8 @@ plugins: # TypeScript (using protobuf-ts for better ergonomics) - remote: buf.build/community/timostamm-protobuf-ts out: gen/ts + exclude_types: + - stackpanel.agent.v1 opt: - long_type_string - output_typescript diff --git a/packages/proto/gen/gopb/stackpanel/agent/v1/agent.pb.go b/packages/proto/gen/gopb/stackpanel/agent/v1/agent.pb.go new file mode 100644 index 00000000..822e3e74 --- /dev/null +++ b/packages/proto/gen/gopb/stackpanel/agent/v1/agent.pb.go @@ -0,0 +1,185 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc (unknown) +// source: stackpanel/agent/v1/agent.proto + +package agentv1 + +import ( + reflect "reflect" + sync "sync" + unsafe "unsafe" + + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +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 GetAgentInfoRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetAgentInfoRequest) Reset() { + *x = GetAgentInfoRequest{} + mi := &file_stackpanel_agent_v1_agent_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetAgentInfoRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetAgentInfoRequest) ProtoMessage() {} + +func (x *GetAgentInfoRequest) ProtoReflect() protoreflect.Message { + mi := &file_stackpanel_agent_v1_agent_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 GetAgentInfoRequest.ProtoReflect.Descriptor instead. +func (*GetAgentInfoRequest) Descriptor() ([]byte, []int) { + return file_stackpanel_agent_v1_agent_proto_rawDescGZIP(), []int{0} +} + +type GetAgentInfoResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Build version of the agent binary (the value `stack version` prints). + Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` + // Procedures this agent serves, as Connect procedure paths such as + // "/stackpanel.agent.v1.ProjectService/ListProjects". Listing procedures + // rather than services also covers RPCs added to an existing service in a + // newer agent. Clients must treat any procedure not listed here as + // unavailable, and must ignore entries they do not recognize. + Procedures []string `protobuf:"bytes,2,rep,name=procedures,proto3" json:"procedures,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetAgentInfoResponse) Reset() { + *x = GetAgentInfoResponse{} + mi := &file_stackpanel_agent_v1_agent_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetAgentInfoResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetAgentInfoResponse) ProtoMessage() {} + +func (x *GetAgentInfoResponse) ProtoReflect() protoreflect.Message { + mi := &file_stackpanel_agent_v1_agent_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 GetAgentInfoResponse.ProtoReflect.Descriptor instead. +func (*GetAgentInfoResponse) Descriptor() ([]byte, []int) { + return file_stackpanel_agent_v1_agent_proto_rawDescGZIP(), []int{1} +} + +func (x *GetAgentInfoResponse) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *GetAgentInfoResponse) GetProcedures() []string { + if x != nil { + return x.Procedures + } + return nil +} + +var File_stackpanel_agent_v1_agent_proto protoreflect.FileDescriptor + +const file_stackpanel_agent_v1_agent_proto_rawDesc = "" + + "\n" + + "\x1fstackpanel/agent/v1/agent.proto\x12\x13stackpanel.agent.v1\"\x15\n" + + "\x13GetAgentInfoRequest\"P\n" + + "\x14GetAgentInfoResponse\x12\x18\n" + + "\aversion\x18\x01 \x01(\tR\aversion\x12\x1e\n" + + "\n" + + "procedures\x18\x02 \x03(\tR\n" + + "procedures2x\n" + + "\fAgentService\x12h\n" + + "\fGetAgentInfo\x12(.stackpanel.agent.v1.GetAgentInfoRequest\x1a).stackpanel.agent.v1.GetAgentInfoResponse\"\x03\x90\x02\x01BVZTgithub.com/darkmatter/stackpanel/packages/proto/gen/gopb/stackpanel/agent/v1;agentv1b\x06proto3" + +var ( + file_stackpanel_agent_v1_agent_proto_rawDescOnce sync.Once + file_stackpanel_agent_v1_agent_proto_rawDescData []byte +) + +func file_stackpanel_agent_v1_agent_proto_rawDescGZIP() []byte { + file_stackpanel_agent_v1_agent_proto_rawDescOnce.Do(func() { + file_stackpanel_agent_v1_agent_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_stackpanel_agent_v1_agent_proto_rawDesc), len(file_stackpanel_agent_v1_agent_proto_rawDesc))) + }) + return file_stackpanel_agent_v1_agent_proto_rawDescData +} + +var ( + file_stackpanel_agent_v1_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 2) + file_stackpanel_agent_v1_agent_proto_goTypes = []any{ + (*GetAgentInfoRequest)(nil), // 0: stackpanel.agent.v1.GetAgentInfoRequest + (*GetAgentInfoResponse)(nil), // 1: stackpanel.agent.v1.GetAgentInfoResponse + } +) + +var file_stackpanel_agent_v1_agent_proto_depIdxs = []int32{ + 0, // 0: stackpanel.agent.v1.AgentService.GetAgentInfo:input_type -> stackpanel.agent.v1.GetAgentInfoRequest + 1, // 1: stackpanel.agent.v1.AgentService.GetAgentInfo:output_type -> stackpanel.agent.v1.GetAgentInfoResponse + 1, // [1:2] is the sub-list for method output_type + 0, // [0:1] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_stackpanel_agent_v1_agent_proto_init() } +func file_stackpanel_agent_v1_agent_proto_init() { + if File_stackpanel_agent_v1_agent_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_stackpanel_agent_v1_agent_proto_rawDesc), len(file_stackpanel_agent_v1_agent_proto_rawDesc)), + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_stackpanel_agent_v1_agent_proto_goTypes, + DependencyIndexes: file_stackpanel_agent_v1_agent_proto_depIdxs, + MessageInfos: file_stackpanel_agent_v1_agent_proto_msgTypes, + }.Build() + File_stackpanel_agent_v1_agent_proto = out.File + file_stackpanel_agent_v1_agent_proto_goTypes = nil + file_stackpanel_agent_v1_agent_proto_depIdxs = nil +} diff --git a/packages/proto/gen/gopb/stackpanel/agent/v1/agentv1connect/agent.connect.go b/packages/proto/gen/gopb/stackpanel/agent/v1/agentv1connect/agent.connect.go new file mode 100644 index 00000000..72dbc841 --- /dev/null +++ b/packages/proto/gen/gopb/stackpanel/agent/v1/agentv1connect/agent.connect.go @@ -0,0 +1,112 @@ +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: stackpanel/agent/v1/agent.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 ( + // AgentServiceName is the fully-qualified name of the AgentService service. + AgentServiceName = "stackpanel.agent.v1.AgentService" +) + +// 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 ( + // AgentServiceGetAgentInfoProcedure is the fully-qualified name of the AgentService's GetAgentInfo + // RPC. + AgentServiceGetAgentInfoProcedure = "/stackpanel.agent.v1.AgentService/GetAgentInfo" +) + +// AgentServiceClient is a client for the stackpanel.agent.v1.AgentService service. +type AgentServiceClient interface { + GetAgentInfo(context.Context, *connect.Request[v1.GetAgentInfoRequest]) (*connect.Response[v1.GetAgentInfoResponse], error) +} + +// NewAgentServiceClient constructs a client for the stackpanel.agent.v1.AgentService 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 NewAgentServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) AgentServiceClient { + baseURL = strings.TrimRight(baseURL, "/") + agentServiceMethods := v1.File_stackpanel_agent_v1_agent_proto.Services().ByName("AgentService").Methods() + return &agentServiceClient{ + getAgentInfo: connect.NewClient[v1.GetAgentInfoRequest, v1.GetAgentInfoResponse]( + httpClient, + baseURL+AgentServiceGetAgentInfoProcedure, + connect.WithSchema(agentServiceMethods.ByName("GetAgentInfo")), + connect.WithIdempotency(connect.IdempotencyNoSideEffects), + connect.WithClientOptions(opts...), + ), + } +} + +// agentServiceClient implements AgentServiceClient. +type agentServiceClient struct { + getAgentInfo *connect.Client[v1.GetAgentInfoRequest, v1.GetAgentInfoResponse] +} + +// GetAgentInfo calls stackpanel.agent.v1.AgentService.GetAgentInfo. +func (c *agentServiceClient) GetAgentInfo(ctx context.Context, req *connect.Request[v1.GetAgentInfoRequest]) (*connect.Response[v1.GetAgentInfoResponse], error) { + return c.getAgentInfo.CallUnary(ctx, req) +} + +// AgentServiceHandler is an implementation of the stackpanel.agent.v1.AgentService service. +type AgentServiceHandler interface { + GetAgentInfo(context.Context, *connect.Request[v1.GetAgentInfoRequest]) (*connect.Response[v1.GetAgentInfoResponse], error) +} + +// NewAgentServiceHandler 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 NewAgentServiceHandler(svc AgentServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + agentServiceMethods := v1.File_stackpanel_agent_v1_agent_proto.Services().ByName("AgentService").Methods() + agentServiceGetAgentInfoHandler := connect.NewUnaryHandler( + AgentServiceGetAgentInfoProcedure, + svc.GetAgentInfo, + connect.WithSchema(agentServiceMethods.ByName("GetAgentInfo")), + connect.WithIdempotency(connect.IdempotencyNoSideEffects), + connect.WithHandlerOptions(opts...), + ) + return "/stackpanel.agent.v1.AgentService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case AgentServiceGetAgentInfoProcedure: + agentServiceGetAgentInfoHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedAgentServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedAgentServiceHandler struct{} + +func (UnimplementedAgentServiceHandler) GetAgentInfo(context.Context, *connect.Request[v1.GetAgentInfoRequest]) (*connect.Response[v1.GetAgentInfoResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("stackpanel.agent.v1.AgentService.GetAgentInfo is not implemented")) +} 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 new file mode 100644 index 00000000..743912b0 --- /dev/null +++ b/packages/proto/gen/gopb/stackpanel/agent/v1/agentv1connect/project.connect.go @@ -0,0 +1,211 @@ +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: stackpanel/agent/v1/project.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 ( + // ProjectServiceName is the fully-qualified name of the ProjectService service. + ProjectServiceName = "stackpanel.agent.v1.ProjectService" +) + +// 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 ( + // ProjectServiceListProjectsProcedure is the fully-qualified name of the ProjectService's + // ListProjects RPC. + ProjectServiceListProjectsProcedure = "/stackpanel.agent.v1.ProjectService/ListProjects" + // ProjectServiceAddProjectProcedure is the fully-qualified name of the ProjectService's AddProject + // RPC. + ProjectServiceAddProjectProcedure = "/stackpanel.agent.v1.ProjectService/AddProject" + // ProjectServiceRemoveProjectProcedure is the fully-qualified name of the ProjectService's + // RemoveProject RPC. + ProjectServiceRemoveProjectProcedure = "/stackpanel.agent.v1.ProjectService/RemoveProject" + // ProjectServiceSetDefaultProjectProcedure is the fully-qualified name of the ProjectService's + // SetDefaultProject RPC. + ProjectServiceSetDefaultProjectProcedure = "/stackpanel.agent.v1.ProjectService/SetDefaultProject" +) + +// ProjectServiceClient is a client for the stackpanel.agent.v1.ProjectService service. +type ProjectServiceClient interface { + ListProjects(context.Context, *connect.Request[v1.ListProjectsRequest]) (*connect.Response[v1.ListProjectsResponse], error) + // Validates the directory at path and adds it to the registry. Adding a + // path that is already registered returns the existing project. + // Fails with InvalidArgument when path is not a stackpanel project. + 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. + SetDefaultProject(context.Context, *connect.Request[v1.SetDefaultProjectRequest]) (*connect.Response[v1.SetDefaultProjectResponse], error) +} + +// NewProjectServiceClient constructs a client for the stackpanel.agent.v1.ProjectService 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 NewProjectServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) ProjectServiceClient { + baseURL = strings.TrimRight(baseURL, "/") + projectServiceMethods := v1.File_stackpanel_agent_v1_project_proto.Services().ByName("ProjectService").Methods() + return &projectServiceClient{ + listProjects: connect.NewClient[v1.ListProjectsRequest, v1.ListProjectsResponse]( + httpClient, + baseURL+ProjectServiceListProjectsProcedure, + connect.WithSchema(projectServiceMethods.ByName("ListProjects")), + connect.WithIdempotency(connect.IdempotencyNoSideEffects), + connect.WithClientOptions(opts...), + ), + addProject: connect.NewClient[v1.AddProjectRequest, v1.AddProjectResponse]( + httpClient, + baseURL+ProjectServiceAddProjectProcedure, + connect.WithSchema(projectServiceMethods.ByName("AddProject")), + connect.WithClientOptions(opts...), + ), + removeProject: connect.NewClient[v1.RemoveProjectRequest, v1.RemoveProjectResponse]( + httpClient, + baseURL+ProjectServiceRemoveProjectProcedure, + connect.WithSchema(projectServiceMethods.ByName("RemoveProject")), + connect.WithClientOptions(opts...), + ), + setDefaultProject: connect.NewClient[v1.SetDefaultProjectRequest, v1.SetDefaultProjectResponse]( + httpClient, + baseURL+ProjectServiceSetDefaultProjectProcedure, + connect.WithSchema(projectServiceMethods.ByName("SetDefaultProject")), + connect.WithClientOptions(opts...), + ), + } +} + +// projectServiceClient implements ProjectServiceClient. +type projectServiceClient struct { + listProjects *connect.Client[v1.ListProjectsRequest, v1.ListProjectsResponse] + addProject *connect.Client[v1.AddProjectRequest, v1.AddProjectResponse] + removeProject *connect.Client[v1.RemoveProjectRequest, v1.RemoveProjectResponse] + setDefaultProject *connect.Client[v1.SetDefaultProjectRequest, v1.SetDefaultProjectResponse] +} + +// ListProjects calls stackpanel.agent.v1.ProjectService.ListProjects. +func (c *projectServiceClient) ListProjects(ctx context.Context, req *connect.Request[v1.ListProjectsRequest]) (*connect.Response[v1.ListProjectsResponse], error) { + return c.listProjects.CallUnary(ctx, req) +} + +// AddProject calls stackpanel.agent.v1.ProjectService.AddProject. +func (c *projectServiceClient) AddProject(ctx context.Context, req *connect.Request[v1.AddProjectRequest]) (*connect.Response[v1.AddProjectResponse], error) { + return c.addProject.CallUnary(ctx, req) +} + +// RemoveProject calls stackpanel.agent.v1.ProjectService.RemoveProject. +func (c *projectServiceClient) RemoveProject(ctx context.Context, req *connect.Request[v1.RemoveProjectRequest]) (*connect.Response[v1.RemoveProjectResponse], error) { + return c.removeProject.CallUnary(ctx, req) +} + +// SetDefaultProject calls stackpanel.agent.v1.ProjectService.SetDefaultProject. +func (c *projectServiceClient) SetDefaultProject(ctx context.Context, req *connect.Request[v1.SetDefaultProjectRequest]) (*connect.Response[v1.SetDefaultProjectResponse], error) { + return c.setDefaultProject.CallUnary(ctx, req) +} + +// ProjectServiceHandler is an implementation of the stackpanel.agent.v1.ProjectService service. +type ProjectServiceHandler interface { + ListProjects(context.Context, *connect.Request[v1.ListProjectsRequest]) (*connect.Response[v1.ListProjectsResponse], error) + // Validates the directory at path and adds it to the registry. Adding a + // path that is already registered returns the existing project. + // Fails with InvalidArgument when path is not a stackpanel project. + 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. + SetDefaultProject(context.Context, *connect.Request[v1.SetDefaultProjectRequest]) (*connect.Response[v1.SetDefaultProjectResponse], error) +} + +// NewProjectServiceHandler 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 NewProjectServiceHandler(svc ProjectServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + projectServiceMethods := v1.File_stackpanel_agent_v1_project_proto.Services().ByName("ProjectService").Methods() + projectServiceListProjectsHandler := connect.NewUnaryHandler( + ProjectServiceListProjectsProcedure, + svc.ListProjects, + connect.WithSchema(projectServiceMethods.ByName("ListProjects")), + connect.WithIdempotency(connect.IdempotencyNoSideEffects), + connect.WithHandlerOptions(opts...), + ) + projectServiceAddProjectHandler := connect.NewUnaryHandler( + ProjectServiceAddProjectProcedure, + svc.AddProject, + connect.WithSchema(projectServiceMethods.ByName("AddProject")), + connect.WithHandlerOptions(opts...), + ) + projectServiceRemoveProjectHandler := connect.NewUnaryHandler( + ProjectServiceRemoveProjectProcedure, + svc.RemoveProject, + connect.WithSchema(projectServiceMethods.ByName("RemoveProject")), + connect.WithHandlerOptions(opts...), + ) + projectServiceSetDefaultProjectHandler := connect.NewUnaryHandler( + ProjectServiceSetDefaultProjectProcedure, + svc.SetDefaultProject, + connect.WithSchema(projectServiceMethods.ByName("SetDefaultProject")), + connect.WithHandlerOptions(opts...), + ) + return "/stackpanel.agent.v1.ProjectService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case ProjectServiceListProjectsProcedure: + projectServiceListProjectsHandler.ServeHTTP(w, r) + case ProjectServiceAddProjectProcedure: + projectServiceAddProjectHandler.ServeHTTP(w, r) + case ProjectServiceRemoveProjectProcedure: + projectServiceRemoveProjectHandler.ServeHTTP(w, r) + case ProjectServiceSetDefaultProjectProcedure: + projectServiceSetDefaultProjectHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedProjectServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedProjectServiceHandler struct{} + +func (UnimplementedProjectServiceHandler) ListProjects(context.Context, *connect.Request[v1.ListProjectsRequest]) (*connect.Response[v1.ListProjectsResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("stackpanel.agent.v1.ProjectService.ListProjects is not implemented")) +} + +func (UnimplementedProjectServiceHandler) AddProject(context.Context, *connect.Request[v1.AddProjectRequest]) (*connect.Response[v1.AddProjectResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("stackpanel.agent.v1.ProjectService.AddProject is not implemented")) +} + +func (UnimplementedProjectServiceHandler) RemoveProject(context.Context, *connect.Request[v1.RemoveProjectRequest]) (*connect.Response[v1.RemoveProjectResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("stackpanel.agent.v1.ProjectService.RemoveProject is not implemented")) +} + +func (UnimplementedProjectServiceHandler) SetDefaultProject(context.Context, *connect.Request[v1.SetDefaultProjectRequest]) (*connect.Response[v1.SetDefaultProjectResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("stackpanel.agent.v1.ProjectService.SetDefaultProject 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 new file mode 100644 index 00000000..1af9f4ed --- /dev/null +++ b/packages/proto/gen/gopb/stackpanel/agent/v1/project.pb.go @@ -0,0 +1,560 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc (unknown) +// source: stackpanel/agent/v1/project.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 Project struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Stable identifier derived from the project path; the value clients send + // in the 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"` + // Absolute path to the project root (the directory containing .stack/). + Path string `protobuf:"bytes,3,opt,name=path,proto3" json:"path,omitempty"` + LastOpenedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=last_opened_at,json=lastOpenedAt,proto3" json:"last_opened_at,omitempty"` + // False when the directory no longer exists or is no longer a valid + // stackpanel project; invalid_reason then says why. + Valid bool `protobuf:"varint,5,opt,name=valid,proto3" json:"valid,omitempty"` + InvalidReason string `protobuf:"bytes,6,opt,name=invalid_reason,json=invalidReason,proto3" json:"invalid_reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Project) Reset() { + *x = Project{} + mi := &file_stackpanel_agent_v1_project_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Project) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Project) ProtoMessage() {} + +func (x *Project) ProtoReflect() protoreflect.Message { + mi := &file_stackpanel_agent_v1_project_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 Project.ProtoReflect.Descriptor instead. +func (*Project) Descriptor() ([]byte, []int) { + return file_stackpanel_agent_v1_project_proto_rawDescGZIP(), []int{0} +} + +func (x *Project) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Project) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Project) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *Project) GetLastOpenedAt() *timestamppb.Timestamp { + if x != nil { + return x.LastOpenedAt + } + return nil +} + +func (x *Project) GetValid() bool { + if x != nil { + return x.Valid + } + return false +} + +func (x *Project) GetInvalidReason() string { + if x != nil { + return x.InvalidReason + } + return "" +} + +type ListProjectsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListProjectsRequest) Reset() { + *x = ListProjectsRequest{} + mi := &file_stackpanel_agent_v1_project_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListProjectsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListProjectsRequest) ProtoMessage() {} + +func (x *ListProjectsRequest) ProtoReflect() protoreflect.Message { + mi := &file_stackpanel_agent_v1_project_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 ListProjectsRequest.ProtoReflect.Descriptor instead. +func (*ListProjectsRequest) Descriptor() ([]byte, []int) { + return file_stackpanel_agent_v1_project_proto_rawDescGZIP(), []int{1} +} + +type ListProjectsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Most recently opened first. + Projects []*Project `protobuf:"bytes,1,rep,name=projects,proto3" json:"projects,omitempty"` + // Empty when no default project is set. + DefaultProjectId string `protobuf:"bytes,2,opt,name=default_project_id,json=defaultProjectId,proto3" json:"default_project_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListProjectsResponse) Reset() { + *x = ListProjectsResponse{} + mi := &file_stackpanel_agent_v1_project_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListProjectsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListProjectsResponse) ProtoMessage() {} + +func (x *ListProjectsResponse) ProtoReflect() protoreflect.Message { + mi := &file_stackpanel_agent_v1_project_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 ListProjectsResponse.ProtoReflect.Descriptor instead. +func (*ListProjectsResponse) Descriptor() ([]byte, []int) { + return file_stackpanel_agent_v1_project_proto_rawDescGZIP(), []int{2} +} + +func (x *ListProjectsResponse) GetProjects() []*Project { + if x != nil { + return x.Projects + } + return nil +} + +func (x *ListProjectsResponse) GetDefaultProjectId() string { + if x != nil { + return x.DefaultProjectId + } + return "" +} + +type AddProjectRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Absolute path to the project root. + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddProjectRequest) Reset() { + *x = AddProjectRequest{} + mi := &file_stackpanel_agent_v1_project_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddProjectRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddProjectRequest) ProtoMessage() {} + +func (x *AddProjectRequest) ProtoReflect() protoreflect.Message { + mi := &file_stackpanel_agent_v1_project_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 AddProjectRequest.ProtoReflect.Descriptor instead. +func (*AddProjectRequest) Descriptor() ([]byte, []int) { + return file_stackpanel_agent_v1_project_proto_rawDescGZIP(), []int{3} +} + +func (x *AddProjectRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type AddProjectResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Project *Project `protobuf:"bytes,1,opt,name=project,proto3" json:"project,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddProjectResponse) Reset() { + *x = AddProjectResponse{} + mi := &file_stackpanel_agent_v1_project_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddProjectResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddProjectResponse) ProtoMessage() {} + +func (x *AddProjectResponse) ProtoReflect() protoreflect.Message { + mi := &file_stackpanel_agent_v1_project_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 AddProjectResponse.ProtoReflect.Descriptor instead. +func (*AddProjectResponse) Descriptor() ([]byte, []int) { + return file_stackpanel_agent_v1_project_proto_rawDescGZIP(), []int{4} +} + +func (x *AddProjectResponse) GetProject() *Project { + if x != nil { + return x.Project + } + return nil +} + +type RemoveProjectRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ProjectId string `protobuf:"bytes,1,opt,name=project_id,json=projectId,proto3" json:"project_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveProjectRequest) Reset() { + *x = RemoveProjectRequest{} + mi := &file_stackpanel_agent_v1_project_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveProjectRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveProjectRequest) ProtoMessage() {} + +func (x *RemoveProjectRequest) ProtoReflect() protoreflect.Message { + mi := &file_stackpanel_agent_v1_project_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 RemoveProjectRequest.ProtoReflect.Descriptor instead. +func (*RemoveProjectRequest) Descriptor() ([]byte, []int) { + return file_stackpanel_agent_v1_project_proto_rawDescGZIP(), []int{5} +} + +func (x *RemoveProjectRequest) GetProjectId() string { + if x != nil { + return x.ProjectId + } + return "" +} + +type RemoveProjectResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveProjectResponse) Reset() { + *x = RemoveProjectResponse{} + mi := &file_stackpanel_agent_v1_project_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveProjectResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveProjectResponse) ProtoMessage() {} + +func (x *RemoveProjectResponse) ProtoReflect() protoreflect.Message { + mi := &file_stackpanel_agent_v1_project_proto_msgTypes[6] + 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 RemoveProjectResponse.ProtoReflect.Descriptor instead. +func (*RemoveProjectResponse) Descriptor() ([]byte, []int) { + return file_stackpanel_agent_v1_project_proto_rawDescGZIP(), []int{6} +} + +type SetDefaultProjectRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Empty clears the default. + ProjectId string `protobuf:"bytes,1,opt,name=project_id,json=projectId,proto3" json:"project_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetDefaultProjectRequest) Reset() { + *x = SetDefaultProjectRequest{} + mi := &file_stackpanel_agent_v1_project_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetDefaultProjectRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetDefaultProjectRequest) ProtoMessage() {} + +func (x *SetDefaultProjectRequest) ProtoReflect() protoreflect.Message { + mi := &file_stackpanel_agent_v1_project_proto_msgTypes[7] + 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 SetDefaultProjectRequest.ProtoReflect.Descriptor instead. +func (*SetDefaultProjectRequest) Descriptor() ([]byte, []int) { + return file_stackpanel_agent_v1_project_proto_rawDescGZIP(), []int{7} +} + +func (x *SetDefaultProjectRequest) GetProjectId() string { + if x != nil { + return x.ProjectId + } + return "" +} + +type SetDefaultProjectResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetDefaultProjectResponse) Reset() { + *x = SetDefaultProjectResponse{} + mi := &file_stackpanel_agent_v1_project_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetDefaultProjectResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetDefaultProjectResponse) ProtoMessage() {} + +func (x *SetDefaultProjectResponse) ProtoReflect() protoreflect.Message { + mi := &file_stackpanel_agent_v1_project_proto_msgTypes[8] + 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 SetDefaultProjectResponse.ProtoReflect.Descriptor instead. +func (*SetDefaultProjectResponse) Descriptor() ([]byte, []int) { + return file_stackpanel_agent_v1_project_proto_rawDescGZIP(), []int{8} +} + +var File_stackpanel_agent_v1_project_proto protoreflect.FileDescriptor + +const file_stackpanel_agent_v1_project_proto_rawDesc = "" + + "\n" + + "!stackpanel/agent/v1/project.proto\x12\x13stackpanel.agent.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\xc0\x01\n" + + "\aProject\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12\x12\n" + + "\x04path\x18\x03 \x01(\tR\x04path\x12@\n" + + "\x0elast_opened_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\flastOpenedAt\x12\x14\n" + + "\x05valid\x18\x05 \x01(\bR\x05valid\x12%\n" + + "\x0einvalid_reason\x18\x06 \x01(\tR\rinvalidReason\"\x15\n" + + "\x13ListProjectsRequest\"~\n" + + "\x14ListProjectsResponse\x128\n" + + "\bprojects\x18\x01 \x03(\v2\x1c.stackpanel.agent.v1.ProjectR\bprojects\x12,\n" + + "\x12default_project_id\x18\x02 \x01(\tR\x10defaultProjectId\"'\n" + + "\x11AddProjectRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\"L\n" + + "\x12AddProjectResponse\x126\n" + + "\aproject\x18\x01 \x01(\v2\x1c.stackpanel.agent.v1.ProjectR\aproject\"5\n" + + "\x14RemoveProjectRequest\x12\x1d\n" + + "\n" + + "project_id\x18\x01 \x01(\tR\tprojectId\"\x17\n" + + "\x15RemoveProjectResponse\"9\n" + + "\x18SetDefaultProjectRequest\x12\x1d\n" + + "\n" + + "project_id\x18\x01 \x01(\tR\tprojectId\"\x1b\n" + + "\x19SetDefaultProjectResponse2\xb5\x03\n" + + "\x0eProjectService\x12h\n" + + "\fListProjects\x12(.stackpanel.agent.v1.ListProjectsRequest\x1a).stackpanel.agent.v1.ListProjectsResponse\"\x03\x90\x02\x01\x12]\n" + + "\n" + + "AddProject\x12&.stackpanel.agent.v1.AddProjectRequest\x1a'.stackpanel.agent.v1.AddProjectResponse\x12f\n" + + "\rRemoveProject\x12).stackpanel.agent.v1.RemoveProjectRequest\x1a*.stackpanel.agent.v1.RemoveProjectResponse\x12r\n" + + "\x11SetDefaultProject\x12-.stackpanel.agent.v1.SetDefaultProjectRequest\x1a..stackpanel.agent.v1.SetDefaultProjectResponseBVZTgithub.com/darkmatter/stackpanel/packages/proto/gen/gopb/stackpanel/agent/v1;agentv1b\x06proto3" + +var ( + file_stackpanel_agent_v1_project_proto_rawDescOnce sync.Once + file_stackpanel_agent_v1_project_proto_rawDescData []byte +) + +func file_stackpanel_agent_v1_project_proto_rawDescGZIP() []byte { + file_stackpanel_agent_v1_project_proto_rawDescOnce.Do(func() { + file_stackpanel_agent_v1_project_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_stackpanel_agent_v1_project_proto_rawDesc), len(file_stackpanel_agent_v1_project_proto_rawDesc))) + }) + return file_stackpanel_agent_v1_project_proto_rawDescData +} + +var ( + file_stackpanel_agent_v1_project_proto_msgTypes = make([]protoimpl.MessageInfo, 9) + file_stackpanel_agent_v1_project_proto_goTypes = []any{ + (*Project)(nil), // 0: stackpanel.agent.v1.Project + (*ListProjectsRequest)(nil), // 1: stackpanel.agent.v1.ListProjectsRequest + (*ListProjectsResponse)(nil), // 2: stackpanel.agent.v1.ListProjectsResponse + (*AddProjectRequest)(nil), // 3: stackpanel.agent.v1.AddProjectRequest + (*AddProjectResponse)(nil), // 4: stackpanel.agent.v1.AddProjectResponse + (*RemoveProjectRequest)(nil), // 5: stackpanel.agent.v1.RemoveProjectRequest + (*RemoveProjectResponse)(nil), // 6: stackpanel.agent.v1.RemoveProjectResponse + (*SetDefaultProjectRequest)(nil), // 7: stackpanel.agent.v1.SetDefaultProjectRequest + (*SetDefaultProjectResponse)(nil), // 8: stackpanel.agent.v1.SetDefaultProjectResponse + (*timestamppb.Timestamp)(nil), // 9: google.protobuf.Timestamp + } +) + +var file_stackpanel_agent_v1_project_proto_depIdxs = []int32{ + 9, // 0: stackpanel.agent.v1.Project.last_opened_at:type_name -> google.protobuf.Timestamp + 0, // 1: stackpanel.agent.v1.ListProjectsResponse.projects:type_name -> stackpanel.agent.v1.Project + 0, // 2: stackpanel.agent.v1.AddProjectResponse.project:type_name -> stackpanel.agent.v1.Project + 1, // 3: stackpanel.agent.v1.ProjectService.ListProjects:input_type -> stackpanel.agent.v1.ListProjectsRequest + 3, // 4: stackpanel.agent.v1.ProjectService.AddProject:input_type -> stackpanel.agent.v1.AddProjectRequest + 5, // 5: stackpanel.agent.v1.ProjectService.RemoveProject:input_type -> stackpanel.agent.v1.RemoveProjectRequest + 7, // 6: stackpanel.agent.v1.ProjectService.SetDefaultProject:input_type -> stackpanel.agent.v1.SetDefaultProjectRequest + 2, // 7: stackpanel.agent.v1.ProjectService.ListProjects:output_type -> stackpanel.agent.v1.ListProjectsResponse + 4, // 8: stackpanel.agent.v1.ProjectService.AddProject:output_type -> stackpanel.agent.v1.AddProjectResponse + 6, // 9: stackpanel.agent.v1.ProjectService.RemoveProject:output_type -> stackpanel.agent.v1.RemoveProjectResponse + 8, // 10: stackpanel.agent.v1.ProjectService.SetDefaultProject:output_type -> stackpanel.agent.v1.SetDefaultProjectResponse + 7, // [7:11] is the sub-list for method output_type + 3, // [3:7] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name +} + +func init() { file_stackpanel_agent_v1_project_proto_init() } +func file_stackpanel_agent_v1_project_proto_init() { + if File_stackpanel_agent_v1_project_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_stackpanel_agent_v1_project_proto_rawDesc), len(file_stackpanel_agent_v1_project_proto_rawDesc)), + NumEnums: 0, + NumMessages: 9, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_stackpanel_agent_v1_project_proto_goTypes, + DependencyIndexes: file_stackpanel_agent_v1_project_proto_depIdxs, + MessageInfos: file_stackpanel_agent_v1_project_proto_msgTypes, + }.Build() + File_stackpanel_agent_v1_project_proto = out.File + file_stackpanel_agent_v1_project_proto_goTypes = nil + file_stackpanel_agent_v1_project_proto_depIdxs = nil +} diff --git a/packages/proto/gen/ts/stackpanel/agent/v1/agent-AgentService_connectquery.d.ts b/packages/proto/gen/ts/stackpanel/agent/v1/agent-AgentService_connectquery.d.ts new file mode 100644 index 00000000..17a16a74 --- /dev/null +++ b/packages/proto/gen/ts/stackpanel/agent/v1/agent-AgentService_connectquery.d.ts @@ -0,0 +1,10 @@ +// @generated by protoc-gen-connect-query v2.1.1 +// @generated from file stackpanel/agent/v1/agent.proto (package stackpanel.agent.v1, syntax proto3) +/* eslint-disable */ + +import { AgentService } from "./agent_pb"; + +/** + * @generated from rpc stackpanel.agent.v1.AgentService.GetAgentInfo + */ +export const getAgentInfo: typeof AgentService["method"]["getAgentInfo"]; diff --git a/packages/proto/gen/ts/stackpanel/agent/v1/agent-AgentService_connectquery.js b/packages/proto/gen/ts/stackpanel/agent/v1/agent-AgentService_connectquery.js new file mode 100644 index 00000000..73bcef3c --- /dev/null +++ b/packages/proto/gen/ts/stackpanel/agent/v1/agent-AgentService_connectquery.js @@ -0,0 +1,10 @@ +// @generated by protoc-gen-connect-query v2.1.1 +// @generated from file stackpanel/agent/v1/agent.proto (package stackpanel.agent.v1, syntax proto3) +/* eslint-disable */ + +import { AgentService } from "./agent_pb"; + +/** + * @generated from rpc stackpanel.agent.v1.AgentService.GetAgentInfo + */ +export const getAgentInfo = AgentService.method.getAgentInfo; diff --git a/packages/proto/gen/ts/stackpanel/agent/v1/agent_pb.d.ts b/packages/proto/gen/ts/stackpanel/agent/v1/agent_pb.d.ts new file mode 100644 index 00000000..f46b6eff --- /dev/null +++ b/packages/proto/gen/ts/stackpanel/agent/v1/agent_pb.d.ts @@ -0,0 +1,75 @@ +// @generated by protoc-gen-es v2.7.0 +// @generated from file stackpanel/agent/v1/agent.proto (package stackpanel.agent.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file stackpanel/agent/v1/agent.proto. + */ +export declare const file_stackpanel_agent_v1_agent: GenFile; + +/** + * @generated from message stackpanel.agent.v1.GetAgentInfoRequest + */ +export declare type GetAgentInfoRequest = Message<"stackpanel.agent.v1.GetAgentInfoRequest"> & { +}; + +/** + * Describes the message stackpanel.agent.v1.GetAgentInfoRequest. + * Use `create(GetAgentInfoRequestSchema)` to create a new message. + */ +export declare const GetAgentInfoRequestSchema: GenMessage; + +/** + * @generated from message stackpanel.agent.v1.GetAgentInfoResponse + */ +export declare type GetAgentInfoResponse = Message<"stackpanel.agent.v1.GetAgentInfoResponse"> & { + /** + * Build version of the agent binary (the value `stack version` prints). + * + * @generated from field: string version = 1; + */ + version: string; + + /** + * Procedures this agent serves, as Connect procedure paths such as + * "/stackpanel.agent.v1.ProjectService/ListProjects". Listing procedures + * rather than services also covers RPCs added to an existing service in a + * newer agent. Clients must treat any procedure not listed here as + * unavailable, and must ignore entries they do not recognize. + * + * @generated from field: repeated string procedures = 2; + */ + procedures: string[]; +}; + +/** + * Describes the message stackpanel.agent.v1.GetAgentInfoResponse. + * Use `create(GetAgentInfoResponseSchema)` to create a new message. + */ +export declare const GetAgentInfoResponseSchema: GenMessage; + +/** + * AgentService describes the running agent itself. + * + * The Studio is deployed continuously while each user's agent is pinned by + * their flake, so every Studio build talks to agents of many versions. + * Clients call GetAgentInfo when they connect and decide which features to + * offer from the reported capabilities, instead of failing on Unimplemented + * (ADR 0004). + * + * @generated from service stackpanel.agent.v1.AgentService + */ +export declare const AgentService: GenService<{ + /** + * @generated from rpc stackpanel.agent.v1.AgentService.GetAgentInfo + */ + getAgentInfo: { + methodKind: "unary"; + input: typeof GetAgentInfoRequestSchema; + output: typeof GetAgentInfoResponseSchema; + }, +}>; + diff --git a/packages/proto/gen/ts/stackpanel/agent/v1/agent_pb.js b/packages/proto/gen/ts/stackpanel/agent/v1/agent_pb.js new file mode 100644 index 00000000..74c81820 --- /dev/null +++ b/packages/proto/gen/ts/stackpanel/agent/v1/agent_pb.js @@ -0,0 +1,40 @@ +// @generated by protoc-gen-es v2.7.0 +// @generated from file stackpanel/agent/v1/agent.proto (package stackpanel.agent.v1, syntax proto3) +/* eslint-disable */ + +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; + +/** + * Describes the file stackpanel/agent/v1/agent.proto. + */ +export const file_stackpanel_agent_v1_agent = /*@__PURE__*/ + fileDesc("Ch9zdGFja3BhbmVsL2FnZW50L3YxL2FnZW50LnByb3RvEhNzdGFja3BhbmVsLmFnZW50LnYxIhUKE0dldEFnZW50SW5mb1JlcXVlc3QiOwoUR2V0QWdlbnRJbmZvUmVzcG9uc2USDwoHdmVyc2lvbhgBIAEoCRISCgpwcm9jZWR1cmVzGAIgAygJMngKDEFnZW50U2VydmljZRJoCgxHZXRBZ2VudEluZm8SKC5zdGFja3BhbmVsLmFnZW50LnYxLkdldEFnZW50SW5mb1JlcXVlc3QaKS5zdGFja3BhbmVsLmFnZW50LnYxLkdldEFnZW50SW5mb1Jlc3BvbnNlIgOQAgFCVlpUZ2l0aHViLmNvbS9kYXJrbWF0dGVyL3N0YWNrcGFuZWwvcGFja2FnZXMvcHJvdG8vZ2VuL2dvcGIvc3RhY2twYW5lbC9hZ2VudC92MTthZ2VudHYxYgZwcm90bzM"); + +/** + * Describes the message stackpanel.agent.v1.GetAgentInfoRequest. + * Use `create(GetAgentInfoRequestSchema)` to create a new message. + */ +export const GetAgentInfoRequestSchema = /*@__PURE__*/ + messageDesc(file_stackpanel_agent_v1_agent, 0); + +/** + * Describes the message stackpanel.agent.v1.GetAgentInfoResponse. + * Use `create(GetAgentInfoResponseSchema)` to create a new message. + */ +export const GetAgentInfoResponseSchema = /*@__PURE__*/ + messageDesc(file_stackpanel_agent_v1_agent, 1); + +/** + * AgentService describes the running agent itself. + * + * The Studio is deployed continuously while each user's agent is pinned by + * their flake, so every Studio build talks to agents of many versions. + * Clients call GetAgentInfo when they connect and decide which features to + * offer from the reported capabilities, instead of failing on Unimplemented + * (ADR 0004). + * + * @generated from service stackpanel.agent.v1.AgentService + */ +export const AgentService = /*@__PURE__*/ + serviceDesc(file_stackpanel_agent_v1_agent, 0); + 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 new file mode 100644 index 00000000..c2d14a20 --- /dev/null +++ b/packages/proto/gen/ts/stackpanel/agent/v1/project-ProjectService_connectquery.d.ts @@ -0,0 +1,31 @@ +// @generated by protoc-gen-connect-query v2.1.1 +// @generated from file stackpanel/agent/v1/project.proto (package stackpanel.agent.v1, syntax proto3) +/* eslint-disable */ + +import { ProjectService } from "./project_pb"; + +/** + * @generated from rpc stackpanel.agent.v1.ProjectService.ListProjects + */ +export const listProjects: typeof ProjectService["method"]["listProjects"]; +/** + * Validates the directory at path and adds it to the registry. Adding a + * path that is already registered returns the existing project. + * Fails with InvalidArgument when path is not a stackpanel project. + * + * @generated from rpc stackpanel.agent.v1.ProjectService.AddProject + */ +export const addProject: typeof ProjectService["method"]["addProject"]; +/** + * Removes a project from the registry. The directory is left untouched. + * + * @generated from rpc stackpanel.agent.v1.ProjectService.RemoveProject + */ +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. + * + * @generated from rpc stackpanel.agent.v1.ProjectService.SetDefaultProject + */ +export const setDefaultProject: typeof ProjectService["method"]["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 new file mode 100644 index 00000000..d6016c2f --- /dev/null +++ b/packages/proto/gen/ts/stackpanel/agent/v1/project-ProjectService_connectquery.js @@ -0,0 +1,34 @@ +// @generated by protoc-gen-connect-query v2.1.1 +// @generated from file stackpanel/agent/v1/project.proto (package stackpanel.agent.v1, syntax proto3) +/* eslint-disable */ + +import { ProjectService } from "./project_pb"; + +/** + * @generated from rpc stackpanel.agent.v1.ProjectService.ListProjects + */ +export const listProjects = ProjectService.method.listProjects; + +/** + * Validates the directory at path and adds it to the registry. Adding a + * path that is already registered returns the existing project. + * Fails with InvalidArgument when path is not a stackpanel project. + * + * @generated from rpc stackpanel.agent.v1.ProjectService.AddProject + */ +export const addProject = ProjectService.method.addProject; + +/** + * Removes a project from the registry. The directory is left untouched. + * + * @generated from rpc stackpanel.agent.v1.ProjectService.RemoveProject + */ +export const removeProject = ProjectService.method.removeProject; + +/** + * Sets the project used for requests that carry no Stackpanel-Project + * header, such as CLI and TUI calls. + * + * @generated from rpc stackpanel.agent.v1.ProjectService.SetDefaultProject + */ +export const setDefaultProject = ProjectService.method.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 new file mode 100644 index 00000000..3eff3e3f --- /dev/null +++ b/packages/proto/gen/ts/stackpanel/agent/v1/project_pb.d.ts @@ -0,0 +1,248 @@ +// @generated by protoc-gen-es v2.7.0 +// @generated from file stackpanel/agent/v1/project.proto (package stackpanel.agent.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import type { Message } from "@bufbuild/protobuf"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; + +/** + * Describes the file stackpanel/agent/v1/project.proto. + */ +export declare const file_stackpanel_agent_v1_project: GenFile; + +/** + * @generated from message stackpanel.agent.v1.Project + */ +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. + * + * @generated from field: string id = 1; + */ + id: string; + + /** + * Display name. + * + * @generated from field: string name = 2; + */ + name: string; + + /** + * Absolute path to the project root (the directory containing .stack/). + * + * @generated from field: string path = 3; + */ + path: string; + + /** + * @generated from field: google.protobuf.Timestamp last_opened_at = 4; + */ + lastOpenedAt?: Timestamp; + + /** + * False when the directory no longer exists or is no longer a valid + * stackpanel project; invalid_reason then says why. + * + * @generated from field: bool valid = 5; + */ + valid: boolean; + + /** + * @generated from field: string invalid_reason = 6; + */ + invalidReason: string; +}; + +/** + * Describes the message stackpanel.agent.v1.Project. + * Use `create(ProjectSchema)` to create a new message. + */ +export declare const ProjectSchema: GenMessage; + +/** + * @generated from message stackpanel.agent.v1.ListProjectsRequest + */ +export declare type ListProjectsRequest = Message<"stackpanel.agent.v1.ListProjectsRequest"> & { +}; + +/** + * Describes the message stackpanel.agent.v1.ListProjectsRequest. + * Use `create(ListProjectsRequestSchema)` to create a new message. + */ +export declare const ListProjectsRequestSchema: GenMessage; + +/** + * @generated from message stackpanel.agent.v1.ListProjectsResponse + */ +export declare type ListProjectsResponse = Message<"stackpanel.agent.v1.ListProjectsResponse"> & { + /** + * Most recently opened first. + * + * @generated from field: repeated stackpanel.agent.v1.Project projects = 1; + */ + projects: Project[]; + + /** + * Empty when no default project is set. + * + * @generated from field: string default_project_id = 2; + */ + defaultProjectId: string; +}; + +/** + * Describes the message stackpanel.agent.v1.ListProjectsResponse. + * Use `create(ListProjectsResponseSchema)` to create a new message. + */ +export declare const ListProjectsResponseSchema: GenMessage; + +/** + * @generated from message stackpanel.agent.v1.AddProjectRequest + */ +export declare type AddProjectRequest = Message<"stackpanel.agent.v1.AddProjectRequest"> & { + /** + * Absolute path to the project root. + * + * @generated from field: string path = 1; + */ + path: string; +}; + +/** + * Describes the message stackpanel.agent.v1.AddProjectRequest. + * Use `create(AddProjectRequestSchema)` to create a new message. + */ +export declare const AddProjectRequestSchema: GenMessage; + +/** + * @generated from message stackpanel.agent.v1.AddProjectResponse + */ +export declare type AddProjectResponse = Message<"stackpanel.agent.v1.AddProjectResponse"> & { + /** + * @generated from field: stackpanel.agent.v1.Project project = 1; + */ + project?: Project; +}; + +/** + * Describes the message stackpanel.agent.v1.AddProjectResponse. + * Use `create(AddProjectResponseSchema)` to create a new message. + */ +export declare const AddProjectResponseSchema: GenMessage; + +/** + * @generated from message stackpanel.agent.v1.RemoveProjectRequest + */ +export declare type RemoveProjectRequest = Message<"stackpanel.agent.v1.RemoveProjectRequest"> & { + /** + * @generated from field: string project_id = 1; + */ + projectId: string; +}; + +/** + * Describes the message stackpanel.agent.v1.RemoveProjectRequest. + * Use `create(RemoveProjectRequestSchema)` to create a new message. + */ +export declare const RemoveProjectRequestSchema: GenMessage; + +/** + * @generated from message stackpanel.agent.v1.RemoveProjectResponse + */ +export declare type RemoveProjectResponse = Message<"stackpanel.agent.v1.RemoveProjectResponse"> & { +}; + +/** + * Describes the message stackpanel.agent.v1.RemoveProjectResponse. + * Use `create(RemoveProjectResponseSchema)` to create a new message. + */ +export declare const RemoveProjectResponseSchema: GenMessage; + +/** + * @generated from message stackpanel.agent.v1.SetDefaultProjectRequest + */ +export declare type SetDefaultProjectRequest = Message<"stackpanel.agent.v1.SetDefaultProjectRequest"> & { + /** + * Empty clears the default. + * + * @generated from field: string project_id = 1; + */ + projectId: string; +}; + +/** + * Describes the message stackpanel.agent.v1.SetDefaultProjectRequest. + * Use `create(SetDefaultProjectRequestSchema)` to create a new message. + */ +export declare const SetDefaultProjectRequestSchema: GenMessage; + +/** + * @generated from message stackpanel.agent.v1.SetDefaultProjectResponse + */ +export declare type SetDefaultProjectResponse = Message<"stackpanel.agent.v1.SetDefaultProjectResponse"> & { +}; + +/** + * Describes the message stackpanel.agent.v1.SetDefaultProjectResponse. + * Use `create(SetDefaultProjectResponseSchema)` to create a new message. + */ +export declare const SetDefaultProjectResponseSchema: GenMessage; + +/** + * ProjectService manages the agent's registry of known projects, stored in + * ~/.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. + * + * @generated from service stackpanel.agent.v1.ProjectService + */ +export declare const ProjectService: GenService<{ + /** + * @generated from rpc stackpanel.agent.v1.ProjectService.ListProjects + */ + listProjects: { + methodKind: "unary"; + input: typeof ListProjectsRequestSchema; + output: typeof ListProjectsResponseSchema; + }, + /** + * Validates the directory at path and adds it to the registry. Adding a + * path that is already registered returns the existing project. + * Fails with InvalidArgument when path is not a stackpanel project. + * + * @generated from rpc stackpanel.agent.v1.ProjectService.AddProject + */ + addProject: { + methodKind: "unary"; + input: typeof AddProjectRequestSchema; + output: typeof AddProjectResponseSchema; + }, + /** + * Removes a project from the registry. The directory is left untouched. + * + * @generated from rpc stackpanel.agent.v1.ProjectService.RemoveProject + */ + removeProject: { + methodKind: "unary"; + input: typeof RemoveProjectRequestSchema; + output: typeof RemoveProjectResponseSchema; + }, + /** + * Sets the project used for requests that carry no Stackpanel-Project + * header, such as CLI and TUI calls. + * + * @generated from rpc stackpanel.agent.v1.ProjectService.SetDefaultProject + */ + setDefaultProject: { + methodKind: "unary"; + input: typeof SetDefaultProjectRequestSchema; + output: typeof SetDefaultProjectResponseSchema; + }, +}>; + diff --git a/packages/proto/gen/ts/stackpanel/agent/v1/project_pb.js b/packages/proto/gen/ts/stackpanel/agent/v1/project_pb.js new file mode 100644 index 00000000..7b3b4bb8 --- /dev/null +++ b/packages/proto/gen/ts/stackpanel/agent/v1/project_pb.js @@ -0,0 +1,90 @@ +// @generated by protoc-gen-es v2.7.0 +// @generated from file stackpanel/agent/v1/project.proto (package stackpanel.agent.v1, syntax proto3) +/* eslint-disable */ + +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; + +/** + * Describes the file stackpanel/agent/v1/project.proto. + */ +export const file_stackpanel_agent_v1_project = /*@__PURE__*/ + fileDesc("CiFzdGFja3BhbmVsL2FnZW50L3YxL3Byb2plY3QucHJvdG8SE3N0YWNrcGFuZWwuYWdlbnQudjEijAEKB1Byb2plY3QSCgoCaWQYASABKAkSDAoEbmFtZRgCIAEoCRIMCgRwYXRoGAMgASgJEjIKDmxhc3Rfb3BlbmVkX2F0GAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBINCgV2YWxpZBgFIAEoCBIWCg5pbnZhbGlkX3JlYXNvbhgGIAEoCSIVChNMaXN0UHJvamVjdHNSZXF1ZXN0ImIKFExpc3RQcm9qZWN0c1Jlc3BvbnNlEi4KCHByb2plY3RzGAEgAygLMhwuc3RhY2twYW5lbC5hZ2VudC52MS5Qcm9qZWN0EhoKEmRlZmF1bHRfcHJvamVjdF9pZBgCIAEoCSIhChFBZGRQcm9qZWN0UmVxdWVzdBIMCgRwYXRoGAEgASgJIkMKEkFkZFByb2plY3RSZXNwb25zZRItCgdwcm9qZWN0GAEgASgLMhwuc3RhY2twYW5lbC5hZ2VudC52MS5Qcm9qZWN0IioKFFJlbW92ZVByb2plY3RSZXF1ZXN0EhIKCnByb2plY3RfaWQYASABKAkiFwoVUmVtb3ZlUHJvamVjdFJlc3BvbnNlIi4KGFNldERlZmF1bHRQcm9qZWN0UmVxdWVzdBISCgpwcm9qZWN0X2lkGAEgASgJIhsKGVNldERlZmF1bHRQcm9qZWN0UmVzcG9uc2UytQMKDlByb2plY3RTZXJ2aWNlEmgKDExpc3RQcm9qZWN0cxIoLnN0YWNrcGFuZWwuYWdlbnQudjEuTGlzdFByb2plY3RzUmVxdWVzdBopLnN0YWNrcGFuZWwuYWdlbnQudjEuTGlzdFByb2plY3RzUmVzcG9uc2UiA5ACARJdCgpBZGRQcm9qZWN0EiYuc3RhY2twYW5lbC5hZ2VudC52MS5BZGRQcm9qZWN0UmVxdWVzdBonLnN0YWNrcGFuZWwuYWdlbnQudjEuQWRkUHJvamVjdFJlc3BvbnNlEmYKDVJlbW92ZVByb2plY3QSKS5zdGFja3BhbmVsLmFnZW50LnYxLlJlbW92ZVByb2plY3RSZXF1ZXN0Giouc3RhY2twYW5lbC5hZ2VudC52MS5SZW1vdmVQcm9qZWN0UmVzcG9uc2UScgoRU2V0RGVmYXVsdFByb2plY3QSLS5zdGFja3BhbmVsLmFnZW50LnYxLlNldERlZmF1bHRQcm9qZWN0UmVxdWVzdBouLnN0YWNrcGFuZWwuYWdlbnQudjEuU2V0RGVmYXVsdFByb2plY3RSZXNwb25zZUJWWlRnaXRodWIuY29tL2RhcmttYXR0ZXIvc3RhY2twYW5lbC9wYWNrYWdlcy9wcm90by9nZW4vZ29wYi9zdGFja3BhbmVsL2FnZW50L3YxO2FnZW50djFiBnByb3RvMw", [file_google_protobuf_timestamp]); + +/** + * Describes the message stackpanel.agent.v1.Project. + * Use `create(ProjectSchema)` to create a new message. + */ +export const ProjectSchema = /*@__PURE__*/ + messageDesc(file_stackpanel_agent_v1_project, 0); + +/** + * Describes the message stackpanel.agent.v1.ListProjectsRequest. + * Use `create(ListProjectsRequestSchema)` to create a new message. + */ +export const ListProjectsRequestSchema = /*@__PURE__*/ + messageDesc(file_stackpanel_agent_v1_project, 1); + +/** + * Describes the message stackpanel.agent.v1.ListProjectsResponse. + * Use `create(ListProjectsResponseSchema)` to create a new message. + */ +export const ListProjectsResponseSchema = /*@__PURE__*/ + messageDesc(file_stackpanel_agent_v1_project, 2); + +/** + * Describes the message stackpanel.agent.v1.AddProjectRequest. + * Use `create(AddProjectRequestSchema)` to create a new message. + */ +export const AddProjectRequestSchema = /*@__PURE__*/ + messageDesc(file_stackpanel_agent_v1_project, 3); + +/** + * Describes the message stackpanel.agent.v1.AddProjectResponse. + * Use `create(AddProjectResponseSchema)` to create a new message. + */ +export const AddProjectResponseSchema = /*@__PURE__*/ + messageDesc(file_stackpanel_agent_v1_project, 4); + +/** + * Describes the message stackpanel.agent.v1.RemoveProjectRequest. + * Use `create(RemoveProjectRequestSchema)` to create a new message. + */ +export const RemoveProjectRequestSchema = /*@__PURE__*/ + messageDesc(file_stackpanel_agent_v1_project, 5); + +/** + * Describes the message stackpanel.agent.v1.RemoveProjectResponse. + * Use `create(RemoveProjectResponseSchema)` to create a new message. + */ +export const RemoveProjectResponseSchema = /*@__PURE__*/ + messageDesc(file_stackpanel_agent_v1_project, 6); + +/** + * Describes the message stackpanel.agent.v1.SetDefaultProjectRequest. + * Use `create(SetDefaultProjectRequestSchema)` to create a new message. + */ +export const SetDefaultProjectRequestSchema = /*@__PURE__*/ + messageDesc(file_stackpanel_agent_v1_project, 7); + +/** + * Describes the message stackpanel.agent.v1.SetDefaultProjectResponse. + * Use `create(SetDefaultProjectResponseSchema)` to create a new message. + */ +export const SetDefaultProjectResponseSchema = /*@__PURE__*/ + messageDesc(file_stackpanel_agent_v1_project, 8); + +/** + * ProjectService manages the agent's registry of known projects, stored in + * ~/.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. + * + * @generated from service stackpanel.agent.v1.ProjectService + */ +export const ProjectService = /*@__PURE__*/ + serviceDesc(file_stackpanel_agent_v1_project, 0); + diff --git a/packages/proto/proto/stackpanel/agent/v1/agent.proto b/packages/proto/proto/stackpanel/agent/v1/agent.proto new file mode 100644 index 00000000..1667dfc8 --- /dev/null +++ b/packages/proto/proto/stackpanel/agent/v1/agent.proto @@ -0,0 +1,32 @@ +syntax = "proto3"; + +package stackpanel.agent.v1; + +option go_package = "github.com/darkmatter/stackpanel/packages/proto/gen/gopb/stackpanel/agent/v1;agentv1"; + +// AgentService describes the running agent itself. +// +// The Studio is deployed continuously while each user's agent is pinned by +// their flake, so every Studio build talks to agents of many versions. +// Clients call GetAgentInfo when they connect and decide which features to +// offer from the reported capabilities, instead of failing on Unimplemented +// (ADR 0004). +service AgentService { + rpc GetAgentInfo(GetAgentInfoRequest) returns (GetAgentInfoResponse) { + option idempotency_level = NO_SIDE_EFFECTS; + } +} + +message GetAgentInfoRequest {} + +message GetAgentInfoResponse { + // Build version of the agent binary (the value `stack version` prints). + string version = 1; + + // Procedures this agent serves, as Connect procedure paths such as + // "/stackpanel.agent.v1.ProjectService/ListProjects". Listing procedures + // rather than services also covers RPCs added to an existing service in a + // newer agent. Clients must treat any procedure not listed here as + // unavailable, and must ignore entries they do not recognize. + repeated string procedures = 2; +} diff --git a/packages/proto/proto/stackpanel/agent/v1/project.proto b/packages/proto/proto/stackpanel/agent/v1/project.proto new file mode 100644 index 00000000..cdf01083 --- /dev/null +++ b/packages/proto/proto/stackpanel/agent/v1/project.proto @@ -0,0 +1,83 @@ +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"; + +// ProjectService manages the agent's registry of known projects, stored in +// ~/.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 ProjectService { + rpc ListProjects(ListProjectsRequest) returns (ListProjectsResponse) { + option idempotency_level = NO_SIDE_EFFECTS; + } + + // Validates the directory at path and adds it to the registry. Adding a + // path that is already registered returns the existing project. + // Fails with InvalidArgument when path is not a stackpanel project. + rpc AddProject(AddProjectRequest) returns (AddProjectResponse); + + // 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. + rpc SetDefaultProject(SetDefaultProjectRequest) returns (SetDefaultProjectResponse); +} + +message Project { + // Stable identifier derived from the project path; the value clients send + // in the Stackpanel-Project header. + string id = 1; + + // Display name. + string name = 2; + + // Absolute path to the project root (the directory containing .stack/). + string path = 3; + + google.protobuf.Timestamp last_opened_at = 4; + + // False when the directory no longer exists or is no longer a valid + // stackpanel project; invalid_reason then says why. + bool valid = 5; + string invalid_reason = 6; +} + +message ListProjectsRequest {} + +message ListProjectsResponse { + // Most recently opened first. + repeated Project projects = 1; + + // Empty when no default project is set. + string default_project_id = 2; +} + +message AddProjectRequest { + // Absolute path to the project root. + string path = 1; +} + +message AddProjectResponse { + Project project = 1; +} + +message RemoveProjectRequest { + string project_id = 1; +} + +message RemoveProjectResponse {} + +message SetDefaultProjectRequest { + // Empty clears the default. + string project_id = 1; +} + +message SetDefaultProjectResponse {}