Skip to main content

stackless_core/
fault.rs

1//! The agent-facing error contract (ARCHITECTURE.md §2).
2//!
3//! Every error stackless emits carries three parts: *what* failed (the
4//! step and instance), *why* (the observed cause), and *how to proceed*
5//! (a concrete command, flag, or fix). Agents branch on stable codes,
6//! never on prose.
7
8use serde::Serialize;
9
10/// Lines of captured output included in failure context (hooks, health,
11/// prepare). Aligns with `stackless logs --tail` defaults for operators.
12pub const FAILURE_LOG_TAIL_LINES: usize = 80;
13
14/// Factual observables agents use to locate and inspect a failure.
15#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
16pub struct ErrorContext {
17    pub service: Option<String>,
18    pub hook: Option<String>,
19    pub command: Option<String>,
20    pub source_dir: Option<String>,
21    pub log_path: Option<String>,
22    pub log_hint: Option<String>,
23    pub exit_status: Option<String>,
24    pub log_tail: Option<String>,
25}
26
27/// Stable machine-readable error codes.
28///
29/// Codes are versioned API surface: renaming one is a breaking change
30/// (ARCHITECTURE.md §2). Every code lives here, in one registry, so the
31/// full surface is greppable and uniqueness is testable.
32pub mod codes {
33    pub const DEF_PARSE_SYNTAX: &str = "def.parse.syntax";
34    pub const DEF_PARSE_SCHEMA: &str = "def.parse.schema";
35    pub const DEF_NAME_INVALID: &str = "def.validate.name_invalid";
36    pub const DEF_NO_SERVICES: &str = "def.validate.no_services";
37    pub const DEF_UNKNOWN_KEY: &str = "def.validate.unknown_key";
38    pub const DEF_DEPENDS_ON_REJECTED: &str = "def.validate.depends_on_rejected";
39    pub const DEF_SUBSTRATE_BLOCK_INVALID: &str = "def.validate.substrate_block_invalid";
40    pub const DEF_SUBSTRATE_CONFIG_MISSING: &str = "def.validate.substrate_config_missing";
41    pub const DEF_ROOT_ORIGIN_CONFLICT: &str = "def.validate.root_origin_conflict";
42    pub const DEF_REFERENCE_SYNTAX: &str = "def.validate.reference_syntax";
43    pub const DEF_UNDECLARED_REFERENCE: &str = "def.validate.undeclared_reference";
44    pub const DEF_SECRET_NOT_REQUIRED: &str = "def.validate.secret_not_required";
45    pub const DEF_INTEGRATION_INVALID: &str = "def.validate.integration_invalid";
46    pub const DEF_WIRING_CYCLE: &str = "def.validate.wiring_cycle";
47    pub const DEF_ENV_NOT_STRINGS: &str = "def.validate.env_not_strings";
48    pub const CLI_FILE_READ: &str = "cli.file.read";
49    pub const CLI_FILE_MISSING: &str = "cli.file.missing";
50    pub const CLI_SUBSTRATE_UNKNOWN: &str = "cli.substrate.unknown";
51    pub const STATE_OPEN: &str = "state.open_failed";
52    pub const STATE_MIGRATE: &str = "state.migrate_failed";
53    pub const STATE_QUERY: &str = "state.query_failed";
54    pub const STATE_INSTANCE_EXISTS: &str = "state.instance.exists";
55    pub const STATE_INSTANCE_NOT_FOUND: &str = "state.instance.not_found";
56    pub const STATE_LOCK_HELD: &str = "state.lock.held";
57    pub const STATE_GC_FAILED: &str = "state.gc_failed";
58    pub const STATE_REMOTE_OPEN: &str = "state.remote.open_failed";
59    pub const STATE_REMOTE_QUERY: &str = "state.remote.query_failed";
60    pub const STATE_REMOTE_RUNTIME: &str = "state.remote.runtime_failed";
61    pub const STATE_REMOTE_WORKER: &str = "state.remote.worker_gone";
62    pub const STATE_ROW_DECODE: &str = "state.row.decode_failed";
63    pub const ENGINE_SUBSTRATE_MISMATCH: &str = "engine.substrate.mismatch";
64    pub const ENGINE_SUBSTRATE_REQUIRED: &str = "engine.substrate.required";
65    pub const ENGINE_SOURCE_OVERRIDE_UNSUPPORTED: &str = "engine.source_override.unsupported";
66    pub const ENGINE_SOURCE_OVERRIDE_SHARED: &str = "engine.source_override.shared";
67    pub const ENGINE_STEP_FAILED: &str = "engine.step.failed";
68    pub const ENGINE_TEARDOWN_SURVIVORS: &str = "engine.teardown.survivors";
69    pub const DAEMON_UNREACHABLE: &str = "daemon.unreachable";
70    pub const DAEMON_REQUEST_FAILED: &str = "daemon.request_failed";
71    pub const DAEMON_SPAWN_FAILED: &str = "daemon.spawn_failed";
72    pub const DAEMON_BINARY_NOT_FOUND: &str = "daemon.binary_not_found";
73    pub const CLI_RUNTIME: &str = "cli.runtime";
74    pub const LOCAL_MATERIALIZE_UNAVAILABLE: &str = "local.materialize.unavailable";
75    pub const LOCAL_SOURCE_PATH_INVALID: &str = "local.source_path.invalid";
76    pub const LOCAL_CONFIG_INVALID: &str = "local.config.invalid";
77    pub const LOCAL_PORT_ALLOC: &str = "local.port.alloc_failed";
78    pub const LOCAL_LOG_FILE: &str = "local.log_file";
79    pub const LOCAL_SPAWN_FAILED: &str = "local.spawn_failed";
80    pub const LOCAL_HOOK_FAILED: &str = "local.hook_failed";
81    pub const LOCAL_HEALTH_FAILED: &str = "local.health_failed";
82    pub const LOCAL_SERVICE_DIED: &str = "local.service_died";
83    pub const LOCAL_ENV_RESOLVE: &str = "local.env_resolve";
84    pub const LOCAL_KILL_FAILED: &str = "local.kill_failed";
85    pub const LOCAL_GIT_CLONE_FAILED: &str = "local.git.clone_failed";
86    pub const LOCAL_GIT_FETCH_FAILED: &str = "local.git.fetch_failed";
87    pub const LOCAL_GIT_REF_NOT_FOUND: &str = "local.git.ref_not_found";
88    pub const LOCAL_GIT_CHECKOUT_FAILED: &str = "local.git.checkout_failed";
89    pub const CLI_BAD_ARGUMENT: &str = "cli.bad_argument";
90    pub const CLI_FILE_WRITE: &str = "cli.file.write";
91    pub const CLI_INIT_EXISTS: &str = "cli.init.exists";
92    pub const CLI_INIT_NAME_INVALID: &str = "cli.init.name_invalid";
93    pub const CLI_ADOPT_EXISTS: &str = "cli.adopt.exists";
94    pub const CLI_ADOPT_INSPECT: &str = "cli.adopt.inspect";
95    pub const CLI_SELF_UPDATE: &str = "cli.self_update";
96    pub const DOCTOR_CHECKS_FAILED: &str = "doctor.checks.failed";
97    /// Legacy local Docker datastore teardown (pre-`[datastores.*]` removal).
98    pub const LOCAL_DOCKER_ENGINE: &str = "local.docker.engine";
99    pub const LOCAL_DATASTORE_FAILED: &str = "local.datastore.failed";
100    pub const SECRETS_UNRESOLVED: &str = "secrets.unresolved";
101    pub const VERIFY_FAILED: &str = "verify.failed";
102    pub const VERIFY_NOT_DECLARED: &str = "verify.not_declared";
103    pub const VERIFY_SOURCE_UNAVAILABLE: &str = "verify.source_unavailable";
104    pub const VERIFY_TIER_UNKNOWN: &str = "verify.tier_unknown";
105    pub const VERIFY_TIER_REQUIRED: &str = "verify.tier_required";
106    pub const STRIPE_PROJECTS_UNAVAILABLE: &str = "stripe.projects.unavailable";
107    pub const STRIPE_PROJECTS_AUTH: &str = "stripe.projects.auth";
108    pub const STRIPE_PROJECTS_FAILED: &str = "stripe.projects.failed";
109    pub const STRIPE_PROJECTS_LOCK_HELD: &str = "stripe.projects.lock_held";
110    pub const STRIPE_PROJECTS_TIMEOUT: &str = "stripe.projects.timeout";
111    pub const STRIPE_PROJECT_ANCHOR: &str = "stripe.project.anchor";
112    pub const STRIPE_PROJECTS_PROVISION_FAILED: &str = "stripe.projects.provision_failed";
113    pub const STRIPE_PROJECTS_CATALOG_MISSING: &str = "stripe.projects.catalog_missing";
114    pub const STRIPE_PROJECTS_CONFIG_SCHEMA: &str = "stripe.projects.config_schema";
115    pub const INTEGRATION_CONFIG_INVALID: &str = "integration.config.invalid";
116    pub const INTEGRATION_HOST_UNSUPPORTED: &str = "integration.host.unsupported";
117
118    /// Every core code, for uniqueness tests. Substrate codes live in their
119    /// own crates (`stackless_render::codes`, …); the binary aggregates them
120    /// with these for a workspace-wide uniqueness check.
121    pub const ALL: &[&str] = &[
122        DEF_PARSE_SYNTAX,
123        DEF_PARSE_SCHEMA,
124        DEF_NAME_INVALID,
125        DEF_NO_SERVICES,
126        DEF_UNKNOWN_KEY,
127        DEF_DEPENDS_ON_REJECTED,
128        DEF_SUBSTRATE_BLOCK_INVALID,
129        DEF_SUBSTRATE_CONFIG_MISSING,
130        DEF_ROOT_ORIGIN_CONFLICT,
131        DEF_REFERENCE_SYNTAX,
132        DEF_UNDECLARED_REFERENCE,
133        DEF_SECRET_NOT_REQUIRED,
134        DEF_INTEGRATION_INVALID,
135        DEF_WIRING_CYCLE,
136        DEF_ENV_NOT_STRINGS,
137        CLI_FILE_READ,
138        CLI_FILE_MISSING,
139        CLI_SUBSTRATE_UNKNOWN,
140        STATE_OPEN,
141        STATE_MIGRATE,
142        STATE_QUERY,
143        STATE_INSTANCE_EXISTS,
144        STATE_INSTANCE_NOT_FOUND,
145        STATE_LOCK_HELD,
146        STATE_GC_FAILED,
147        STATE_REMOTE_OPEN,
148        STATE_REMOTE_QUERY,
149        STATE_REMOTE_RUNTIME,
150        STATE_REMOTE_WORKER,
151        STATE_ROW_DECODE,
152        ENGINE_SUBSTRATE_MISMATCH,
153        ENGINE_SUBSTRATE_REQUIRED,
154        ENGINE_SOURCE_OVERRIDE_UNSUPPORTED,
155        ENGINE_SOURCE_OVERRIDE_SHARED,
156        ENGINE_STEP_FAILED,
157        ENGINE_TEARDOWN_SURVIVORS,
158        DAEMON_UNREACHABLE,
159        DAEMON_REQUEST_FAILED,
160        DAEMON_SPAWN_FAILED,
161        DAEMON_BINARY_NOT_FOUND,
162        CLI_RUNTIME,
163        LOCAL_MATERIALIZE_UNAVAILABLE,
164        LOCAL_SOURCE_PATH_INVALID,
165        LOCAL_CONFIG_INVALID,
166        LOCAL_PORT_ALLOC,
167        LOCAL_LOG_FILE,
168        LOCAL_SPAWN_FAILED,
169        LOCAL_HOOK_FAILED,
170        LOCAL_HEALTH_FAILED,
171        LOCAL_SERVICE_DIED,
172        LOCAL_ENV_RESOLVE,
173        LOCAL_KILL_FAILED,
174        LOCAL_GIT_CLONE_FAILED,
175        LOCAL_GIT_FETCH_FAILED,
176        LOCAL_GIT_REF_NOT_FOUND,
177        LOCAL_GIT_CHECKOUT_FAILED,
178        CLI_BAD_ARGUMENT,
179        CLI_FILE_WRITE,
180        CLI_INIT_EXISTS,
181        CLI_INIT_NAME_INVALID,
182        CLI_ADOPT_EXISTS,
183        CLI_ADOPT_INSPECT,
184        CLI_SELF_UPDATE,
185        DOCTOR_CHECKS_FAILED,
186        LOCAL_DOCKER_ENGINE,
187        LOCAL_DATASTORE_FAILED,
188        SECRETS_UNRESOLVED,
189        VERIFY_FAILED,
190        VERIFY_NOT_DECLARED,
191        VERIFY_SOURCE_UNAVAILABLE,
192        VERIFY_TIER_UNKNOWN,
193        VERIFY_TIER_REQUIRED,
194        STRIPE_PROJECTS_UNAVAILABLE,
195        STRIPE_PROJECTS_AUTH,
196        STRIPE_PROJECTS_FAILED,
197        STRIPE_PROJECTS_LOCK_HELD,
198        STRIPE_PROJECTS_TIMEOUT,
199        STRIPE_PROJECT_ANCHOR,
200        STRIPE_PROJECTS_PROVISION_FAILED,
201        STRIPE_PROJECTS_CATALOG_MISSING,
202        STRIPE_PROJECTS_CONFIG_SCHEMA,
203        INTEGRATION_CONFIG_INVALID,
204        INTEGRATION_HOST_UNSUPPORTED,
205    ];
206}
207
208/// Implemented by every error enum in every stackless crate.
209///
210/// A new error variant is only complete when its remediation text says
211/// what the operator should actually do (ARCHITECTURE.md §8).
212pub trait Fault: std::error::Error {
213    /// The stable machine-readable code from [`codes`].
214    fn code(&self) -> &'static str;
215    /// How to proceed: a concrete command, flag, or fix.
216    fn remediation(&self) -> String;
217    /// The lifecycle step that failed, when one was executing.
218    fn step(&self) -> Option<&str> {
219        None
220    }
221    /// The instance the failure belongs to, when there is one.
222    fn instance(&self) -> Option<&str> {
223        None
224    }
225    /// Structured observables for agents (`command`, `log_path`, `log_tail`, …).
226    fn context(&self) -> ErrorContext {
227        ErrorContext::default()
228    }
229}
230
231/// The serialized error shape agents consume in `--json` mode.
232#[derive(Debug, Serialize)]
233pub struct Report {
234    pub schema_version: u32,
235    pub code: &'static str,
236    pub message: String,
237    #[serde(skip_serializing_if = "Option::is_none")]
238    pub step: Option<String>,
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub instance: Option<String>,
241    pub remediation: String,
242    pub context: ErrorContext,
243}
244
245impl Report {
246    pub fn from_fault(fault: &dyn Fault) -> Self {
247        Self {
248            schema_version: 1,
249            code: fault.code(),
250            message: fault.to_string(),
251            step: fault.step().map(str::to_owned),
252            instance: fault.instance().map(str::to_owned),
253            remediation: fault.remediation(),
254            context: fault.context(),
255        }
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::{ErrorContext, Report, codes};
262    use crate::substrate::SubstrateFault;
263    use std::collections::BTreeSet;
264
265    #[test]
266    fn codes_are_unique() {
267        let set: BTreeSet<_> = codes::ALL.iter().collect();
268        assert_eq!(set.len(), codes::ALL.len());
269    }
270
271    #[test]
272    fn report_serializes_schema_version_and_context() {
273        let fault = SubstrateFault {
274            code: codes::LOCAL_HOOK_FAILED,
275            message: "setup hook exited with exit status: 1".into(),
276            remediation: "re-run up".into(),
277            context: Box::new(ErrorContext {
278                service: Some("web".into()),
279                hook: Some("setup".into()),
280                command: Some("mise install".into()),
281                source_dir: Some("/tmp/web".into()),
282                log_path: Some("/tmp/logs/web.log".into()),
283                exit_status: Some("exit status: 1".into()),
284                log_tail: Some("error: trust denied".into()),
285                ..ErrorContext::default()
286            }),
287        };
288        let report = Report::from_fault(&fault);
289        assert_eq!(report.schema_version, 1);
290        assert_eq!(report.context.service.as_deref(), Some("web"));
291        let json = serde_json::to_value(&report).unwrap();
292        assert_eq!(json["schema_version"], 1);
293        assert_eq!(json["context"]["command"], "mise install");
294        assert_eq!(json["context"]["log_tail"], "error: trust denied");
295    }
296}