Skip to content

Commit 482da0b

Browse files
authored
feat(derun): enrich error messages with deterministic details (#301)
## Summary - add a shared `internal/errmsg` formatter for deterministic single-line error details - enrich derun runtime/usage/parse/required errors across CLI, MCP, state, transport, retention, logging, capture, and session ID generation - preserve compatibility-critical tokens (`session not found`, `parse <field>`, `<field> is required`) while adding safe diagnostics - keep sentinel compatibility by wrapping `ErrSessionNotFound` and `ErrInvalidSessionID` with `%w`-compatible chains - update derun docs contracts for the new `details` segment and safe-data policy ## Testing - `go test ./cmds/derun/...`
1 parent bcc1b82 commit 482da0b

31 files changed

Lines changed: 991 additions & 208 deletions

cmds/derun/internal/capture/writer.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
package capture
22

33
import (
4-
"fmt"
54
"sync"
65
"time"
76

87
"github.com/delinoio/oss/cmds/derun/internal/contracts"
8+
"github.com/delinoio/oss/cmds/derun/internal/errmsg"
99
"github.com/delinoio/oss/cmds/derun/internal/logging"
1010
"github.com/delinoio/oss/cmds/derun/internal/state"
1111
)
@@ -35,7 +35,11 @@ func (w *Writer) Write(p []byte) (int, error) {
3535
defer w.mu.Unlock()
3636
offset, err := w.store.AppendOutput(w.sessionID, w.channel, p, time.Now().UTC())
3737
if err != nil {
38-
return 0, fmt.Errorf("append output: %w", err)
38+
return 0, errmsg.Error(errmsg.Runtime("append output", err, map[string]any{
39+
"session_id": w.sessionID,
40+
"channel": w.channel,
41+
"chunk_size": len(p),
42+
}), nil)
3943
}
4044
w.logger.Event("chunk_written", map[string]any{
4145
"session_id": w.sessionID,

cmds/derun/internal/cli/errors.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
package cli
22

3-
import "fmt"
3+
import "github.com/delinoio/oss/cmds/derun/internal/errmsg"
44

55
func formatUsageError(reason, hint string) string {
6-
if hint == "" {
7-
return fmt.Sprintf("invalid arguments: %s", reason)
8-
}
9-
return fmt.Sprintf("invalid arguments: %s; hint: %s", reason, hint)
6+
return formatUsageErrorWithDetails(reason, hint, nil)
7+
}
8+
9+
func formatUsageErrorWithDetails(reason, hint string, details map[string]any) string {
10+
return errmsg.Usage(reason, hint, details)
1011
}
1112

1213
func formatRuntimeError(action string, err error) string {
13-
if err == nil {
14-
return fmt.Sprintf("failed to %s", action)
15-
}
16-
return fmt.Sprintf("failed to %s: %v", action, err)
14+
return formatRuntimeErrorWithDetails(action, err, nil)
15+
}
16+
17+
func formatRuntimeErrorWithDetails(action string, err error, details map[string]any) string {
18+
return errmsg.Runtime(action, err, details)
1719
}

cmds/derun/internal/cli/mcp.go

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,26 +26,43 @@ func ExecuteMCP(args []string) int {
2626
return 2
2727
}
2828
if len(fs.Args()) != 0 {
29+
firstArg := ""
30+
if len(fs.Args()) > 0 {
31+
firstArg = fs.Args()[0]
32+
}
2933
fmt.Fprintln(
3034
os.Stderr,
31-
formatUsageError("mcp command does not accept positional arguments", "use `derun mcp` without extra arguments"),
35+
formatUsageErrorWithDetails(
36+
"mcp command does not accept positional arguments",
37+
"use `derun mcp` without extra arguments",
38+
map[string]any{
39+
"arg_count": len(fs.Args()),
40+
"first_arg": firstArg,
41+
},
42+
),
3243
)
3344
return 2
3445
}
3546

3647
stateRoot, err := resolveStateRootForMCP()
3748
if err != nil {
38-
fmt.Fprintln(os.Stderr, formatRuntimeError("resolve state root", err))
49+
fmt.Fprintln(os.Stderr, formatRuntimeErrorWithDetails("resolve state root", err, map[string]any{
50+
"has_derun_state_root": os.Getenv("DERUN_STATE_ROOT") != "",
51+
}))
3952
return 1
4053
}
4154
store, err := state.New(stateRoot)
4255
if err != nil {
43-
fmt.Fprintln(os.Stderr, formatRuntimeError("initialize state store", err))
56+
fmt.Fprintln(os.Stderr, formatRuntimeErrorWithDetails("initialize state store", err, map[string]any{
57+
"state_root": stateRoot,
58+
}))
4459
return 1
4560
}
4661
logger, err := logging.New(stateRoot)
4762
if err != nil {
48-
fmt.Fprintln(os.Stderr, formatRuntimeError("initialize logger", err))
63+
fmt.Fprintln(os.Stderr, formatRuntimeErrorWithDetails("initialize logger", err, map[string]any{
64+
"state_root": stateRoot,
65+
}))
4966
return 1
5067
}
5168
defer logger.Close()
@@ -54,7 +71,11 @@ func ExecuteMCP(args []string) int {
5471

5572
server := mcp.NewServer(store, logger, 10*time.Minute, defaultRetention)
5673
if err := server.Serve(context.Background(), os.Stdin, os.Stdout); err != nil {
57-
fmt.Fprintln(os.Stderr, formatRuntimeError("run mcp server", err))
74+
fmt.Fprintln(os.Stderr, formatRuntimeErrorWithDetails("run mcp server", err, map[string]any{
75+
"state_root": stateRoot,
76+
"gc_interval_ms": (10 * time.Minute).Milliseconds(),
77+
"retention_ms": defaultRetention.Milliseconds(),
78+
}))
5879
return 1
5980
}
6081
return 0

cmds/derun/internal/cli/root.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,14 @@ func Execute(args []string) int {
3030
default:
3131
fmt.Fprintln(
3232
os.Stderr,
33-
formatUsageError(fmt.Sprintf("unknown command %q", args[0]), "run `derun help` to see available commands"),
33+
formatUsageErrorWithDetails(
34+
fmt.Sprintf("unknown command %q", args[0]),
35+
"run `derun help` to see available commands",
36+
map[string]any{
37+
"command": args[0],
38+
"arg_count": len(args),
39+
},
40+
),
3441
)
3542
printUsage()
3643
return 2
@@ -45,7 +52,11 @@ func executeHelp(args []string) int {
4552
if len(args) > 1 {
4653
fmt.Fprintln(
4754
os.Stderr,
48-
formatUsageError("help command accepts at most one topic", "use `derun help` or `derun help <run|mcp>`"),
55+
formatUsageErrorWithDetails(
56+
"help command accepts at most one topic",
57+
"use `derun help` or `derun help <run|mcp>`",
58+
map[string]any{"topic_count": len(args)},
59+
),
4960
)
5061
return 2
5162
}
@@ -60,7 +71,11 @@ func executeHelp(args []string) int {
6071
default:
6172
fmt.Fprintln(
6273
os.Stderr,
63-
formatUsageError(fmt.Sprintf("unknown help topic %q", args[0]), "use `derun help` to list supported topics"),
74+
formatUsageErrorWithDetails(
75+
fmt.Sprintf("unknown help topic %q", args[0]),
76+
"use `derun help` to list supported topics",
77+
map[string]any{"topic": args[0]},
78+
),
6479
)
6580
printUsage()
6681
return 2

cmds/derun/internal/cli/run.go

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ package cli
22

33
import (
44
"errors"
5-
"fmt"
65
"os"
76
"runtime"
87
"strings"
98
"time"
109

1110
"github.com/delinoio/oss/cmds/derun/internal/contracts"
11+
"github.com/delinoio/oss/cmds/derun/internal/errmsg"
1212
"github.com/delinoio/oss/cmds/derun/internal/logging"
1313
"github.com/delinoio/oss/cmds/derun/internal/session"
1414
"github.com/delinoio/oss/cmds/derun/internal/state"
@@ -83,10 +83,24 @@ func selectTransportMode(ttyAttached bool, goos string) contracts.DerunTransport
8383

8484
func validateRetentionDuration(retentionDuration time.Duration) error {
8585
if retentionDuration <= 0 {
86-
return errors.New(formatUsageError("retention must be positive", "use values like 1s, 5m, or 24h"))
86+
return errors.New(formatUsageErrorWithDetails(
87+
"retention must be positive",
88+
"use values like 1s, 5m, or 24h",
89+
map[string]any{
90+
"retention": retentionDuration.String(),
91+
"retention_ms": retentionDuration.Milliseconds(),
92+
},
93+
))
8794
}
8895
if retentionDuration%time.Second != 0 {
89-
return errors.New(formatUsageError("retention must use whole-second precision", "use values like 1s, 30s, or 5m"))
96+
return errors.New(formatUsageErrorWithDetails(
97+
"retention must use whole-second precision",
98+
"use values like 1s, 30s, or 5m",
99+
map[string]any{
100+
"retention": retentionDuration.String(),
101+
"retention_ms": retentionDuration.Milliseconds(),
102+
},
103+
))
90104
}
91105
return nil
92106
}
@@ -99,14 +113,20 @@ func resolveStateRootForRun() (string, error) {
99113
}
100114

101115
func generateUniqueSessionID(store *state.Store, logger *logging.Logger) (string, error) {
102-
for attempt := 1; attempt <= 5; attempt++ {
116+
const maxAttempts = 5
117+
for attempt := 1; attempt <= maxAttempts; attempt++ {
103118
sessionID, err := session.NewULID(time.Now().UTC())
104119
if err != nil {
105-
return "", err
120+
return "", errors.New(formatRuntimeErrorWithDetails("generate candidate session id", err, map[string]any{
121+
"attempt": attempt,
122+
}))
106123
}
107124
hasMetadata, err := store.HasSessionMetadata(sessionID)
108125
if err != nil {
109-
return "", err
126+
return "", errors.New(formatRuntimeErrorWithDetails("check candidate session metadata", err, map[string]any{
127+
"attempt": attempt,
128+
"session_id": sessionID,
129+
}))
110130
}
111131
if !hasMetadata {
112132
return sessionID, nil
@@ -116,7 +136,9 @@ func generateUniqueSessionID(store *state.Store, logger *logging.Logger) (string
116136
"attempt": attempt,
117137
})
118138
}
119-
return "", fmt.Errorf("too many session id collisions")
139+
return "", errmsg.Error("too many session id collisions", map[string]any{
140+
"attempt_limit": maxAttempts,
141+
})
120142
}
121143

122144
func derefInt(v *int) int {

0 commit comments

Comments
 (0)