Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions docs/structured-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ Within the main payload, the `error` field contains the following sub-fields:
| Field | Type | Notes |
|---|---|---|
| `code` | string | One of the enumerated codes below. Never free text — see the error-codes table. The primary, stable identifier — branch on this for anything specific. |
| `category` | string | One of 7 coarse groupings of `code` (`RUNTIME`, `EMULATOR`, `AUTH`, `RESOURCE`, `CONFIG`, `USAGE`, `INTERNAL`) — see [Error categories](#error-categories) below. Additive alongside `code`, not a replacement for it: a caller that only wants broad handling can switch on `category`'s ~7 values instead of `code`'s ~28, while a caller that already keys off a specific `code` is unaffected. |
| `category` | string | One of 8 coarse groupings of `code` (`RUNTIME`, `EMULATOR`, `AUTH`, `RESOURCE`, `CONFIG`, `USAGE`, `INTERNAL`, `IAC`) — see [Error categories](#error-categories) below. Additive alongside `code`, not a replacement for it: a caller that only wants broad handling can switch on `category`'s ~8 values instead of `code`'s ~32, while a caller that already keys off a specific `code` is unaffected. |
| `message` | string | Human-readable headline, informational only. **Not guaranteed stable across versions** — scripts must branch on `code`, not `message`. |
| `retryable` | bool | A static property of `code` (not computed per failure) — see below. Note this is independent of `category`: a category can contain both retryable and non-retryable codes (e.g. `RUNTIME` contains both `NETWORK_ERROR` [retryable] and `DNS_RESOLUTION_REQUIRED` [not]), so `retryable` can't be inferred from `category` alone. |
| `details` | object | Optional, code-specific structured context. Omitted when empty. Illustrative example, for a future `SNAPSHOT_BUCKET_NOT_FOUND`: `{"bucket": "my-terraform-state"}`. Also where the additional diagnostic depth plain text and the TUI show alongside the `message` headline lands, as `summary`/`detail` string keys, when available — e.g. `{"summary": "cannot connect to Docker daemon: ..."}` for `RUNTIME_UNAVAILABLE`. |
Expand Down Expand Up @@ -121,10 +121,14 @@ Every `error.code` is one of the following fixed constants. A failure that doesn
| `NETWORK_ERROR` | An unclassified network/transport failure occurred | Yes | `RUNTIME` |
| `CANCELLED` | The operation was interrupted (e.g. context cancellation via Ctrl+C) | Yes | `INTERNAL` |
| `INTERNAL_ERROR` | Unclassified or unexpected failure; the universal fallback | No | `INTERNAL` |
| `IAC_FILE_NOT_FOUND` | A required infrastructure-as-code file or directory does not exist or cannot be read (e.g. the workspace `lstk deploy detect --dir` was pointed at) | No | `IAC` |
| `IAC_NO_TOOL_DETECTED` | No IaC tool could be resolved for the workspace: nothing matched, and no `--tool` was given | No | `IAC` |
| `IAC_TOOL_AMBIGUOUS` | More than one IaC tool matched the workspace and no choice could be made (no `--tool`, and nobody to prompt) | No | `IAC` |
| `IAC_DEPLOY_FAILED` | A delegated deployment command (e.g. `terraform apply`) exited non-zero; the tool's own status is reported in `error.details` | No | `IAC` |

### Error categories

`error.category` groups the 28 codes above into 7 buckets, additive alongside `code` — it exists purely so a caller that only wants coarse handling doesn't have to build and maintain its own mapping from all 28 codes. `code` is unaffected and remains the primary, stable identifier for anything more specific.
`error.category` groups the 29 codes above into 8 buckets, additive alongside `code` — it exists purely so a caller that only wants coarse handling doesn't have to build and maintain its own mapping from all 29 codes. `code` is unaffected and remains the primary, stable identifier for anything more specific.

```
RUNTIME RUNTIME_UNAVAILABLE, IMAGE_PULL_FAILED, DEPENDENCY_MISSING,
Expand Down Expand Up @@ -154,10 +158,17 @@ USAGE CONFIRMATION_REQUIRED, VALIDATION_ERROR, USAGE_ERROR,

INTERNAL CANCELLED, INTERNAL_ERROR
→ catch-all: unexpected failure, or a user-initiated interruption

IAC IAC_FILE_NOT_FOUND, IAC_NO_TOOL_DETECTED, IAC_TOOL_AMBIGUOUS,
IAC_DEPLOY_FAILED
→ the workspace's infrastructure-as-code is the problem (the
domain `lstk deploy` operates in)
```

Every code maps to exactly one category, and the mapping is static — the same code always reports the same category, regardless of which command emitted it (see the Category column above for the authoritative per-code mapping).

`IAC` is the first category no built-in command emits today: `lstk deploy` is a bundled extension, and an extension renders its own envelope (see the note on extension dispatch under [Commands that will never support `--json`](#commands-that-will-never-support---json)). The vocabulary is enumerated here anyway, because the whole point of a closed enumeration is that a consumer can switch on it exhaustively without caring which binary produced the envelope — an extension's output is meant to be indistinguishable from a built-in's.

## Exit codes

```
Expand Down
17 changes: 16 additions & 1 deletion internal/output/error_code.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ const (
ErrNetworkError ErrorCode = "NETWORK_ERROR"
ErrCancelled ErrorCode = "CANCELLED"
ErrInternal ErrorCode = "INTERNAL_ERROR"
ErrIACFileNotFound ErrorCode = "IAC_FILE_NOT_FOUND"
ErrIACNoToolDetected ErrorCode = "IAC_NO_TOOL_DETECTED"
ErrIACToolAmbiguous ErrorCode = "IAC_TOOL_AMBIGUOUS"
ErrIACDeployFailed ErrorCode = "IAC_DEPLOY_FAILED"
)

// retryableCodes is the single source of truth for whether a given ErrorCode
Expand All @@ -60,7 +64,7 @@ func (c ErrorCode) Retryable() bool {
// additive alongside Code (not a replacement for it — see design.md's
// naming decisions). A caller that only wants to distinguish broad kinds of
// failure (an environment problem vs. a usage problem vs. an auth problem)
// can switch on the ~7 Category values instead of the ~28 Code values;
// can switch on the ~8 Category values instead of the ~32 Code values;
// Code remains the primary, stable identifier for anything more specific.
type ErrorCategory string

Expand All @@ -84,6 +88,9 @@ const (
// CategoryInternal: catch-all — unexpected failure or user-initiated
// interruption.
CategoryInternal ErrorCategory = "INTERNAL"
// CategoryIAC: the workspace's infrastructure-as-code is the problem —
// the domain `lstk deploy` operates in.
CategoryIAC ErrorCategory = "IAC"
)

// allErrorCodes lists every defined ErrorCode, so tests can assert every code
Expand Down Expand Up @@ -118,6 +125,10 @@ var allErrorCodes = []ErrorCode{
ErrNetworkError,
ErrCancelled,
ErrInternal,
ErrIACFileNotFound,
ErrIACNoToolDetected,
ErrIACToolAmbiguous,
ErrIACDeployFailed,
}

// categoryByCode is the single source of truth mapping each ErrorCode to its
Expand Down Expand Up @@ -152,6 +163,10 @@ var categoryByCode = map[ErrorCode]ErrorCategory{
ErrNotJSONCapable: CategoryUsage,
ErrCancelled: CategoryInternal,
ErrInternal: CategoryInternal,
ErrIACFileNotFound: CategoryIAC,
ErrIACNoToolDetected: CategoryIAC,
ErrIACToolAmbiguous: CategoryIAC,
ErrIACDeployFailed: CategoryIAC,
}

// Category reports the code's static, coarse grouping. Every ErrorCode in
Expand Down
8 changes: 6 additions & 2 deletions internal/output/error_code_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ func TestErrorCode_AllErrorCodesIsComplete(t *testing.T) {
t.Errorf("ErrorCode %q appears %d times in allErrorCodes, want exactly once", code, count)
}
}
if len(allErrorCodes) != 28 {
t.Errorf("expected 28 documented error codes, got %d — update this test's expectation alongside error-codes/spec.md if a code was intentionally added or removed", len(allErrorCodes))
if len(allErrorCodes) != 32 {
t.Errorf("expected 32 documented error codes, got %d — update this test's expectation alongside error-codes/spec.md if a code was intentionally added or removed", len(allErrorCodes))
}
}

Expand All @@ -56,6 +56,10 @@ func TestErrorCode_Category(t *testing.T) {
{ErrUsageError, CategoryUsage},
{ErrCancelled, CategoryInternal},
{ErrInternal, CategoryInternal},
{ErrIACFileNotFound, CategoryIAC},
{ErrIACNoToolDetected, CategoryIAC},
{ErrIACToolAmbiguous, CategoryIAC},
{ErrIACDeployFailed, CategoryIAC},
}
for _, c := range cases {
if got := c.code.Category(); got != c.want {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ Every `error.code` value emitted in a JSON envelope SHALL be one of a fixed, doc
| `NETWORK_ERROR` | An unclassified network/transport failure occurred | Yes | `RUNTIME` |
| `CANCELLED` | The operation was interrupted (e.g. context cancellation via Ctrl+C) | Yes | `INTERNAL` |
| `INTERNAL_ERROR` | Unclassified or unexpected failure; the universal fallback | No | `INTERNAL` |
| `IAC_FILE_NOT_FOUND` | A required infrastructure-as-code file or directory does not exist or cannot be read (e.g. the workspace `lstk deploy detect --dir` was pointed at) | No | `IAC` |
| `IAC_NO_TOOL_DETECTED` | No IaC tool could be resolved for the workspace: nothing matched, and no `--tool` was given | No | `IAC` |
| `IAC_TOOL_AMBIGUOUS` | More than one IaC tool matched the workspace and no choice could be made (no `--tool`, and nobody to prompt) | No | `IAC` |
| `IAC_DEPLOY_FAILED` | A delegated deployment command (e.g. `terraform apply`) exited non-zero; the tool's own status is reported in `error.details` | No | `IAC` |

#### Scenario: Error code is one of the documented constants
- **WHEN** any JSON-capable command emits an `error` object
Expand Down Expand Up @@ -65,11 +69,11 @@ Every `error` object SHALL include a `retryable` boolean, a static property of `
- **THEN** both error objects report the same `retryable` value for that code

### Requirement: Error objects declare a coarse category, additive alongside code
Every `error` object SHALL include a `category` string, a static property of `code` (the same code always carries the same category, per the table above) drawn from a fixed, small set: `RUNTIME`, `EMULATOR`, `AUTH`, `RESOURCE`, `CONFIG`, `USAGE`, `INTERNAL`. `category` is additive: it exists so a caller that only wants to distinguish broad kinds of failure can switch on roughly 7 values instead of the full code list, without requiring `code` to change meaning or cardinality — `code` remains the primary, stable identifier for anything more specific, and every existing rule keyed on `code` (the exit-code reservations, `retryable`, every scenario elsewhere in this document) is unaffected by `category`'s presence.
Every `error` object SHALL include a `category` string, a static property of `code` (the same code always carries the same category, per the table above) drawn from a fixed, small set: `RUNTIME`, `EMULATOR`, `AUTH`, `RESOURCE`, `CONFIG`, `USAGE`, `INTERNAL`, `IAC`. `category` is additive: it exists so a caller that only wants to distinguish broad kinds of failure can switch on roughly 8 values instead of the full code list, without requiring `code` to change meaning or cardinality — `code` remains the primary, stable identifier for anything more specific, and every existing rule keyed on `code` (the exit-code reservations, `retryable`, every scenario elsewhere in this document) is unaffected by `category`'s presence.

#### Scenario: Category is one of the documented constants
- **WHEN** any JSON-capable command emits an `error` object
- **THEN** `error.category` is exactly one of `RUNTIME`, `EMULATOR`, `AUTH`, `RESOURCE`, `CONFIG`, `USAGE`, or `INTERNAL`
- **THEN** `error.category` is exactly one of `RUNTIME`, `EMULATOR`, `AUTH`, `RESOURCE`, `CONFIG`, `USAGE`, `INTERNAL`, or `IAC`

#### Scenario: Category is consistent for a given code
- **WHEN** the same `error.code` is emitted by two different commands
Expand Down
Loading