|
1
|
+ |
//! The exit-code ladder and the `--json` error envelope.
|
|
2
|
+ |
//!
|
|
3
|
+ |
//! A machine consuming `oa` has to be able to tell an expired token from a
|
|
4
|
+ |
//! typo from a missing repository from an outage. Until this module existed it
|
|
5
|
+ |
//! could not: every refusal left through `cli::fail`, which exits 2, and an
|
|
6
|
+ |
//! internal failure exited 1 — inverted from the convention where 1 is the
|
|
7
|
+ |
//! generic failure and 2 is the usage error.
|
|
8
|
+ |
//!
|
|
9
|
+ |
//! The ladder here is not a new design. It is the one the TypeScript CLI
|
|
10
|
+ |
//! publishes at `packages/openagents-cli/src/errors.ts` (`exitCodeFor`), which
|
|
11
|
+ |
//! consumers already code against and which release automation keys on for
|
|
12
|
+ |
//! 17, 18, and 19. It is transcribed rather than reinterpreted, and
|
|
13
|
+ |
//! [`CliError::exit_code`] is asserted against that source arm by arm in
|
|
14
|
+ |
//! `tests/parity_test.rs`. Adding a code here without adding it there is a
|
|
15
|
+ |
//! divergence, which is the thing this module exists to prevent.
|
|
16
|
+ |
//!
|
|
17
|
+ |
//! ## The envelope
|
|
18
|
+ |
//!
|
|
19
|
+ |
//! Under `--json` a failure prints one compact JSON object on **stdout** and
|
|
20
|
+ |
//! nothing on stderr:
|
|
21
|
+ |
//!
|
|
22
|
+ |
//! ```text
|
|
23
|
+ |
//! {"code":"api_error","message":"Not Found","exit_code":4,"request_id":"…"}
|
|
24
|
+ |
//! ```
|
|
25
|
+ |
//!
|
|
26
|
+ |
//! That is `main.ts`'s shape, key for key, and compact for the same reason:
|
|
27
|
+ |
//! a consumer reading NDJSON gets one document per line. Without `--json` the
|
|
28
|
+ |
//! failure is one `oa: …` sentence on stderr, which is what it always was.
|
|
29
|
+ |
|
|
30
|
+ |
use std::sync::atomic::{AtomicBool, Ordering};
|
|
31
|
+ |
|
|
32
|
+ |
/// Whether `--json` was passed. Read by [`fail`], which has no other way to
|
|
33
|
+ |
/// know: it is called from several hundred sites that never took the flag.
|
|
34
|
+ |
static JSON: AtomicBool = AtomicBool::new(false);
|
|
35
|
+ |
|
|
36
|
+ |
/// Record `--json` for the failure path. Called once from `cli::run`.
|
|
37
|
+ |
pub fn set_json(on: bool) {
|
|
38
|
+ |
JSON.store(on, Ordering::Relaxed);
|
|
39
|
+ |
}
|
|
40
|
+ |
|
|
41
|
+ |
pub fn json() -> bool {
|
|
42
|
+ |
JSON.load(Ordering::Relaxed)
|
|
43
|
+ |
}
|
|
44
|
+ |
|
|
45
|
+ |
/// Why the command stopped, in the classes the TypeScript CLI distinguishes.
|
|
46
|
+ |
///
|
|
47
|
+ |
/// Each variant corresponds to one `_tag` in `errors.ts`. Variants the Rust
|
|
48
|
+ |
/// CLI has no producer for yet are still present, so the ladder is complete
|
|
49
|
+ |
/// and testable as a unit and so wiring a producer later is a one-line change
|
|
50
|
+ |
/// rather than a re-derivation of the mapping.
|
|
51
|
+ |
#[derive(Debug, Clone)]
|
|
52
|
+ |
pub enum CliError {
|
|
53
|
+ |
/// A malformed argument, an impossible combination, a bad flag value.
|
|
54
|
+ |
Input(String),
|
|
55
|
+ |
/// The environment or a config file cannot support the request.
|
|
56
|
+ |
Configuration(String),
|
|
57
|
+ |
/// No usable credential, or one the store would not surrender.
|
|
58
|
+ |
AuthenticationRequired(String),
|
|
59
|
+ |
/// The credential store itself failed.
|
|
60
|
+ |
CredentialStore(String),
|
|
61
|
+ |
/// The request never reached a server, or never came back.
|
|
62
|
+ |
Network(String),
|
|
63
|
+ |
/// The server answered inside the accepted set with a body this cannot read.
|
|
64
|
+ |
Contract(String),
|
|
65
|
+ |
/// The server answered and refused. The status decides the code.
|
|
66
|
+ |
Api {
|
|
67
|
+ |
status: u16,
|
|
68
|
+ |
/// The server's own `code` field, when it sent one.
|
|
69
|
+ |
code: Option<String>,
|
|
70
|
+ |
message: String,
|
|
71
|
+ |
request_id: Option<String>,
|
|
72
|
+ |
},
|
|
73
|
+ |
/// A repository import ended in `failed`, or stopped being watched.
|
|
74
|
+ |
Import(String),
|
|
75
|
+ |
/// Repository provisioning ended in `failed`, or stopped being watched.
|
|
76
|
+ |
Provisioning(String),
|
|
77
|
+ |
/// A `git` invocation failed.
|
|
78
|
+ |
Git(String),
|
|
79
|
+ |
/// Rendering the answer failed after the answer arrived.
|
|
80
|
+ |
Output(String),
|
|
81
|
+ |
ComputerAlreadyPaired(String),
|
|
82
|
+ |
ComputerPairingInProgress(String),
|
|
83
|
+ |
ComputerDisabled(String),
|
|
84
|
+ |
ComputerPairingExpired(String),
|
|
85
|
+ |
ComputerPairingRefused(String),
|
|
86
|
+ |
ComputerPairingNetworkFailure(String),
|
|
87
|
+ |
ComputerStatusNetworkFailure(String),
|
|
88
|
+ |
ComputerMachineUnavailable(String),
|
|
89
|
+ |
ComputerMachineMismatch(String),
|
|
90
|
+ |
ComputerReconnectExhausted(String),
|
|
91
|
+ |
/// A fleet promotion target reached `failed` or `reverted`.
|
|
92
|
+ |
DeploymentFailed(String),
|
|
93
|
+ |
/// Polling ended while the target was still nonterminal. The target has
|
|
94
|
+ |
/// not failed; the CLI stopped watching.
|
|
95
|
+ |
DeploymentWaitTimeout(String),
|
|
96
|
+ |
/// The target needs an operator-driven rolling replacement to finish.
|
|
97
|
+ |
DeploymentRollingReplaceRequired(String),
|
|
98
|
+ |
/// A failure that reached the top with no class of its own.
|
|
99
|
+ |
///
|
|
100
|
+ |
/// The one variant with no counterpart in `errors.ts`, and deliberately
|
|
101
|
+ |
/// so: it stands for the branch `main.ts` takes when `isCliError` is
|
|
102
|
+ |
/// false, which exits 1 there too. It is rung 1 rather than rung 2
|
|
103
|
+ |
/// because 1 is the generic failure and 2 is the usage error, and `oa`
|
|
104
|
+ |
/// had those the wrong way round.
|
|
105
|
+ |
Internal(String),
|
|
106
|
+ |
}
|
|
107
|
+ |
|
|
108
|
+ |
impl CliError {
|
|
109
|
+ |
/// The status this failure exits with.
|
|
110
|
+ |
///
|
|
111
|
+ |
/// Transcribed from `exitCodeFor` in `packages/openagents-cli/src/errors.ts`.
|
|
112
|
+ |
/// Code 16 is deliberately absent there — it was `TraceUploadUnsupported`,
|
|
113
|
+ |
/// retired rather than reassigned so a script still checking for it stops
|
|
114
|
+ |
/// seeing it instead of starting to see it mean something else — and it is
|
|
115
|
+ |
/// absent here for the same reason.
|
|
116
|
+ |
pub fn exit_code(&self) -> i32 {
|
|
117
|
+ |
match self {
|
|
118
|
+ |
Self::Input(_) | Self::Configuration(_) => 2,
|
|
119
|
+ |
Self::ComputerAlreadyPaired(_) | Self::ComputerPairingInProgress(_) => 5,
|
|
120
|
+ |
Self::ComputerDisabled(_) => 8,
|
|
121
|
+ |
Self::ComputerPairingExpired(_) => 9,
|
|
122
|
+ |
Self::ComputerPairingRefused(_) => 10,
|
|
123
|
+ |
Self::ComputerPairingNetworkFailure(_) => 11,
|
|
124
|
+ |
Self::ComputerStatusNetworkFailure(_) => 12,
|
|
125
|
+ |
Self::ComputerMachineUnavailable(_) => 13,
|
|
126
|
+ |
Self::ComputerMachineMismatch(_) => 14,
|
|
127
|
+ |
Self::ComputerReconnectExhausted(_) => 15,
|
|
128
|
+ |
Self::DeploymentFailed(_) => 17,
|
|
129
|
+ |
Self::DeploymentWaitTimeout(_) => 18,
|
|
130
|
+ |
Self::DeploymentRollingReplaceRequired(_) => 19,
|
|
131
|
+ |
Self::AuthenticationRequired(_) | Self::CredentialStore(_) => 3,
|
|
132
|
+ |
Self::Network(_) | Self::Contract(_) => 6,
|
|
133
|
+ |
Self::Api { status, .. } => match *status {
|
|
134
|
+ |
401 | 403 => 3,
|
|
135
|
+ |
404 => 4,
|
|
136
|
+ |
409 => 5,
|
|
137
|
+ |
400 | 422 => 2,
|
|
138
|
+ |
status if status >= 500 => 6,
|
|
139
|
+ |
_ => 1,
|
|
140
|
+ |
},
|
|
141
|
+ |
Self::Import(_) | Self::Provisioning(_) => 7,
|
|
142
|
+ |
Self::Git(_) | Self::Output(_) | Self::Internal(_) => 1,
|
|
143
|
+ |
}
|
|
144
|
+ |
}
|
|
145
|
+ |
|
|
146
|
+ |
/// The `code` field of the envelope.
|
|
147
|
+ |
///
|
|
148
|
+ |
/// `errorCode` in `errors.ts` returns the server's own `code` for an API
|
|
149
|
+ |
/// refusal that carried one, and otherwise the tag with its
|
|
150
|
+ |
/// `OpenAgentsCli.` prefix dropped and its camel case broken into
|
|
151
|
+ |
/// snake case.
|
|
152
|
+ |
pub fn code(&self) -> String {
|
|
153
|
+ |
if let Self::Api {
|
|
154
|
+ |
code: Some(code), ..
|
|
155
|
+ |
} = self
|
|
156
|
+ |
{
|
|
157
|
+ |
return code.clone();
|
|
158
|
+ |
}
|
|
159
|
+ |
snake_case(self.tag())
|
|
160
|
+ |
}
|
|
161
|
+ |
|
|
162
|
+ |
/// The tag, as `errors.ts` spells it after the `OpenAgentsCli.` prefix.
|
|
163
|
+ |
fn tag(&self) -> &'static str {
|
|
164
|
+ |
match self {
|
|
165
|
+ |
Self::Input(_) => "InputError",
|
|
166
|
+ |
Self::Configuration(_) => "ConfigurationError",
|
|
167
|
+ |
Self::AuthenticationRequired(_) => "AuthenticationRequired",
|
|
168
|
+ |
Self::CredentialStore(_) => "CredentialStoreError",
|
|
169
|
+ |
Self::Network(_) => "TransportError",
|
|
170
|
+ |
Self::Contract(_) => "ContractError",
|
|
171
|
+ |
Self::Api { .. } => "ApiError",
|
|
172
|
+ |
Self::Import(_) => "ImportFailed",
|
|
173
|
+ |
Self::Provisioning(_) => "ProvisioningFailed",
|
|
174
|
+ |
Self::Git(_) => "GitExecutionError",
|
|
175
|
+ |
Self::Output(_) => "OutputError",
|
|
176
|
+ |
Self::ComputerAlreadyPaired(_) => "ComputerAlreadyPaired",
|
|
177
|
+ |
Self::ComputerPairingInProgress(_) => "ComputerPairingInProgress",
|
|
178
|
+ |
Self::ComputerDisabled(_) => "ComputerDisabled",
|
|
179
|
+ |
Self::ComputerPairingExpired(_) => "ComputerPairingExpired",
|
|
180
|
+ |
Self::ComputerPairingRefused(_) => "ComputerPairingRefused",
|
|
181
|
+ |
Self::ComputerPairingNetworkFailure(_) => "ComputerPairingNetworkFailure",
|
|
182
|
+ |
Self::ComputerStatusNetworkFailure(_) => "ComputerStatusNetworkFailure",
|
|
183
|
+ |
Self::ComputerMachineUnavailable(_) => "ComputerMachineUnavailable",
|
|
184
|
+ |
Self::ComputerMachineMismatch(_) => "ComputerMachineMismatch",
|
|
185
|
+ |
Self::ComputerReconnectExhausted(_) => "ComputerReconnectExhausted",
|
|
186
|
+ |
Self::DeploymentFailed(_) => "DeploymentFailed",
|
|
187
|
+ |
Self::DeploymentWaitTimeout(_) => "DeploymentWaitTimeout",
|
|
188
|
+ |
Self::DeploymentRollingReplaceRequired(_) => "DeploymentRollingReplaceRequired",
|
|
189
|
+ |
Self::Internal(_) => "InternalError",
|
|
190
|
+ |
}
|
|
191
|
+ |
}
|
|
192
|
+ |
|
|
193
|
+ |
/// The request id, when the failure is one the server answered and
|
|
194
|
+ |
/// labelled. `requestIdFor` in `errors.ts` publishes it only for an API
|
|
195
|
+ |
/// refusal, and a made-up id would be worse than none.
|
|
196
|
+ |
pub fn request_id(&self) -> Option<&str> {
|
|
197
|
+ |
match self {
|
|
198
|
+ |
Self::Api { request_id, .. } => request_id.as_deref(),
|
|
199
|
+ |
_ => None,
|
|
200
|
+ |
}
|
|
201
|
+ |
}
|
|
202
|
+ |
|
|
203
|
+ |
/// The sentence a person reads.
|
|
204
|
+ |
pub fn message(&self) -> &str {
|
|
205
|
+ |
match self {
|
|
206
|
+ |
Self::Input(message)
|
|
207
|
+ |
| Self::Configuration(message)
|
|
208
|
+ |
| Self::AuthenticationRequired(message)
|
|
209
|
+ |
| Self::CredentialStore(message)
|
|
210
|
+ |
| Self::Network(message)
|
|
211
|
+ |
| Self::Contract(message)
|
|
212
|
+ |
| Self::Import(message)
|
|
213
|
+ |
| Self::Provisioning(message)
|
|
214
|
+ |
| Self::Git(message)
|
|
215
|
+ |
| Self::Output(message)
|
|
216
|
+ |
| Self::ComputerAlreadyPaired(message)
|
|
217
|
+ |
| Self::ComputerPairingInProgress(message)
|
|
218
|
+ |
| Self::ComputerDisabled(message)
|
|
219
|
+ |
| Self::ComputerPairingExpired(message)
|
|
220
|
+ |
| Self::ComputerPairingRefused(message)
|
|
221
|
+ |
| Self::ComputerPairingNetworkFailure(message)
|
|
222
|
+ |
| Self::ComputerStatusNetworkFailure(message)
|
|
223
|
+ |
| Self::ComputerMachineUnavailable(message)
|
|
224
|
+ |
| Self::ComputerMachineMismatch(message)
|
|
225
|
+ |
| Self::ComputerReconnectExhausted(message)
|
|
226
|
+ |
| Self::DeploymentFailed(message)
|
|
227
|
+ |
| Self::DeploymentWaitTimeout(message)
|
|
228
|
+ |
| Self::DeploymentRollingReplaceRequired(message)
|
|
229
|
+ |
| Self::Internal(message)
|
|
230
|
+ |
| Self::Api { message, .. } => message,
|
|
231
|
+ |
}
|
|
232
|
+ |
}
|
|
233
|
+ |
|
|
234
|
+ |
/// The envelope, exactly as `main.ts` builds it: `code`, `message`,
|
|
235
|
+ |
/// `exit_code`, and `request_id` only when there is one.
|
|
236
|
+ |
pub fn envelope(&self) -> serde_json::Value {
|
|
237
|
+ |
let mut object = serde_json::Map::new();
|
|
238
|
+ |
object.insert("code".to_string(), self.code().into());
|
|
239
|
+ |
object.insert("message".to_string(), self.message().into());
|
|
240
|
+ |
object.insert("exit_code".to_string(), self.exit_code().into());
|
|
241
|
+ |
if let Some(id) = self.request_id() {
|
|
242
|
+ |
object.insert("request_id".to_string(), id.into());
|
|
243
|
+ |
}
|
|
244
|
+ |
serde_json::Value::Object(object)
|
|
245
|
+ |
}
|
|
246
|
+ |
}
|
|
247
|
+ |
|
|
248
|
+ |
impl std::fmt::Display for CliError {
|
|
249
|
+ |
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
250
|
+ |
f.write_str(self.message())
|
|
251
|
+ |
}
|
|
252
|
+ |
}
|
|
253
|
+ |
|
|
254
|
+ |
impl std::error::Error for CliError {}
|
|
255
|
+ |
|
|
256
|
+ |
/// `CamelCase` to `snake_case`, matching the `replaceAll(/([a-z])([A-Z])/gu)`
|
|
257
|
+ |
/// in `errorCode`. That regex inserts a separator only between a lower and an
|
|
258
|
+ |
/// upper, so a run of capitals stays together the way it does there.
|
|
259
|
+ |
fn snake_case(tag: &str) -> String {
|
|
260
|
+ |
let mut out = String::with_capacity(tag.len() + 4);
|
|
261
|
+ |
let mut previous_lower = false;
|
|
262
|
+ |
for character in tag.chars() {
|
|
263
|
+ |
if previous_lower && character.is_ascii_uppercase() {
|
|
264
|
+ |
out.push('_');
|
|
265
|
+ |
}
|
|
266
|
+ |
previous_lower = character.is_ascii_lowercase();
|
|
267
|
+ |
out.push(character.to_ascii_lowercase());
|
|
268
|
+ |
}
|
|
269
|
+ |
out
|
|
270
|
+ |
}
|
|
271
|
+ |
|
|
272
|
+ |
/// Report the failure and exit with its code.
|
|
273
|
+ |
///
|
|
274
|
+ |
/// Under `--json` the envelope goes to stdout, because that is where a
|
|
275
|
+ |
/// consumer that asked for JSON is reading and the TypeScript CLI writes it
|
|
276
|
+ |
/// there. Otherwise the sentence goes to stderr, so a body piped to `jq`
|
|
277
|
+ |
/// stays parseable.
|
|
278
|
+ |
pub fn fail(error: &CliError) -> ! {
|
|
279
|
+ |
if json() {
|
|
280
|
+ |
// `to_string`, not `to_string_pretty`: one document per line is what
|
|
281
|
+ |
// an NDJSON consumer needs, and it is what `JSON.stringify` produces.
|
|
282
|
+ |
println!("{}", serde_json::Value::to_string(&error.envelope()));
|
|
283
|
+ |
} else {
|
|
284
|
+ |
eprintln!("oa: {}", error.message());
|
|
285
|
+ |
}
|
|
286
|
+ |
std::process::exit(error.exit_code())
|
|
287
|
+ |
}
|
|
288
|
+ |
|
|
289
|
+ |
impl From<crate::tracker::ApiError> for CliError {
|
|
290
|
+ |
fn from(error: crate::tracker::ApiError) -> Self {
|
|
291
|
+ |
use crate::tracker::ApiError;
|
|
292
|
+ |
// The rendered sentence is kept rather than the bare server message:
|
|
293
|
+ |
// it names the operation and the status, which is strictly more than
|
|
294
|
+ |
// the TypeScript CLI prints and is not a parity break. What the
|
|
295
|
+ |
// envelope's `code` and `exit_code` say is the part a machine reads,
|
|
296
|
+ |
// and that is the part this classification fixes.
|
|
297
|
+ |
let message = error.to_string();
|
|
298
|
+ |
match error {
|
|
299
|
+ |
ApiError::Transport { .. } => Self::Network(message),
|
|
300
|
+ |
ApiError::Malformed { .. } => Self::Contract(message),
|
|
301
|
+ |
ApiError::Input(_) => Self::Input(message),
|
|
302
|
+ |
// Only the fleet client produces this today, and `run_deploy`
|
|
303
|
+ |
// relabels it as `DeploymentWaitTimeout` so it lands on rung 18.
|
|
304
|
+ |
// A caller that adds a second producer without relabelling gets
|
|
305
|
+ |
// rung 6, which says "the CLI never got an answer" — true of a
|
|
306
|
+ |
// timeout, and never mistaken for a failed deployment.
|
|
307
|
+ |
ApiError::Timeout { .. } => Self::Network(message),
|
|
308
|
+ |
ApiError::Refused {
|
|
309
|
+ |
status,
|
|
310
|
+ |
code,
|
|
311
|
+ |
request_id,
|
|
312
|
+ |
..
|
|
313
|
+ |
} => Self::Api {
|
|
314
|
+ |
status,
|
|
315
|
+ |
code,
|
|
316
|
+ |
message,
|
|
317
|
+ |
request_id,
|
|
318
|
+ |
},
|
|
319
|
+ |
}
|
|
320
|
+ |
}
|
|
321
|
+ |
}
|
|
322
|
+ |
|
|
323
|
+ |
impl From<crate::auth::AuthError> for CliError {
|
|
324
|
+ |
/// A credential the CLI could not read, write, or refresh. `errors.ts`
|
|
325
|
+ |
/// puts `CredentialStoreError` and `CredentialPersistenceUnavailable` on
|
|
326
|
+ |
/// rung 3 alongside `AuthenticationRequired`, because to a caller they are
|
|
327
|
+ |
/// the same problem: this run has no usable credential.
|
|
328
|
+ |
///
|
|
329
|
+ |
/// `AuthError` is one undifferentiated newtype, so it cannot separate
|
|
330
|
+ |
/// those three. A *configuration* failure — an unusable `--api-url`, say —
|
|
331
|
+ |
/// is refused through `cli::fail` before a store is opened, and keeps
|
|
332
|
+ |
/// rung 2 where it belongs.
|
|
333
|
+ |
fn from(error: crate::auth::AuthError) -> Self {
|
|
334
|
+ |
Self::CredentialStore(error.to_string())
|
|
335
|
+ |
}
|
|
336
|
+ |
}
|
|
337
|
+ |
|
|
338
|
+ |
/// A message with no class of its own is an input error, which is the status
|
|
339
|
+ |
/// every one of these already exited with. Nothing here is reclassified by
|
|
340
|
+ |
/// accident: a failure only moves off rung 2 when something gives it a type.
|
|
341
|
+ |
impl From<String> for CliError {
|
|
342
|
+ |
fn from(message: String) -> Self {
|
|
343
|
+ |
Self::Input(message)
|
|
344
|
+ |
}
|
|
345
|
+ |
}
|
|
346
|
+ |
|
|
347
|
+ |
impl From<&str> for CliError {
|
|
348
|
+ |
fn from(message: &str) -> Self {
|
|
349
|
+ |
Self::Input(message.to_string())
|
|
350
|
+ |
}
|
|
351
|
+ |
}
|
|
352
|
+ |
|
|
353
|
+ |
impl From<crate::forum::ForumError> for CliError {
|
|
354
|
+ |
fn from(error: crate::forum::ForumError) -> Self {
|
|
355
|
+ |
use crate::forum::ForumError;
|
|
356
|
+ |
let message = error.to_string();
|
|
357
|
+ |
match error {
|
|
358
|
+ |
ForumError::Transport(_) => Self::Network(message),
|
|
359
|
+ |
ForumError::Malformed(_) => Self::Contract(message),
|
|
360
|
+ |
ForumError::Refused { status, .. } => Self::Api {
|
|
361
|
+ |
status,
|
|
362
|
+ |
code: None,
|
|
363
|
+ |
message,
|
|
364
|
+ |
request_id: None,
|
|
365
|
+ |
},
|
|
366
|
+ |
}
|
|
367
|
+ |
}
|
|
368
|
+ |
}
|