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
47 changes: 47 additions & 0 deletions src/bcli/exit_codes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Canonical bcli exit codes — AIP §Phase 4 taxonomy, seeded in Phase 2.

Phase 2 needs ``EXIT_REMOTE_4XX`` and ``EXIT_REMOTE_5XX`` for the result
envelope's ``exit_code`` field on failure. Phase 4 (Worker A) will wire
the rest into the CLI so ``bcli`` exits with the documented status.

Codes match the contract doc:

* ``0`` — success
* ``1`` — uncategorised failure (default for exceptions we can't map)
* ``2`` — usage error (wrong flag combination, invalid argument)
* ``3`` — auth failure
* ``4`` — not found
* ``5`` — input validation (client-side)
* ``6`` — remote 4xx
* ``7`` — remote 5xx
* ``8`` — policy violation (e.g. ``disable_writes`` triggered without ``--yes``)

Importing the constants keeps test assertions and CLI plumbing in lock-step.
"""

from __future__ import annotations

EXIT_OK = 0
EXIT_GENERIC_ERROR = 1
EXIT_USAGE = 2
EXIT_AUTH = 3
EXIT_NOT_FOUND = 4
EXIT_VALIDATION = 5
EXIT_REMOTE_4XX = 6
EXIT_REMOTE_5XX = 7
EXIT_POLICY = 8


def exit_code_for_status(status_code: int | None) -> int:
"""Map an HTTP status to a CLI exit code.

Falls back to ``EXIT_GENERIC_ERROR`` when ``status_code`` is ``None``
or doesn't fall in the 4xx/5xx range.
"""
if status_code is None:
return EXIT_GENERIC_ERROR
if 400 <= status_code < 500:
return EXIT_REMOTE_4XX
if 500 <= status_code < 600:
return EXIT_REMOTE_5XX
return EXIT_GENERIC_ERROR
110 changes: 110 additions & 0 deletions src/bcli/result_envelope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""Mutation result envelope (AIP v0.1 §Phase 2).

The envelope is a single JSON object an agent runtime can consume on a
side channel — never on stdout. Every mutating CLI verb (`post`, `patch`,
`delete`, `attach upload`, `batch run`) emits one envelope per invocation
when the user passes ``--result-out PATH`` or ``--result-fd N``.

Path mode writes atomically (tmp + ``os.replace``) so a SIGKILL between
write and rename never leaves a half-written file on the documented
output path. Fd mode writes the JSON object then closes the descriptor
so a pipe reader can see EOF.

Stdout output is untouched: the existing ``--format`` flag still drives
whatever the human/CSV/JSON dump looks like. The envelope is the
*attestation* (profile, target, correlation id, outcome), not the
response body.
"""

from __future__ import annotations

import json
import os
import tempfile
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Optional

ENVELOPE_VERSION = "0.1"


@dataclass(frozen=True)
class ResultEnvelope:
"""One mutation, attested.

Fields mirror the contract doc table in §Phase 2. ``record_id`` is
extracted from the response body on success (``systemId``/``id``/first
``*Id``) when available, ``None`` otherwise. ``telemetry_event_id`` and
``audit_log_offset`` are currently always ``None`` — wiring them needs
a protocol extension on the telemetry + audit sinks, deferred to a
follow-up so this PR stays additive.
"""

version: str
invocation_id: str
tool_version: str
profile: str | None
environment: str | None
company: str | None
method: str
endpoint: str
resolved_url: str | None
record_id: str | None
dry_run: bool
status: str # "succeeded" | "failed"
exit_code: int
bc_correlation_id: str | None
telemetry_event_id: str | None # TODO: wire via TelemetrySink follow-up
audit_log_offset: int | None # TODO: wire via AuditSink follow-up
started_at: str # ISO 8601 UTC
duration_ms: int


def write_envelope(
envelope: ResultEnvelope,
*,
path: Optional[Path] = None,
fd: Optional[int] = None,
) -> None:
"""Serialize ``envelope`` to ``path`` (atomic) or ``fd`` (write+close).

Exactly one of ``path`` / ``fd`` must be provided. Path mode creates
parent directories if missing and uses ``os.replace`` for atomicity.
Fd mode writes the JSON and closes the descriptor so a downstream pipe
reader sees EOF.
"""
if path is not None and fd is not None:
raise ValueError("write_envelope: pass either path or fd, not both")
if path is None and fd is None:
raise ValueError("write_envelope: must pass either path or fd")

payload = json.dumps(asdict(envelope), default=str, indent=2)

if path is not None:
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
# NamedTemporaryFile in the same dir so os.replace is on one FS.
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=str(target.parent),
prefix=target.name + ".",
suffix=".tmp",
delete=False,
) as tmp:
tmp.write(payload)
tmp.flush()
os.fsync(tmp.fileno())
tmp_name = tmp.name
os.replace(tmp_name, str(target))
return

# fd path
assert fd is not None
try:
os.write(fd, payload.encode("utf-8"))
finally:
os.close(fd)


__all__ = ["ENVELOPE_VERSION", "ResultEnvelope", "write_envelope"]
Loading
Loading