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