Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
bf64f4e
feat(server)!: assert the expected actor with omnigraph-expect-actor
ragnorc Oct 18, 2026
5588898
feat(server)!: say on every error body whether the request ran
ragnorc Oct 18, 2026
71dc46f
feat(cli)!: exit with what a failed command's outcome says
ragnorc Oct 18, 2026
2fff68e
fix(server,cli): send an actor assertion only to a server that enforc…
ragnorc Oct 18, 2026
b19f6c2
fix(cli): keep a classified exit when its failure cannot be delivered
ragnorc Oct 18, 2026
8a4288a
docs(cli,server): describe `--as` as the assertion it now is
ragnorc Oct 18, 2026
5e46f92
fix(server): keep every error's code and outcome exact
ragnorc Oct 19, 2026
e5d4747
Merge branch 'feat/expect-actor' into feat/error-outcome
ragnorc Oct 19, 2026
04a9da5
Merge feat/error-outcome into feat/cli-exit-table; project embedded r…
ragnorc Oct 19, 2026
23027f5
docs(server): keep the errors paragraph to its original length
ragnorc Oct 19, 2026
d5cda7d
Merge branch 'feat/error-outcome' into feat/cli-exit-table
ragnorc Oct 19, 2026
76cc941
fix(server,cli)!: fence the actor assertion with HTTP contract 0.14
ragnorc Oct 19, 2026
722a104
Merge feat/expect-actor into feat/error-outcome
ragnorc Oct 19, 2026
1abd5bb
fix(engine,server): let the engine say a load forked before its rows …
ragnorc Oct 19, 2026
9467f16
Merge feat/error-outcome into feat/cli-exit-table
ragnorc Oct 19, 2026
229c6c2
test(cli): pin that an embedded load reports its fork from the engine
ragnorc Oct 19, 2026
9666b14
Merge main into feat/expect-actor
ragnorc Oct 19, 2026
cbd4068
Merge feat/expect-actor into feat/error-outcome
ragnorc Oct 19, 2026
a344f23
Merge feat/error-outcome into feat/cli-exit-table
ragnorc Oct 19, 2026
78963e9
docs(api): describe a key conflict without table vocabulary
ragnorc Oct 19, 2026
0a11b0d
Merge feat/error-outcome into feat/cli-exit-table
ragnorc Oct 19, 2026
bf954ad
feat(server,cli)!: rename the actor assertion header to Omnigraph-If-…
ragnorc Oct 19, 2026
1b77b69
docs: name HTTP contract 0.14 on the deployment page
ragnorc Oct 19, 2026
7474e61
Merge branch 'main' into feat/expect-actor
ragnorc Oct 19, 2026
b13d90f
feat(server)!: rename the error outcome to disposition and say what t…
ragnorc Oct 19, 2026
6533ab5
fix(server): one code per status, documented
ragnorc Oct 19, 2026
0ff41ae
fix(server): answer deployment and MCP errors with the error body
ragnorc Oct 19, 2026
023daa7
fix(engine,server): keep a settled step's proof after an earlier publ…
ragnorc Oct 20, 2026
cdcf467
refactor(server): classify error details with an exhaustive match
ragnorc Oct 20, 2026
0e54ed2
Merge branch 'main' into feat/expect-actor
ragnorc Oct 20, 2026
5fbcd0e
Merge origin/main into feat/error-outcome
ragnorc Oct 20, 2026
0c8d35c
docs(server): say a code may be sent under two statuses
ragnorc Oct 20, 2026
503c523
Merge feat/error-outcome into feat/cli-exit-table
ragnorc Oct 20, 2026
8cd4fd5
test(cli): expect each refusal's own action in its disposition
ragnorc Oct 20, 2026
0432c9d
fix(server): describe the branch create refusals under contract 0.14
ragnorc Oct 20, 2026
a59a4c8
Merge fork/feat/expect-actor into feat/error-outcome
ragnorc Oct 20, 2026
ccdfdbd
test(cli): expect fix for a refused credential, contract or actor
ragnorc Oct 20, 2026
e5b11dd
Merge feat/error-outcome into feat/cli-exit-table
ragnorc Oct 20, 2026
e4a3e82
Merge origin/main into feat/expect-actor
ragnorc Oct 20, 2026
d37edc9
Merge feat/expect-actor into feat/error-outcome
ragnorc Oct 20, 2026
4353475
fix(cli): pass the asserted actor in the cluster remote test, keep pa…
ragnorc Oct 20, 2026
25cf087
Merge feat/expect-actor into feat/error-outcome
ragnorc Oct 20, 2026
3a35355
fix(server): answer a branch path's 405 with the error body
ragnorc Oct 20, 2026
93fc09c
Merge feat/error-outcome into feat/cli-exit-table
ragnorc Oct 20, 2026
bd3ee6a
fix(cli): exit a target refusal by its disposition and keep stdout on…
ragnorc Oct 20, 2026
21cea20
test(cli): expect a data command's target refusal to exit 1
ragnorc Oct 20, 2026
54f08e3
Merge branch 'main' into feat/expect-actor
ragnorc Oct 20, 2026
15eb669
fix(server): refuse an unserved method on a protected route behind it…
ragnorc Oct 20, 2026
bcf0d3d
Merge feat/error-outcome into feat/cli-exit-table
ragnorc Oct 20, 2026
16fe99b
Merge feat/expect-actor into feat/error-outcome
ragnorc Oct 20, 2026
40708ed
Merge origin/main into feat/error-outcome
ragnorc Oct 20, 2026
268ee91
fix(core): keep a step's proof when it is marked after an earlier pub…
ragnorc Oct 20, 2026
1f34ad5
docs: cover both 412 codes, both branch start statuses and a served --as
ragnorc Oct 20, 2026
1e67bca
Merge feat/error-outcome into feat/cli-exit-table
ragnorc Oct 21, 2026
8120709
Merge main up to #1105 into feat/cli-exit-table
ragnorc Oct 21, 2026
e3f9813
Merge main's squash of #1076 into feat/cli-exit-table, already contained
ragnorc Oct 21, 2026
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
3 changes: 3 additions & 0 deletions changelog.d/cli-exit-follows-outcome.breaking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
- **CLI exits follow the disposition.** For the data commands the CLI classifies (reads, data writes and aliases), a failure whose effects are unknown, or partial because an earlier step published, exits 2 instead of 1; one with no effects still exits 1, a busy refusal 75 and a failed condition 4, and a server refusal stating `effects: none` with `retry` now also exits 75. An embedded write's own refusal now carries the engine's disposition, as a server's does, so embedded load conflicts and failed conditions exit as served ones (1 and 4) instead of claiming unknown effects. A command line the argument parser refuses exits 64 instead of 2 (an addressing refusal inside a command still exits 1), and a failure whose diagnostic cannot be delivered keeps its exit. Maintenance commands, `cluster`, `policy`, `target`, `credential` and managed commands keep their own exits for now. Read commands, `alias` included, report the same failure envelope as data writes, with `disposition` (always `effects: none`) on stdout in JSON mode and on stderr in human mode. Managed commands keep their own exits for now. Update scripts that read exit 2 as a usage error or exit 1 as every failure. See [failed commands][cli-exit-outcomes].

[cli-exit-outcomes]: ../docs/user/operations/troubleshooting.md#failed-data-write-commands
97 changes: 85 additions & 12 deletions crates/omnigraph-cli/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ pub(crate) fn sync_dir(_dir: &std::path::Path) -> io::Result<()> {
}

/// Invoke the CLI without installing process hooks or terminating the caller.
/// Clap keeps its original output stream and exit status for help and refusals.
/// Clap keeps its original output stream for help and refusals; a refused
/// command line exits 64.
pub async fn run_from<I, T>(args: I) -> Result<Completion>
where
I: IntoIterator<Item = T>,
Expand All @@ -52,9 +53,14 @@ where
let parsed = match invocation::parse_from(args) {
Ok(parsed) => parsed,
Err(error) => {
// Match Clap's exit contract even when its selected stream is closed.
// Help and version succeed; every refused command line is a
// usage error, even when Clap's selected stream is closed.
let _ = error.print();
return Ok(Completion::exit(error.exit_code()));
return Ok(Completion::exit(if error.exit_code() == 0 {
0
} else {
crate::EXIT_USAGE
}));
}
};
let mut stdin = io::stdin();
Expand Down Expand Up @@ -83,34 +89,76 @@ async fn execute(
mut machine,
explicit_config,
} = parsed;
let classify = command_outcome::applies(&cli);
let observe = command_outcome::observes(&cli);
let kind = command_outcome::kind(&cli);
// A served cluster command reports its own failures; its requests are
// observed only so that a refused `--as` assertion exits as a condition.
let observe = kind.or_else(|| {
command_outcome::observed_unclassified(&cli).then_some(command_outcome::CommandKind::Write)
});
// Rendering runs inside observation. The completion path retains that
// same evidence when flushing, including after a dispatched write.
// The dispatch future contains every command variant. Keep that
// large state off reusable callers' stacks (including libtest threads).
let mut stdout = StartedOutput::new(stdout);
let invocation = Box::pin(run(
cli,
explicit_config,
operator,
&mut machine,
stdin,
stdin_is_terminal,
stdout,
&mut stdout,
stderr,
));
let (result, evidence) = if observe {
let (result, evidence) = admission::scope(command_outcome::observe(invocation)).await;
let (result, evidence) = if let Some(kind) = observe {
let (result, evidence) = admission::scope(command_outcome::observe(kind, invocation)).await;
(result, Some(evidence))
} else {
(admission::scope(invocation).await, None)
};
let (evidence, observed) = if classify {
let (evidence, observed) = if kind.is_some() {
(evidence, None)
} else {
(None, evidence)
};
finish(result, evidence, observed, machine, stdout, stderr)
let result_started = stdout.started;
finish(
result,
evidence,
observed,
machine,
result_started,
&mut stdout,
stderr,
)
}

/// A command's stdout, noting whether any of its result reached it. A failure
/// after part of a result goes to stderr, so stdout keeps a single result.
struct StartedOutput<'a> {
inner: &'a mut dyn Write,
started: bool,
}

impl<'a> StartedOutput<'a> {
fn new(inner: &'a mut dyn Write) -> Self {
Self {
inner,
started: false,
}
}
}

impl Write for StartedOutput<'_> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let written = self.inner.write(buf)?;
self.started |= written > 0;
Ok(written)
}

fn flush(&mut self) -> io::Result<()> {
self.inner.flush()
}
}

/// `evidence` classifies a data command's failure; `observed` only decides
Expand All @@ -120,6 +168,7 @@ fn finish(
evidence: Option<command_outcome::Evidence>,
observed: Option<command_outcome::Evidence>,
machine: Option<MachineErrors>,
result_started: bool,
stdout: &mut dyn Write,
stderr: &mut dyn Write,
) -> Result<Completion> {
Expand Down Expand Up @@ -161,12 +210,21 @@ fn finish(
.is_none_or(command_outcome::Evidence::has_no_effects)
{
let mut body = serde_json::json!({"error": refusal.to_string(), "code": refusal.code});
// A target refusal keeps its own exit on the target commands; on a
// classified command it exits as its disposition says, and it asks the
// caller to fix the destination the command named.
let mut exit = 2;
if let Some(evidence) = evidence {
let failure = command_outcome::Failure::classify(
color_eyre::eyre::eyre!(refusal.to_string()),
evidence,
);
body["disposition"] = serde_json::to_value(failure.disposition())?;
let mut disposition = failure.disposition();
if disposition.effects == omnigraph_api_types::DispositionEffects::None {
disposition.action = omnigraph_api_types::DispositionAction::Fix;
}
body["disposition"] = serde_json::to_value(disposition)?;
exit = failure.exit;
}
match machine {
Some(format) => {
Expand All @@ -178,12 +236,18 @@ fn finish(
stderr.flush()?;
}
}
return Ok(Completion::exit(2));
return Ok(Completion::exit(exit));
}
if let Some(evidence) = evidence {
let failure = command_outcome::Failure::classify(error, evidence);
let delivered = (|| -> Result<()> {
match machine {
// Part of the result already reached stdout, so the failure
// envelope goes to stderr and stdout keeps one result.
Some(format) if result_started => {
write_machine(&failure, format, stderr)?;
stderr.flush()?;
}
Some(format) => {
write_machine(&failure, format, stdout)?;
stdout.flush()?;
Expand Down Expand Up @@ -280,6 +344,15 @@ fn write_machine<T: Serialize>(
}
}

/// The exit for an error `run_from` returns: a classified failure that could
/// not be delivered keeps the exit its outcome chose, since a write may have
/// committed; every other error exits 1.
pub fn exit_code(error: &color_eyre::Report) -> i32 {
error
.downcast_ref::<UndeliveredFailure>()
.map_or(1, |undelivered| undelivered.failure.exit)
}

#[derive(Debug)]
struct UndeliveredFailure {
failure: command_outcome::Failure,
Expand Down
Loading
Loading