Skip to content

Commit 656ad2d

Browse files
committed
feat(event): emit typed error envelopes across the event domain
Replace every command-facing error path in the event domain — the consume/schema command layer, the +subscribe shortcut, EventKey definitions, and the consume orchestration — with typed errs.* envelopes, so consumers get stable type, subtype, param, hint, and missing_scopes metadata for classification and recovery instead of free-form message text. - Input validation (--jq, --param, --output-dir, --filter, --route, unknown EventKey, EventKey params) reports validation / invalid_argument with the offending flag in param and an actionable hint. - Scope preflight reports authorization / missing_scope with the machine-readable missing_scopes list; console-subscription and single-bus preconditions report failed_precondition with recovery hints. - The consume API boundary passes already-typed errors through and classifies transport, non-JSON HTTP, and unparsable responses; the vc note-detail retry now matches the not-found code on typed errors (it silently never fired against the legacy envelope shape). - Previously-bare failures exited 1 with a plain-text "Error:" line and now exit with their category code (validation 2, auth 3, network 4, internal 5) alongside the typed stderr envelope. - forbidigo and errscontract guards now cover the event paths so regressions fail lint; AGENTS.md and the lark-event skill document the typed contract for agent consumers. Validation: make unit-test (race) green; event unit and e2e suites assert category/subtype/param/hint and cause preservation against the real binary; errscontract and golangci lint clean.
1 parent 03ea6e7 commit 656ad2d

39 files changed

Lines changed: 1202 additions & 119 deletions

.golangci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,20 +73,20 @@ linters:
7373
- forbidigo
7474
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
7575
# Add a path when its migration is complete.
76-
- 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/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
76+
- 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/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
7777
text: errs-typed-only
7878
linters:
7979
- forbidigo
8080
# errs-no-bare-wrap enforced on paths fully migrated to typed final
8181
# errors. Scoped separately from errs-typed-only because cmd/auth/,
8282
# cmd/config/ still have residual fmt.Errorf and must not be caught.
83-
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go)
83+
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
8484
text: errs-no-bare-wrap
8585
linters:
8686
- forbidigo
8787
# errs-no-legacy-helper enforced on domains whose shared validation/save
8888
# helpers have migrated to typed final errors.
89-
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
89+
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|cmd/event/|events/|shortcuts/event/)
9090
text: errs-no-legacy-helper
9191
linters:
9292
- forbidigo

AGENTS.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,31 @@ The one rule to internalize: **every error message you write will be parsed by a
7575

7676
### Structured errors in commands
7777

78-
`RunE` functions must return `output.Errorf` / `output.ErrWithHint` — never bare `fmt.Errorf`. AI agents parse stderr as JSON; bare errors break this contract.
78+
Command-facing failures must be typed `errs.*` errors — never the legacy `output.Err*` helpers and never a final bare `fmt.Errorf`. AI agents parse the stderr envelope's `type` / `subtype` / `param` / `hint` fields to decide their next action; the full taxonomy lives in `errs/ERROR_CONTRACT.md`.
79+
80+
Picking a constructor:
81+
82+
| Failure | Constructor |
83+
|---------|-------------|
84+
| User flag/arg fails validation | `errs.NewValidationError(errs.SubtypeInvalidArgument, ...).WithParam("--flag")` |
85+
| Valid request, wrong system state | `errs.NewValidationError(errs.SubtypeFailedPrecondition, ...).WithHint(...)` |
86+
| Lark API returned `code != 0` | `runtime.CallAPITyped` (shortcuts) / `errclass.BuildAPIError` (raw responses) — never hand-build |
87+
| Network / transport failure | `errs.NewNetworkError(errs.SubtypeNetworkTransport, ...)` |
88+
| Local file I/O failure | `errs.NewInternalError(errs.SubtypeFileIO, ...)` — validate the path first (`validate.SafeInputPath` / `SafeOutputPath`) and use `vfs.*` |
89+
| Unclassified lower-layer error as final | `errs.NewInternalError(errs.SubtypeUnknown, ...).WithCause(err)` |
90+
| Lower layer already returned a typed error | pass it through unchanged — re-wrapping downgrades its classification |
91+
92+
Signatures that are easy to guess wrong:
93+
94+
- `runtime.CallAPITyped(method, url string, params map[string]interface{}, data interface{}) (map[string]interface{}, error)` — it performs the HTTP request itself and classifies `code != 0` into a typed error; just return the error it gives you.
95+
- Typed pass-through check: `if _, ok := errs.ProblemOf(err); ok { return err }``ProblemOf` returns `(*errs.Problem, bool)`, not a nilable pointer.
96+
- `.WithParam` exists only on `*errs.ValidationError`. `InternalError` / `NetworkError` have no param field — file or endpoint context goes in the message or `.WithHint(...)`.
97+
98+
`forbidigo` + `lint/errscontract` reject the legacy `output.Err*` helpers, bare final `fmt.Errorf` / `errors.New`, and legacy envelope literals on migrated paths. Beyond what lint catches, three authoring conventions apply:
99+
100+
- Preserve the underlying error with `.WithCause(err)` so `errors.Is` / `errors.Unwrap` keep working.
101+
- `param` names only the user input that actually failed. Recovery guidance goes in `.WithHint(...)`; machine-readable recovery fields (`missing_scopes`, `log_id`) carry server/system ground truth only — never caller-side guesses.
102+
- Error-path tests assert typed metadata via `errs.ProblemOf` (`category` / `subtype` / `param`) and cause preservation, not message substrings alone.
79103

80104
### stdout is data, stderr is everything else
81105

cmd/event/bus.go

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

1313
"github.com/spf13/cobra"
1414

15+
"github.com/larksuite/cli/errs"
1516
"github.com/larksuite/cli/internal/cmdutil"
1617
"github.com/larksuite/cli/internal/core"
1718
"github.com/larksuite/cli/internal/event"
@@ -38,7 +39,8 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
3839

3940
logger, err := bus.SetupBusLogger(eventsDir)
4041
if err != nil {
41-
return err
42+
return errs.NewInternalError(errs.SubtypeFileIO,
43+
"set up bus logger: %s", err).WithCause(err)
4244
}
4345

4446
tr := transport.New()
@@ -58,7 +60,14 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
5860
}
5961
}()
6062

61-
return b.Run(ctx)
63+
if err := b.Run(ctx); err != nil {
64+
if _, ok := errs.ProblemOf(err); ok {
65+
return err
66+
}
67+
return errs.NewInternalError(errs.SubtypeUnknown,
68+
"event bus daemon exited: %s", err).WithCause(err)
69+
}
70+
return nil
6271
},
6372
}
6473

cmd/event/bus_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package event
5+
6+
import (
7+
"os"
8+
"path/filepath"
9+
"testing"
10+
11+
"github.com/larksuite/cli/errs"
12+
"github.com/larksuite/cli/internal/cmdutil"
13+
"github.com/larksuite/cli/internal/core"
14+
)
15+
16+
// The hidden `event _bus` daemon command must exit with a typed file_io error
17+
// when its log directory cannot be created (the error is only visible in the
18+
// forked process's captured stderr / bus.log).
19+
func TestBusCommandLoggerSetupFailureIsTypedFileIO(t *testing.T) {
20+
dir := t.TempDir()
21+
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
22+
// Block the events/ root with a regular file so MkdirAll fails.
23+
if err := os.WriteFile(filepath.Join(dir, "events"), []byte("x"), 0600); err != nil {
24+
t.Fatal(err)
25+
}
26+
27+
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
28+
AppID: "cli_bus_test", AppSecret: "secret", Brand: core.BrandFeishu,
29+
})
30+
cmd := NewCmdBus(f)
31+
cmd.SetArgs([]string{})
32+
33+
err := cmd.Execute()
34+
if err == nil {
35+
t.Fatal("expected logger setup error")
36+
}
37+
p, ok := errs.ProblemOf(err)
38+
if !ok {
39+
t.Fatalf("expected typed errs error, got %T: %v", err, err)
40+
}
41+
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeFileIO {
42+
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
43+
errs.CategoryInternal, errs.SubtypeFileIO)
44+
}
45+
}

cmd/event/consume.go

Lines changed: 37 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616

1717
"github.com/spf13/cobra"
1818

19+
"github.com/larksuite/cli/errs"
1920
"github.com/larksuite/cli/internal/appmeta"
2021
"github.com/larksuite/cli/internal/auth"
2122
"github.com/larksuite/cli/internal/cmdutil"
@@ -101,11 +102,10 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
101102

102103
if o.jqExpr != "" {
103104
if err := output.ValidateJqExpression(o.jqExpr); err != nil {
104-
return output.ErrWithHint(
105-
output.ExitValidation, "validation",
106-
err.Error(),
107-
fmt.Sprintf("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey),
108-
)
105+
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
106+
WithParam("--jq").
107+
WithCause(err).
108+
WithHint("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey)
109109
}
110110
}
111111

@@ -261,12 +261,12 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
261261
if len(missing) == 0 {
262262
return nil
263263
}
264-
return output.ErrWithHint(
265-
output.ExitAuth, "auth",
266-
fmt.Sprintf("missing required scopes for EventKey %s (as %s): %s",
267-
pf.eventKey, pf.identity, strings.Join(missing, ", ")),
268-
scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand),
269-
)
264+
return errs.NewPermissionError(errs.SubtypeMissingScope,
265+
"missing required scopes for EventKey %s (as %s): %s",
266+
pf.eventKey, pf.identity, strings.Join(missing, ", ")).
267+
WithIdentity(string(pf.identity)).
268+
WithMissingScopes(missing...).
269+
WithHint("%s", scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand))
270270
}
271271

272272
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
@@ -301,23 +301,27 @@ func preflightEventTypes(pf *preflightCtx) error {
301301
if len(missing) == 0 {
302302
return nil
303303
}
304-
return output.ErrWithHint(
305-
output.ExitValidation, "validation",
306-
fmt.Sprintf("EventKey %s requires event types not subscribed in console: %s",
307-
pf.keyDef.Key, strings.Join(missing, ", ")),
308-
fmt.Sprintf("subscribe these events and publish a new app version at: %s",
309-
consoleEventSubscriptionURL(pf.brand, pf.appID)),
310-
)
304+
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
305+
"EventKey %s requires event types not subscribed in console: %s",
306+
pf.keyDef.Key, strings.Join(missing, ", ")).
307+
WithHint("subscribe these events and publish a new app version at: %s",
308+
consoleEventSubscriptionURL(pf.brand, pf.appID))
311309
}
312310

313311
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).
314312
func sanitizeOutputDir(dir string) (string, error) {
315313
if strings.HasPrefix(dir, "~") {
316-
return "", output.ErrValidation("%s; use a relative path like ./output instead", errOutputDirTilde)
314+
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
315+
"%s; use a relative path like ./output instead", errOutputDirTilde).
316+
WithParam("--output-dir").
317+
WithCause(errOutputDirTilde)
317318
}
318319
safe, err := validate.SafeOutputPath(dir)
319320
if err != nil {
320-
return "", output.ErrValidation("%s %q: %s", errOutputDirUnsafe, dir, err)
321+
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
322+
"%s %q: %s", errOutputDirUnsafe, dir, err).
323+
WithParam("--output-dir").
324+
WithCause(errOutputDirUnsafe)
321325
}
322326
return safe, nil
323327
}
@@ -329,18 +333,21 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
329333
}
330334
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID))
331335
if err != nil {
332-
return "", output.ErrAuth("resolve tenant access token: %s", err)
336+
if _, ok := errs.ProblemOf(err); ok {
337+
return "", err
338+
}
339+
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
340+
"resolve tenant access token: %s", err).WithCause(err)
333341
}
334342
if result == nil || result.Token == "" {
335-
return "", output.ErrWithHint(
336-
output.ExitAuth, "auth",
337-
fmt.Sprintf("no tenant access token available for app %s", appID),
338-
"Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.",
339-
)
343+
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
344+
"no tenant access token available for app %s", appID).
345+
WithHint("Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.")
340346
}
341347
return result.Token, nil
342348
}
343349

350+
// Sentinels for errors.Is checks; call sites wrap them as typed ValidationError causes.
344351
var (
345352
errInvalidParamFormat = errors.New("invalid --param format")
346353
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion")
@@ -352,7 +359,10 @@ func parseParams(raw []string) (map[string]string, error) {
352359
for _, kv := range raw {
353360
k, v, ok := strings.Cut(kv, "=")
354361
if !ok || k == "" {
355-
return nil, output.ErrValidation("%s %q: expected key=value", errInvalidParamFormat, kv)
362+
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
363+
"%s %q: expected key=value", errInvalidParamFormat, kv).
364+
WithParam("--param").
365+
WithCause(errInvalidParamFormat)
356366
}
357367
m[k] = v
358368
}

cmd/event/consume_test.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,14 @@
44
package event
55

66
import (
7+
"context"
78
"errors"
89
"strings"
910
"testing"
11+
12+
"github.com/larksuite/cli/errs"
13+
"github.com/larksuite/cli/internal/cmdutil"
14+
"github.com/larksuite/cli/internal/credential"
1015
)
1116

1217
func TestParseParams(t *testing.T) {
@@ -73,6 +78,7 @@ func TestParseParams(t *testing.T) {
7378
if tc.wantEcho != "" && !strings.Contains(err.Error(), tc.wantEcho) {
7479
t.Errorf("err %q should echo %q so user sees the bad input", err.Error(), tc.wantEcho)
7580
}
81+
assertInvalidArgumentParam(t, err, "--param")
7682
return
7783
}
7884
if err != nil {
@@ -90,6 +96,77 @@ func TestParseParams(t *testing.T) {
9096
}
9197
}
9298

99+
// emptyTokenResolver resolves to a result that carries no token.
100+
type emptyTokenResolver struct{}
101+
102+
func (emptyTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
103+
return &credential.TokenResult{}, nil
104+
}
105+
106+
// failingTokenResolver fails outright with an untyped error.
107+
type failingTokenResolver struct{}
108+
109+
func (failingTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
110+
return nil, errors.New("backend unavailable")
111+
}
112+
113+
func factoryWithResolver(r credential.DefaultTokenResolver) *cmdutil.Factory {
114+
return &cmdutil.Factory{Credential: credential.NewCredentialProvider(nil, nil, r, nil)}
115+
}
116+
117+
func TestResolveTenantToken_EmptyTokenResult(t *testing.T) {
118+
_, err := resolveTenantToken(context.Background(), factoryWithResolver(emptyTokenResolver{}), "cli_x")
119+
if err == nil {
120+
t.Fatal("expected error, got nil")
121+
}
122+
p, ok := errs.ProblemOf(err)
123+
if !ok {
124+
t.Fatalf("expected typed errs error, got %T: %v", err, err)
125+
}
126+
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
127+
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
128+
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
129+
}
130+
var malformed *credential.MalformedTokenResultError
131+
if !errors.As(err, &malformed) {
132+
t.Error("empty-token failure should preserve the credential-layer cause")
133+
}
134+
}
135+
136+
func TestResolveTenantToken_ResolverFailure(t *testing.T) {
137+
_, err := resolveTenantToken(context.Background(), factoryWithResolver(failingTokenResolver{}), "cli_x")
138+
if err == nil {
139+
t.Fatal("expected error, got nil")
140+
}
141+
p, ok := errs.ProblemOf(err)
142+
if !ok {
143+
t.Fatalf("expected typed errs error, got %T: %v", err, err)
144+
}
145+
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
146+
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
147+
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
148+
}
149+
if errors.Unwrap(err) == nil {
150+
t.Error("resolver failure should preserve its cause")
151+
}
152+
}
153+
154+
// assertInvalidArgumentParam verifies err is a typed validation error with
155+
// subtype invalid_argument naming the given flag in its param field.
156+
func assertInvalidArgumentParam(t *testing.T, err error, param string) {
157+
t.Helper()
158+
var ve *errs.ValidationError
159+
if !errors.As(err, &ve) {
160+
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
161+
}
162+
if ve.Subtype != errs.SubtypeInvalidArgument {
163+
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
164+
}
165+
if ve.Param != param {
166+
t.Errorf("param = %q, want %q", ve.Param, param)
167+
}
168+
}
169+
93170
func TestSanitizeOutputDir(t *testing.T) {
94171
cases := []struct {
95172
name string
@@ -130,6 +207,7 @@ func TestSanitizeOutputDir(t *testing.T) {
130207
if !errors.Is(err, tc.wantSentry) {
131208
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
132209
}
210+
assertInvalidArgumentParam(t, err, "--output-dir")
133211
return
134212
}
135213
if err != nil {

0 commit comments

Comments
 (0)