Skip to content

Commit 360567e

Browse files
committed
feat(task): emit typed error envelopes across the task domain
Task commands now return structured, typed errors instead of the legacy exit-code envelope: every failure carries a stable category, subtype, and recovery hint, so callers can branch on the error class instead of parsing messages. Exit codes are now derived from the error category, aligning task with the rest of the CLI — input validation exits 2, a permission denial exits 3, and other API errors exit 1. Batch operations keep their current per-item reporting and exit behavior; typed partial-failure signaling is deferred.
1 parent 0aa9e96 commit 360567e

27 files changed

Lines changed: 462 additions & 241 deletions

.golangci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ linters:
6565
- forbidigo
6666
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
6767
# Add a path when its migration is complete.
68-
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go)
68+
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/task/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go)
6969
text: errs-typed-only
7070
linters:
7171
- forbidigo

shortcuts/task/shortcuts.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import (
1313
"strings"
1414
"time"
1515

16-
"github.com/larksuite/cli/internal/output"
16+
"github.com/larksuite/cli/errs"
1717
"github.com/larksuite/cli/shortcuts/common"
1818
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
1919
)
@@ -107,7 +107,7 @@ func buildTaskCreateBody(runtime *common.RuntimeContext) (map[string]interface{}
107107
// Handle generic JSON payload if provided
108108
if dataStr := runtime.Str("data"); dataStr != "" {
109109
if err := json.Unmarshal([]byte(dataStr), &body); err != nil {
110-
return nil, output.ErrValidation("--data must be a valid JSON object: %v", err)
110+
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--data must be a valid JSON object: %v", err).WithParam("--data")
111111
}
112112
}
113113

@@ -143,7 +143,7 @@ func buildTaskCreateBody(runtime *common.RuntimeContext) (map[string]interface{}
143143
if dueStr := runtime.Str("due"); dueStr != "" {
144144
dueObj, err := parseTaskTime(dueStr)
145145
if err != nil {
146-
return nil, output.ErrValidation("failed to parse due time: %v", err)
146+
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "failed to parse due time: %v", err).WithParam("--due")
147147
}
148148
body["due"] = dueObj
149149
}
@@ -154,7 +154,7 @@ func buildTaskCreateBody(runtime *common.RuntimeContext) (map[string]interface{}
154154

155155
summary, _ := body["summary"].(string)
156156
if strings.TrimSpace(summary) == "" {
157-
return nil, output.ErrValidation("task summary is required")
157+
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "task summary is required").WithParam("--summary")
158158
}
159159

160160
return body, nil
@@ -194,7 +194,7 @@ var CreateTask = common.Shortcut{
194194
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
195195
body, err := buildTaskCreateBody(runtime)
196196
if err != nil {
197-
return WrapTaskError(ErrCodeTaskInvalidParams, err.Error(), "create task")
197+
return err
198198
}
199199

200200
queryParams := make(larkcore.QueryParams)
@@ -210,7 +210,7 @@ var CreateTask = common.Shortcut{
210210
var result map[string]interface{}
211211
if err == nil {
212212
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
213-
return output.Errorf(output.ExitAPI, "api_error", "failed to parse response: %v", parseErr)
213+
return errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to parse response: %v", parseErr)
214214
}
215215
}
216216

shortcuts/task/task_assign.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414

1515
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
1616

17+
"github.com/larksuite/cli/errs"
1718
"github.com/larksuite/cli/shortcuts/common"
1819
)
1920

@@ -35,7 +36,7 @@ var AssignTask = common.Shortcut{
3536

3637
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
3738
if runtime.Str("add") == "" && runtime.Str("remove") == "" {
38-
return WrapTaskError(ErrCodeTaskInvalidParams, "must specify either --add or --remove", "validate assign")
39+
return errs.NewValidationError(errs.SubtypeInvalidArgument, "must specify either --add or --remove")
3940
}
4041
return nil
4142
},
@@ -79,7 +80,7 @@ var AssignTask = common.Shortcut{
7980
var result map[string]interface{}
8081
if err == nil {
8182
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
82-
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse add members")
83+
return errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to parse response: %v", parseErr)
8384
}
8485
}
8586

@@ -102,7 +103,7 @@ var AssignTask = common.Shortcut{
102103
var result map[string]interface{}
103104
if err == nil {
104105
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
105-
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse remove members")
106+
return errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to parse response: %v", parseErr)
106107
}
107108
}
108109

shortcuts/task/task_body_test.go

Lines changed: 34 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ package task
55

66
import (
77
"errors"
8+
"strings"
89
"testing"
910

11+
"github.com/larksuite/cli/errs"
1012
"github.com/larksuite/cli/internal/output"
1113
"github.com/spf13/cobra"
1214

@@ -19,33 +21,25 @@ func TestBuildTaskCreateBody_StructuredErrors(t *testing.T) {
1921
data string
2022
summary string
2123
due string
22-
wantCode int
23-
wantType string
2424
wantSubstr string
2525
}{
2626
{
27-
name: "invalid JSON data returns ErrValidation",
27+
name: "invalid JSON data returns validation error",
2828
data: "not-json",
2929
summary: "test",
30-
wantCode: output.ExitValidation,
31-
wantType: "validation",
3230
wantSubstr: "--data must be a valid JSON object",
3331
},
3432
{
35-
name: "missing summary returns ErrValidation",
33+
name: "missing summary returns validation error",
3634
data: "",
3735
summary: "",
38-
wantCode: output.ExitValidation,
39-
wantType: "validation",
4036
wantSubstr: "task summary is required",
4137
},
4238
{
43-
name: "invalid due time returns ErrValidation",
39+
name: "invalid due time returns validation error",
4440
data: "",
4541
summary: "test task",
4642
due: "not-a-valid-time",
47-
wantCode: output.ExitValidation,
48-
wantType: "validation",
4943
wantSubstr: "failed to parse due time",
5044
},
5145
}
@@ -68,18 +62,22 @@ func TestBuildTaskCreateBody_StructuredErrors(t *testing.T) {
6862
t.Fatal("expected error, got nil")
6963
}
7064

71-
var exitErr *output.ExitError
72-
if !errors.As(err, &exitErr) {
73-
t.Fatalf("error type = %T, want *output.ExitError; error = %v", err, err)
65+
var ve *errs.ValidationError
66+
if !errors.As(err, &ve) {
67+
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
7468
}
75-
if exitErr.Code != tt.wantCode {
76-
t.Errorf("exit code = %d, want %d", exitErr.Code, tt.wantCode)
69+
p, ok := errs.ProblemOf(err)
70+
if !ok {
71+
t.Fatalf("ProblemOf(%T) returned !ok", err)
7772
}
78-
if exitErr.Detail == nil {
79-
t.Fatal("expected non-nil error detail")
73+
if p.Subtype != errs.SubtypeInvalidArgument {
74+
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
8075
}
81-
if exitErr.Detail.Type != tt.wantType {
82-
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, tt.wantType)
76+
if got := output.ExitCodeOf(err); got != output.ExitValidation {
77+
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
78+
}
79+
if !strings.Contains(err.Error(), tt.wantSubstr) {
80+
t.Errorf("message = %q, want substring %q", err.Error(), tt.wantSubstr)
8381
}
8482
})
8583
}
@@ -91,35 +89,27 @@ func TestBuildTaskUpdateBody_StructuredErrors(t *testing.T) {
9189
data string
9290
summary string
9391
due string
94-
wantCode int
95-
wantType string
9692
wantSubstr string
9793
}{
9894
{
99-
name: "invalid JSON data returns ErrValidation",
95+
name: "invalid JSON data returns validation error",
10096
data: "not-json",
10197
summary: "",
10298
due: "",
103-
wantCode: output.ExitValidation,
104-
wantType: "validation",
10599
wantSubstr: "--data must be a valid JSON object",
106100
},
107101
{
108-
name: "no fields to update returns ErrValidation",
102+
name: "no fields to update returns validation error",
109103
data: "",
110104
summary: "",
111105
due: "",
112-
wantCode: output.ExitValidation,
113-
wantType: "validation",
114106
wantSubstr: "no fields to update",
115107
},
116108
{
117-
name: "invalid due time returns ErrValidation",
109+
name: "invalid due time returns validation error",
118110
data: "",
119111
summary: "",
120112
due: "not-a-valid-time",
121-
wantCode: output.ExitValidation,
122-
wantType: "validation",
123113
wantSubstr: "failed to parse due time",
124114
},
125115
}
@@ -138,18 +128,22 @@ func TestBuildTaskUpdateBody_StructuredErrors(t *testing.T) {
138128
t.Fatal("expected error, got nil")
139129
}
140130

141-
var exitErr *output.ExitError
142-
if !errors.As(err, &exitErr) {
143-
t.Fatalf("error type = %T, want *output.ExitError; error = %v", err, err)
131+
var ve *errs.ValidationError
132+
if !errors.As(err, &ve) {
133+
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
134+
}
135+
p, ok := errs.ProblemOf(err)
136+
if !ok {
137+
t.Fatalf("ProblemOf(%T) returned !ok", err)
144138
}
145-
if exitErr.Code != tt.wantCode {
146-
t.Errorf("exit code = %d, want %d", exitErr.Code, tt.wantCode)
139+
if p.Subtype != errs.SubtypeInvalidArgument {
140+
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
147141
}
148-
if exitErr.Detail == nil {
149-
t.Fatal("expected non-nil error detail")
142+
if got := output.ExitCodeOf(err); got != output.ExitValidation {
143+
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
150144
}
151-
if exitErr.Detail.Type != tt.wantType {
152-
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, tt.wantType)
145+
if !strings.Contains(err.Error(), tt.wantSubstr) {
146+
t.Errorf("message = %q, want substring %q", err.Error(), tt.wantSubstr)
153147
}
154148
})
155149
}

shortcuts/task/task_comment.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
1414

15+
"github.com/larksuite/cli/errs"
1516
"github.com/larksuite/cli/shortcuts/common"
1617
)
1718

@@ -61,7 +62,7 @@ var CommentTask = common.Shortcut{
6162
var result map[string]interface{}
6263
if err == nil {
6364
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
64-
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse comment response")
65+
return errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to parse response: %v", parseErr)
6566
}
6667
}
6768

shortcuts/task/task_complete.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414

1515
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
1616

17+
"github.com/larksuite/cli/errs"
1718
"github.com/larksuite/cli/shortcuts/common"
1819
)
1920

@@ -62,7 +63,7 @@ var CompleteTask = common.Shortcut{
6263
var getResult map[string]interface{}
6364
if getErr == nil {
6465
if parseErr := json.Unmarshal(getResp.RawBody, &getResult); parseErr != nil {
65-
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse get response: %v", parseErr), "parse get response")
66+
return errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to parse get response: %v", parseErr)
6667
}
6768
}
6869

@@ -90,7 +91,7 @@ var CompleteTask = common.Shortcut{
9091
var result map[string]interface{}
9192
if err == nil {
9293
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
93-
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse complete response")
94+
return errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to parse response: %v", parseErr)
9495
}
9596
}
9697

shortcuts/task/task_errors.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package task
5+
6+
import (
7+
"errors"
8+
9+
"github.com/larksuite/cli/errs"
10+
"github.com/larksuite/cli/extension/fileio"
11+
)
12+
13+
// wrapTaskNetworkErr returns err unchanged when it is already a typed errs.*
14+
// error (preserving its subtype / code / log_id from the runtime boundary),
15+
// and only wraps a raw, unclassified error as a transport-level network error.
16+
func wrapTaskNetworkErr(err error, format string, args ...any) error {
17+
if _, ok := errs.ProblemOf(err); ok {
18+
return err
19+
}
20+
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
21+
}
22+
23+
// taskInputStatError maps a FileIO.Stat/Open error for input file validation
24+
// to a typed validation error:
25+
// - Path validation failures → "unsafe file path: ..."
26+
// - Other errors → readMsg prefix (default "cannot read file")
27+
//
28+
// Pass an optional readMsg to override the non-path-validation message prefix,
29+
// mirroring common.WrapInputStatError so call-site context is preserved.
30+
func taskInputStatError(err error, readMsg ...string) error {
31+
if err == nil {
32+
return nil
33+
}
34+
if errors.Is(err, fileio.ErrPathValidation) {
35+
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).WithCause(err)
36+
}
37+
msg := "cannot read file"
38+
if len(readMsg) > 0 && readMsg[0] != "" {
39+
msg = readMsg[0]
40+
}
41+
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: %s", msg, err).WithCause(err)
42+
}

shortcuts/task/task_followers.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414

1515
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
1616

17+
"github.com/larksuite/cli/errs"
1718
"github.com/larksuite/cli/shortcuts/common"
1819
)
1920

@@ -35,7 +36,7 @@ var FollowersTask = common.Shortcut{
3536

3637
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
3738
if runtime.Str("add") == "" && runtime.Str("remove") == "" {
38-
return WrapTaskError(ErrCodeTaskInvalidParams, "must specify either --add or --remove", "validate followers")
39+
return errs.NewValidationError(errs.SubtypeInvalidArgument, "must specify either --add or --remove")
3940
}
4041
return nil
4142
},
@@ -80,7 +81,7 @@ var FollowersTask = common.Shortcut{
8081
var result map[string]interface{}
8182
if err == nil {
8283
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
83-
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse add followers")
84+
return errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to parse response: %v", parseErr)
8485
}
8586
}
8687

@@ -103,7 +104,7 @@ var FollowersTask = common.Shortcut{
103104
var result map[string]interface{}
104105
if err == nil {
105106
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
106-
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse remove followers")
107+
return errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to parse response: %v", parseErr)
107108
}
108109
}
109110

0 commit comments

Comments
 (0)