Skip to content

Commit 05c7bc1

Browse files
authored
fix(api): distinct error code for templated-dispatch refusal (#196) (#238)
DispatchAction's templated-params gate previously returned the generic ErrValidationFailed code. The web client maps errors by structured ErrorDetail.code (not message text) to localized UI strings, so the shared 'validation_failed' code couldn't be distinguished from a hundred other validation paths. Adds ErrTemplatedDispatchRefused = "templated_dispatch_refused" and swaps it into the gate. The CodeFailedPrecondition + human message stay the same; only the structured code changes. Test extended to assert the structured code via connect.Error.Details \xe2\x80\x94 a future refactor that drops the structured detail will break the test instead of silently breaking the web error mapping. Refs #196 (web error mapping prep).
1 parent ee455b1 commit 05c7bc1

3 files changed

Lines changed: 27 additions & 1 deletion

File tree

internal/api/action_dispatch.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ func (h *ActionHandler) DispatchAction(ctx context.Context, req *connect.Request
170170
fmt.Sprintf("failed to marshal action params for template scan: %v", err))
171171
}
172172
if template.HasReference(string(paramsJSONForScan)) {
173-
return nil, apiErrorCtx(ctx, ErrValidationFailed, connect.CodeFailedPrecondition,
173+
return nil, apiErrorCtx(ctx, ErrTemplatedDispatchRefused, connect.CodeFailedPrecondition,
174174
"this action contains templated parameters ({{ var.NAME }}) and cannot be dispatched ad-hoc to a single device. "+
175175
"Variables are resolved from device-group / user-group memberships at agent sync time. "+
176176
"Assign the action to a device-group or user-group instead of running it directly.")

internal/api/dispatch_validation_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package api_test
22

33
import (
4+
"errors"
45
"log/slog"
56
"testing"
67

@@ -213,6 +214,22 @@ func TestDispatchAction_TemplatedParamsRefused(t *testing.T) {
213214
"templated params on ad-hoc dispatch MUST surface FailedPrecondition — anything else lets literal {{ var.X }} reach the agent verbatim")
214215
assert.Contains(t, err.Error(), "templated parameters",
215216
"error message must explain that variables are group-only and the action should be assigned to a group instead")
217+
218+
// The web client reads the structured ErrorDetail code (not the
219+
// human-readable message) to map the failure to a localized
220+
// "assign to a group instead" UI message. Pin the code here so a
221+
// future refactor that drops the structured detail breaks the test
222+
// instead of silently breaking the web error mapping.
223+
var connectErr *connect.Error
224+
require.True(t, errors.As(err, &connectErr))
225+
details := connectErr.Details()
226+
require.NotEmpty(t, details, "templated-dispatch refusal MUST carry the structured ErrorDetail — the web client maps by code, not by message")
227+
val, vErr := details[0].Value()
228+
require.NoError(t, vErr)
229+
detail, ok := val.(*pm.ErrorDetail)
230+
require.True(t, ok, "first detail must be ErrorDetail; got %T", val)
231+
assert.Equal(t, api.ErrTemplatedDispatchRefused, detail.Code,
232+
"error code MUST be templated_dispatch_refused — the web client switches on this exact string")
216233
}
217234

218235
// TestDispatchInstantAction_PreconditionNoTaskQueue pins the

internal/api/errors.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,15 @@ const (
109109
ErrValidationFailed = "validation_failed"
110110
ErrInvalidPageToken = "invalid_page_token"
111111
ErrInvalidQuery = "invalid_query"
112+
113+
// ErrTemplatedDispatchRefused is returned when an operator tries to
114+
// dispatch an action whose params contain `{{ var.NAME }}` references
115+
// directly to a single device (DispatchAction / DispatchToMultiple /
116+
// DispatchActionSet / etc.). Variables resolve only via device-group
117+
// or user-group memberships at agent SyncActions time; the ad-hoc
118+
// dispatch path has no group context. Web maps this code to a
119+
// "assign to a group instead" UI message. See #196.
120+
ErrTemplatedDispatchRefused = "templated_dispatch_refused"
112121
)
113122

114123
// Internal error code (generic).

0 commit comments

Comments
 (0)