diff --git a/pkg/api/provider.go b/pkg/api/provider.go index b2a92fa..4d96b2e 100644 --- a/pkg/api/provider.go +++ b/pkg/api/provider.go @@ -98,6 +98,7 @@ func (p *ProcessProvider) RegisterRoutes(rg *gin.RouterGroup) { rg.GET("/processes", p.listProcesses) rg.GET("/processes/:id", p.getProcess) rg.GET("/processes/:id/output", p.getProcessOutput) + rg.POST("/processes/:id/wait", p.waitProcess) rg.POST("/processes/:id/input", p.inputProcess) rg.POST("/processes/:id/close-stdin", p.closeProcessStdin) rg.POST("/processes/:id/kill", p.killProcess) @@ -231,6 +232,28 @@ func (p *ProcessProvider) Describe() []api.RouteDescription { "type": "string", }, }, + { + Method: "POST", + Path: "/processes/:id/wait", + Summary: "Wait for a managed process", + Description: "Blocks until the process exits and returns the final process snapshot. Non-zero exits include the snapshot in the error details payload.", + Tags: []string{"process"}, + Response: map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + "command": map[string]any{"type": "string"}, + "args": map[string]any{"type": "array"}, + "dir": map[string]any{"type": "string"}, + "startedAt": map[string]any{"type": "string", "format": "date-time"}, + "running": map[string]any{"type": "boolean"}, + "status": map[string]any{"type": "string"}, + "exitCode": map[string]any{"type": "integer"}, + "duration": map[string]any{"type": "integer"}, + "pid": map[string]any{"type": "integer"}, + }, + }, + }, { Method: "POST", Path: "/processes/:id/input", @@ -456,6 +479,28 @@ func (p *ProcessProvider) getProcessOutput(c *gin.Context) { c.JSON(http.StatusOK, api.OK(output)) } +func (p *ProcessProvider) waitProcess(c *gin.Context) { + if p.service == nil { + c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured")) + return + } + + info, err := p.service.Wait(c.Param("id")) + if err != nil { + status := http.StatusInternalServerError + switch { + case err == process.ErrProcessNotFound: + status = http.StatusNotFound + case info.Status == process.StatusExited || info.Status == process.StatusKilled: + status = http.StatusConflict + } + c.JSON(status, api.FailWithDetails("wait_failed", err.Error(), info)) + return + } + + c.JSON(http.StatusOK, api.OK(info)) +} + type processInputRequest struct { Input string `json:"input"` } diff --git a/pkg/api/provider_test.go b/pkg/api/provider_test.go index d5a1541..b8e9920 100644 --- a/pkg/api/provider_test.go +++ b/pkg/api/provider_test.go @@ -320,6 +320,60 @@ func TestProcessProvider_GetProcessOutput_Good(t *testing.T) { assert.Contains(t, resp.Data, "output-check") } +func TestProcessProvider_WaitProcess_Good(t *testing.T) { + svc := newTestProcessService(t) + proc, err := svc.Start(context.Background(), "echo", "wait-check") + require.NoError(t, err) + + p := processapi.NewProvider(nil, svc, nil) + r := setupRouter(p) + w := httptest.NewRecorder() + + req, err := http.NewRequest("POST", "/api/process/processes/"+proc.ID+"/wait", nil) + require.NoError(t, err) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp goapi.Response[process.Info] + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.True(t, resp.Success) + assert.Equal(t, proc.ID, resp.Data.ID) + assert.Equal(t, process.StatusExited, resp.Data.Status) + assert.Equal(t, 0, resp.Data.ExitCode) +} + +func TestProcessProvider_WaitProcess_NonZeroExit_Good(t *testing.T) { + svc := newTestProcessService(t) + proc, err := svc.Start(context.Background(), "sh", "-c", "exit 7") + require.NoError(t, err) + + p := processapi.NewProvider(nil, svc, nil) + r := setupRouter(p) + w := httptest.NewRecorder() + + req, err := http.NewRequest("POST", "/api/process/processes/"+proc.ID+"/wait", nil) + require.NoError(t, err) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusConflict, w.Code) + + var resp goapi.Response[any] + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.False(t, resp.Success) + require.NotNil(t, resp.Error) + assert.Equal(t, "wait_failed", resp.Error.Code) + assert.Contains(t, resp.Error.Message, "process exited with code 7") + + details, ok := resp.Error.Details.(map[string]any) + require.True(t, ok) + assert.Equal(t, "exited", details["status"]) + assert.Equal(t, float64(7), details["exitCode"]) + assert.Equal(t, proc.ID, details["id"]) +} + func TestProcessProvider_InputAndCloseStdin_Good(t *testing.T) { svc := newTestProcessService(t) proc, err := svc.Start(context.Background(), "cat") @@ -484,6 +538,7 @@ func TestProcessProvider_ProcessRoutes_Unavailable(t *testing.T) { "/api/process/processes", "/api/process/processes/anything", "/api/process/processes/anything/output", + "/api/process/processes/anything/wait", "/api/process/processes/anything/input", "/api/process/processes/anything/close-stdin", "/api/process/processes/anything/kill", @@ -494,6 +549,7 @@ func TestProcessProvider_ProcessRoutes_Unavailable(t *testing.T) { method := "GET" switch { case strings.HasSuffix(path, "/kill"), + strings.HasSuffix(path, "/wait"), strings.HasSuffix(path, "/input"), strings.HasSuffix(path, "/close-stdin"): method = "POST"