Skip to content

Commit db0eb4e

Browse files
committed
release: bump version to 0.11.0 and update dependencies; fix approval handler and health summary reporting
1 parent 826a56e commit db0eb4e

9 files changed

Lines changed: 446 additions & 47 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,49 @@ All notable changes to apcore-cli (Python SDK) will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.11.0] - 2026-09-02
9+
10+
Bumps the required `apcore` floor to `0.28.0` and `apcore-toolkit` to `0.10.2`, and fixes **three defects the 0.28.0 upgrade made reachable or visible**. Full suite: 815 passed, 5 xfailed (798 → 815; 17 new regression tests). Each new test was confirmed to fail against the pre-fix behaviour rather than merely to pass against the new one.
11+
12+
**Why a minor rather than a patch.** Every fix below restores behaviour that was already specified, but two of them change what a working consumer observes, and this ecosystem's rule — stated in apcore's own 0.28.0 release note — is that such a change "must ship as a **minor** (or major) version bump, never a patch". A script branching on exit code `1` from an `apcli` system command now sees `45`; a caller doing `handler.request_approval(...)["status"]` now gets a `TypeError`. Neither was correct behaviour, and both were reachable.
13+
14+
### Fixed
15+
16+
- **The `apcli health` summary line reported "no data" for a project whose modules it had just listed.** apcore classifies module health in **four** tiers — `healthy` / `degraded` / `error` / `unknown` — and the tally iterated only the first three. `unknown` means "no calls recorded yet", which is the state every module in a fresh project is in, so the common case rendered a populated table above a total that denied it:
17+
18+
```
19+
probe.echo unknown 0.0% --
20+
Summary: no data
21+
```
22+
23+
**Pre-existing, and not introduced by this upgrade** — all three SDKs have emitted `unknown` since the tier set existed. apcore 0.28.0 is what brought it into focus: `sys-health-summary.schema.json` had declared the enum as `["healthy", "degraded", "unhealthy"]`, a value **no SDK emits**, and the release corrects it to the four tiers actually produced, splitting the summary's `unhealthy` count field into `error` and `unknown`. With the canonical shape finally naming four tiers, rendering three is a plain omission. Fixed in all three SDKs together, with the tally now covering `unknown`; a genuinely empty tally still reads "no data".
24+
25+
- **The approval gate crashed on every gate-routed approval — `CliApprovalHandler` returned a mapping where the protocol requires an `ApprovalResult`.** apcore's `BuiltinApprovalGate` reads the handler's answer by attribute (`result.status`, `result.approved_by` in `builtin_steps.py`), so the handler's `{"status": "approved", ...}` dict raised `AttributeError` *inside* the gate and reached the caller as `MODULE_EXECUTE_ERROR`. Every one of the eight return paths was affected, so a module annotated `requires_approval: true` could not execute through the wired handler at all — including the `--yes` and `APCORE_CLI_AUTO_APPROVE=1` bypasses, which return early and therefore failed the same way.
26+
27+
It survived because nothing exercised it: no test in this SDK called `request_approval`, and the class was only reachable through `executor.set_approval_handler` in `factory.py`. apcore-cli-rust converts through `cli_to_apcore_result` and apcore-cli-typescript's `ApprovalResult` is a structurally-typed interface, so neither had the defect — this was a Python-only divergence from a shape both other SDKs got right.
28+
29+
**Pre-existing, but apcore 0.28.0 widens the blast radius.** Before 0.28.0 the gate fired only for a module whose *annotation* said `requires_approval: true`. Since spec v1.28.0 §6.9 the gate fires on the union of three sources, so an ACL rule carrying `approval: required` (§6.1.6) now routes calls to modules annotated `requires_approval: false` through the same broken path — the exact shape argument-scoped approval was added for (`git push --force` needs a human, `git push` does not).
30+
31+
`_approval_result()` now constructs `apcore.approval.ApprovalResult`, falling back to the mapping shape when apcore is not importable so the handler stays usable in isolation.
32+
33+
- **Every apcore error from an `apcli` system command exited 1 instead of its canonical code.** `exit_code_for_error` matched only on the CLI's own exception classes, none of which an apcore-raised `ModuleError` subclass is an instance of, so the wire code it carries was never read. `system_cmd._exit_on_system_error` documents the canonical taxonomy (44 module-not-found, 45 schema-validation, 46 approval-denied, 47 config-invalid, 77 ACL-denied) and delivered `1` for all of them. TS `exitCodeForError` falls through to a `codeMap` and Rust `map_module_error_to_exit_code` reads `err.code`; Python had the map — in `cli.py` — and never consulted it from this path.
34+
35+
**apcore 0.28.0 makes it newly reachable on a routine command.** `system.usage.summary` / `system.usage.module` now declare `"pattern": "^[1-9][0-9]*[hd]$"` on `period`, and 0.28.0 also stops `_DictSchemaAdapter.model_validate` being a pass-through, so dict-declared schemas are finally enforced. `apcore-cli apcli usage --period 0h` passes the flag through verbatim and now raises `SCHEMA_VALIDATION_ERROR` where it previously returned an empty window with exit 0 — and was reported as exit 1 rather than 45.
36+
37+
`APCORE_ERROR_CODE_MAP` moves to `exit_codes.py` as the single source of truth (`cli._ERROR_CODE_MAP` is now an alias, so the two copies cannot drift), gains the `DEPENDENCY_NOT_FOUND` / `DEPENDENCY_VERSION_MISMATCH` entries TS already had, and `exit_code_for_error` falls back to it.
38+
39+
### Changed
40+
41+
- **`apcore>=0.28.0`, `apcore-toolkit>=0.10.2`.** apcore-toolkit 0.10.2 is a dependency-tracking release with no source change; its stable surface consumed by the CLI (`format_*`, `DisplayResolver`, `BindingLoader`, `RegistryWriter`) is unchanged.
42+
43+
- **Exit-code map parity pinned across the three SDKs.** A mechanical three-way diff of the maps found `DEPENDENCY_NOT_FOUND` and `DEPENDENCY_VERSION_MISMATCH` mapped to 44 here and in the other non-Rust SDK, but falling through to 1 in apcore-cli-rust (fixed in its 0.11.0). Both codes now carry an explicit assertion here too, so the three maps cannot drift again without a test going red.
44+
45+
### Notes
46+
47+
- **What the 0.27.0 → 0.28.0 delta does *not* touch.** The CLI never constructs or loads an `ACL`, never calls `check()` / `check_access()` (so §6.8.1's fail-closed legacy boolean does not reach it), never reads an `AuditEntry`, and never builds an `ACLRule` (so §6.1.5's `effect` value closure and the new `approval` field are inert here). `ExecutionPolicy.resolve()`'s new keyword-only call-site parameters are additive and the CLI configures no policy. `p99_latency_ms` changing value is display-only in `_format_usage_summary_tty`.
48+
- **`Executor.validate()` now reports the governance-effective requirement (§7.9.5), which is an improvement the CLI gets for free.** `apcli validate` and the `--dry-run` path forward `result.requires_approval` verbatim, so a call gated only by an ACL argument-scoped rule is now correctly reported as needing approval. Pinned by `test_preflight_reports_the_acl_sourced_requirement`.
49+
- **The CLI's own pre-execution `check_approval(module_def, ...)` still reads the static annotation**, which since spec v1.29.0 no longer means "no consent needed". That is correct here rather than a defect: the pre-check is an ergonomic early prompt, and a call it skips still meets the executor's gate, which now composes all three sources and routes to the same `CliApprovalHandler`. Verified end-to-end by `TestApprovalGateEndToEnd`.
50+
851
## [0.10.5] - 2026-08-17
952

1053
Patch release. Bumps the required `apcore` floor to `0.27.0` to track the aligned apcore 0.27.0 release (2026-08-14). **No source changes** — the full test suite passes unchanged (798 passed, 5 xfailed) against apcore 0.27.0.

‎pyproject.toml‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "apcore-cli"
7-
version = "0.10.5"
7+
version = "0.11.0"
88
description = "Terminal adapter for apcore — execute AI-Perceivable modules from the command line"
99
readme = "README.md"
1010
license = "Apache-2.0"
@@ -26,8 +26,8 @@ classifiers = [
2626
"Environment :: Console",
2727
]
2828
dependencies = [
29-
"apcore>=0.27.0",
30-
"apcore-toolkit>=0.10.0",
29+
"apcore>=0.28.0",
30+
"apcore-toolkit>=0.10.2",
3131
"click>=8.1",
3232
"jsonschema>=4.20",
3333
"rich>=13.0",

‎src/apcore_cli/approval.py‎

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,30 @@ def _read_timeout_from_env() -> int | None:
7171
return parsed
7272

7373

74+
def _approval_result(status: str, approved_by: str | None = None, reason: str | None = None) -> Any:
75+
"""Build the apcore-protocol ``ApprovalResult`` the executor's gate expects.
76+
77+
The approval gate reads the handler's return value by attribute
78+
(``result.status`` in apcore ``builtin_steps``), so a plain mapping raises
79+
``AttributeError`` inside the gate and surfaces to the caller as
80+
``MODULE_EXECUTE_ERROR``. Mirrors the Rust adapter's ``cli_to_apcore_result``
81+
and TypeScript's structurally-typed ``ApprovalResult`` object.
82+
83+
Falls back to the mapping shape when apcore is not importable so the handler
84+
stays usable in isolation (unit tests that stub the runtime out).
85+
"""
86+
payload: dict[str, Any] = {"status": status}
87+
if approved_by is not None:
88+
payload["approved_by"] = approved_by
89+
if reason is not None:
90+
payload["reason"] = reason
91+
try:
92+
from apcore.approval import ApprovalResult
93+
except ImportError:
94+
return payload
95+
return ApprovalResult(**payload)
96+
97+
7498
# ---------------------------------------------------------------------------
7599
# CliApprovalHandler — implements apcore ApprovalHandler protocol (FE-11 §3.5)
76100
# ---------------------------------------------------------------------------
@@ -109,25 +133,25 @@ async def request_approval(self, request: Any) -> Any:
109133
# (`if !get_requires_approval(module_def) { return Approved::not_required }`).
110134
requires_attr = getattr(request, "requires_approval", None)
111135
if requires_attr is False:
112-
return {"status": "approved", "approved_by": "not_required"}
136+
return _approval_result("approved", approved_by="not_required")
113137
module_def = getattr(request, "module_def", None)
114138
if module_def is not None:
115139
annotations = getattr(module_def, "annotations", None)
116140
if annotations is not None:
117141
requires = _get_annotation(annotations, "requires_approval", None)
118142
if requires is False:
119-
return {"status": "approved", "approved_by": "not_required"}
143+
return _approval_result("approved", approved_by="not_required")
120144

121145
# Bypass: auto_approve flag
122146
if self.auto_approve:
123147
logger.info("Approval bypassed via --yes flag for module '%s'.", module_id)
124-
return {"status": "approved", "approved_by": "auto_approve"}
148+
return _approval_result("approved", approved_by="auto_approve")
125149

126150
# Bypass: APCORE_CLI_AUTO_APPROVE env var
127151
env_val = os.environ.get("APCORE_CLI_AUTO_APPROVE", "")
128152
if env_val == "1":
129153
logger.info("Approval bypassed via APCORE_CLI_AUTO_APPROVE for '%s'.", module_id)
130-
return {"status": "approved", "approved_by": "env_auto_approve"}
154+
return _approval_result("approved", approved_by="env_auto_approve")
131155
if env_val != "" and env_val != "1":
132156
# D10-009 cross-SDK parity: emit to stderr (matching TS) so log
133157
# capture / handler config does not mask the warning. Spec at
@@ -140,10 +164,7 @@ async def request_approval(self, request: Any) -> Any:
140164

141165
# Non-TTY: reject
142166
if not sys.stdin.isatty():
143-
return {
144-
"status": "rejected",
145-
"reason": "Non-interactive session without --yes",
146-
}
167+
return _approval_result("rejected", reason="Non-interactive session without --yes")
147168

148169
# TTY prompt
149170
annotations = getattr(request, "annotations", None) or {}
@@ -154,21 +175,18 @@ async def request_approval(self, request: Any) -> Any:
154175
try:
155176
approved = _tty_prompt(module_id, self.timeout)
156177
except ApprovalTimeoutError:
157-
return {"status": "timeout", "reason": f"Timed out after {self.timeout}s"}
178+
return _approval_result("timeout", reason=f"Timed out after {self.timeout}s")
158179

159180
if approved:
160-
return {"status": "approved", "approved_by": "tty_user"}
161-
return {"status": "rejected", "reason": "User rejected"}
181+
return _approval_result("approved", approved_by="tty_user")
182+
return _approval_result("rejected", reason="User rejected")
162183

163184
async def check_approval(self, approval_id: str) -> Any:
164185
"""Check status of a previously pending approval (Phase B).
165186
166187
CLI does not support async approval polling; always returns rejected.
167188
"""
168-
return {
169-
"status": "rejected",
170-
"reason": "CLI does not support async approval polling",
171-
}
189+
return _approval_result("rejected", reason="CLI does not support async approval polling")
172190

173191

174192
# ---------------------------------------------------------------------------

‎src/apcore_cli/cli.py‎

Lines changed: 9 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@
1616
from apcore_cli.approval import check_approval
1717
from apcore_cli.builtin_group import RESERVED_GROUP_NAMES as RESERVED_GROUP_NAMES # noqa: PLC0414
1818
from apcore_cli.display_helpers import get_display as _get_display
19-
from apcore_cli.exit_codes import EXIT_SCHEMA_CIRCULAR_REF, EXIT_SCHEMA_VALIDATION_ERROR
19+
from apcore_cli.exit_codes import (
20+
APCORE_ERROR_CODE_MAP,
21+
EXIT_SCHEMA_CIRCULAR_REF,
22+
EXIT_SCHEMA_VALIDATION_ERROR,
23+
)
2024
from apcore_cli.output import format_exec_result
2125
from apcore_cli.ref_resolver import (
2226
CircularRefError,
@@ -428,30 +432,10 @@ def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> Non
428432
)
429433

430434

431-
# Error code mapping from apcore error codes to CLI exit codes
432-
_ERROR_CODE_MAP = {
433-
"MODULE_NOT_FOUND": 44,
434-
"MODULE_LOAD_ERROR": 44,
435-
"MODULE_DISABLED": 44,
436-
"SCHEMA_VALIDATION_ERROR": 45,
437-
"SCHEMA_CIRCULAR_REF": 48,
438-
"APPROVAL_DENIED": 46,
439-
"APPROVAL_TIMEOUT": 46,
440-
"APPROVAL_PENDING": 46,
441-
"CONFIG_NOT_FOUND": 47,
442-
"CONFIG_INVALID": 47,
443-
"MODULE_EXECUTE_ERROR": 1,
444-
"MODULE_TIMEOUT": 1,
445-
"ACL_DENIED": 77,
446-
# Config Bus errors (apcore >= 0.15.0)
447-
"CONFIG_NAMESPACE_RESERVED": 78,
448-
"CONFIG_NAMESPACE_DUPLICATE": 78,
449-
"CONFIG_ENV_PREFIX_CONFLICT": 78,
450-
"CONFIG_ENV_MAP_CONFLICT": 78,
451-
"CONFIG_MOUNT_ERROR": 66,
452-
"CONFIG_BIND_ERROR": 65,
453-
"ERROR_FORMATTER_DUPLICATE": 70,
454-
}
435+
# Error code mapping from apcore error codes to CLI exit codes. Single source
436+
# of truth lives in exit_codes so the dispatch paths that map by wire code and
437+
# exit_code_for_error cannot drift apart.
438+
_ERROR_CODE_MAP = APCORE_ERROR_CODE_MAP
455439

456440

457441
# D9-005: format_preflight_result and _first_failed_exit_code moved to

‎src/apcore_cli/exit_codes.py‎

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,61 @@
6161
}
6262

6363

64+
# ---------------------------------------------------------------------------
65+
# Map: apcore wire error code -> exit code
66+
# ---------------------------------------------------------------------------
67+
# The CLI's own exception classes (above) never match an error raised by the
68+
# apcore runtime, which signals failure through ``ModuleError`` subclasses
69+
# carrying a ``code`` attribute. Matching on that code is what keeps the
70+
# canonical taxonomy (44 / 45 / 46 / 47 / 77) intact for every dispatch path —
71+
# ``apcli`` system commands included, which route apcore errors straight to
72+
# :func:`exit_code_for_error`. Mirrors the ``codeMap`` in TS ``exitCodeForError``
73+
# and Rust ``cli::map_apcore_error_to_exit_code``.
74+
APCORE_ERROR_CODE_MAP: dict[str, int] = {
75+
"MODULE_NOT_FOUND": EXIT_MODULE_NOT_FOUND,
76+
"MODULE_LOAD_ERROR": EXIT_MODULE_LOAD_ERROR,
77+
"MODULE_DISABLED": EXIT_MODULE_DISABLED,
78+
"DEPENDENCY_NOT_FOUND": EXIT_DEPENDENCY_NOT_FOUND,
79+
"DEPENDENCY_VERSION_MISMATCH": EXIT_DEPENDENCY_VERSION_MISMATCH,
80+
"SCHEMA_VALIDATION_ERROR": EXIT_SCHEMA_VALIDATION_ERROR,
81+
"SCHEMA_CIRCULAR_REF": EXIT_SCHEMA_CIRCULAR_REF,
82+
"APPROVAL_DENIED": EXIT_APPROVAL_DENIED,
83+
"APPROVAL_TIMEOUT": EXIT_APPROVAL_TIMEOUT,
84+
"APPROVAL_PENDING": EXIT_APPROVAL_DENIED,
85+
"CONFIG_NOT_FOUND": EXIT_CONFIG_NOT_FOUND,
86+
"CONFIG_INVALID": EXIT_CONFIG_INVALID,
87+
"MODULE_EXECUTE_ERROR": EXIT_MODULE_EXECUTE_ERROR,
88+
"MODULE_TIMEOUT": EXIT_MODULE_TIMEOUT,
89+
"ACL_DENIED": EXIT_ACL_DENIED,
90+
# Config Bus errors (apcore >= 0.15.0)
91+
"CONFIG_NAMESPACE_RESERVED": EXIT_CONFIG_NAMESPACE_RESERVED,
92+
"CONFIG_NAMESPACE_DUPLICATE": EXIT_CONFIG_NAMESPACE_DUPLICATE,
93+
"CONFIG_ENV_PREFIX_CONFLICT": EXIT_CONFIG_NAMESPACE_DUPLICATE,
94+
"CONFIG_ENV_MAP_CONFLICT": EXIT_CONFIG_NAMESPACE_DUPLICATE,
95+
"CONFIG_MOUNT_ERROR": EXIT_CONFIG_MOUNT_ERROR,
96+
"CONFIG_BIND_ERROR": EXIT_CONFIG_BIND_ERROR,
97+
"ERROR_FORMATTER_DUPLICATE": EXIT_ERROR_FORMATTER_DUPLICATE,
98+
}
99+
100+
64101
def exit_code_for_error(err: BaseException) -> int:
65102
"""Return the configured exit code for an error instance.
66103
67-
Falls back to ``1`` (generic execute error) when no entry in
68-
:data:`EXIT_CODES` matches. Mirrors TS ``exitCodeForError`` and the
69-
Rust ``ExitCode::from`` mapping.
104+
Resolves in two steps, matching TS ``exitCodeForError`` and the Rust
105+
``ExitCode::from`` mapping: first the CLI's own exception classes
106+
(:data:`EXIT_CODES`), then the apcore wire code the error carries
107+
(:data:`APCORE_ERROR_CODE_MAP`). Falls back to ``1`` (generic execute
108+
error) when neither matches.
70109
"""
71110
for cls, code in EXIT_CODES.items():
72111
if isinstance(err, cls):
73112
return code
113+
114+
# Fall back to the apcore wire code. None of the classes above match an
115+
# error raised by the apcore runtime, so without this every apcore failure
116+
# would collapse to 1 — losing the taxonomy that scripted callers read.
117+
wire_code = getattr(err, "code", None)
118+
wire_code = getattr(wire_code, "value", wire_code)
119+
if isinstance(wire_code, str) and wire_code in APCORE_ERROR_CODE_MAP:
120+
return APCORE_ERROR_CODE_MAP[wire_code]
74121
return 1

‎src/apcore_cli/system_cmd.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,15 @@ def _format_health_summary_tty(result: dict[str, Any]) -> None:
101101
rate = f"{m.get('error_rate', 0) * 100:.1f}%"
102102
click.echo(f" {m['module_id']:<28} {m['status']:<12} {rate:<12} {top_str}")
103103

104+
# The health tiers are healthy / degraded / error / unknown — four, not
105+
# three. `unknown` means "no calls recorded yet", which is the state every
106+
# module in a fresh project is in, so omitting it made the summary line
107+
# contradict the table right above it: the rows listed modules while the
108+
# total read "no data". apcore >= 0.28.0 declares the four-tier set
109+
# canonically in sys-health-summary.schema.json (§6.6); the SDKs have
110+
# emitted `unknown` all along.
104111
parts = []
105-
for key in ("healthy", "degraded", "error"):
112+
for key in ("healthy", "degraded", "error", "unknown"):
106113
count = summary.get(key, 0)
107114
if count:
108115
parts.append(f"{count} {key}")

0 commit comments

Comments
 (0)