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
50 changes: 50 additions & 0 deletions .github/codeql/codeql-config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: "openarmature-python CodeQL config"

# CodeQL's Python ``code-quality`` suite includes
# ``py/ineffectual-statement``, which produces a fixed false-positive
# stream against this codebase:
#
# - PEP 544 ``Protocol`` method bodies use ``...`` (the canonical
# Python idiom — never executed because Protocols are structural,
# not inheritance-based). Replacing with ``raise NotImplementedError``
# would imply ABC semantics that the protocol classes
# intentionally don't have. Cited sites:
# - ``llm/provider.py`` (Provider.ready / complete)
# - ``checkpoint/protocol.py`` (Checkpointer.save / load / list /
# delete)
# - ``graph/middleware/_core.py`` (Middleware.__call__ + chain
# callable)
# - ``graph/observer.py`` (Observer.__call__)
# - ``await worker`` after a queue sentinel in
# ``tests/unit/test_observer.py`` is flagged as no-effect, but
# ``await`` on a Task waits for completion — the entire reason
# the line exists. CodeQL bug for async patterns.
#
# Each finding has been individually triaged across PR review rounds;
# the rule has produced zero true positives. Suppressing the rule
# repo-wide stops the regenerating noise without leaving real
# unused-statement bugs un-caught (pyright's ``reportUnusedExpression``
# already covers the genuine cases at type-check time).
query-filters:
- exclude:
id: py/ineffectual-statement
# ``py/unused-import`` produces false positives on three patterns
# this codebase relies on:
#
# - Forward-reference casts: ``cast("FinishReason", x)`` /
# ``cast("Checkpointer", capturing)``. CodeQL doesn't look
# inside the string argument; pyright's strict mode does, AND
# raises ``reportUndefinedVariable`` if the name isn't in scope
# (verified empirically — removing ``FinishReason`` from
# ``openai.py``'s import yields the pyright error).
# - Subscripted base classes: ``class _TracingFanOutNode(
# FanOutNode[State, State]):``. The base class IS used; the
# generic subscription happens at class-definition time.
# - Re-exports for the public API surface (some submodule
# ``__init__`` files import names solely to re-expose).
#
# Pyright's ``reportUnusedImport`` (default in strict mode) catches
# the genuine unused-import cases without these false positives,
# so dropping the CodeQL rule doesn't lose signal.
- exclude:
id: py/unused-import
9 changes: 8 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,14 @@ jobs:
# version developers run locally.

- name: Sync deps
run: uv sync --frozen
# ``--all-extras`` installs every optional-dependency group
# (currently ``[otel]``) so pyright type-checks the
# OTel-using modules with real types and tests covering
# extras-gated code paths run end-to-end. Without this,
# ``opentelemetry.*`` symbols come back Unknown and pyright
# produces hundreds of false-positive errors on
# ``tests/unit/test_observability_otel.py``.
run: uv sync --frozen --all-extras

- name: Lint (ruff check)
run: uv run ruff check .
Expand Down
61 changes: 61 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# CodeQL advanced setup — replaces the prior default setup so we can
# reference ``.github/codeql/codeql-config.yml`` (default setup's UI
# doesn't expose a config-file field). Config excludes
# ``py/ineffectual-statement`` which produced a regenerating stream
# of false positives against PEP 544 Protocol method bodies; see the
# config file for the full rationale.
name: CodeQL

on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
# Weekly scan to surface new issues that didn't change in any
# PR. Matches default setup's cadence.
- cron: "25 16 * * 5"

# Least-privilege per the CI workflow next door. CodeQL needs:
# - ``actions: read`` to fetch action metadata.
# - ``contents: read`` to check out code.
# - ``security-events: write`` to upload SARIF results to the Security
# tab.
permissions:
actions: read
contents: read
security-events: write

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: [actions, python]
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
# Reference the in-tree config so the rule-suppressions land.
config-file: ./.github/codeql/codeql-config.yml
# Match the prior default setup's query coverage by including
# the security-and-quality suite explicitly. Code-quality
# findings still surface; only the per-rule
# ``py/ineffectual-statement`` exclusion in the config file
# is suppressed.
queries: security-and-quality

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{ matrix.language }}"
12 changes: 10 additions & 2 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@ vim src/openarmature/__init__.py # __version__ = "0.5.0rc1"
vim tests/test_smoke.py # match
# uv.lock auto-updates via pre-commit when you stage pyproject.toml
git add pyproject.toml src/openarmature/__init__.py tests/test_smoke.py uv.lock
git commit -m "release: prep 0.5.0-rc1"
git commit -m "release: prep 0.5.0rc1"
git push origin <branch>
gh pr create --title "release: prep 0.5.0-rc1" ...
gh pr create --title "release: prep 0.5.0rc1" ...
```

If this is the same release cycle as a previous RC, also bump the spec pins
Expand All @@ -56,6 +56,14 @@ Merge the PR.

### 2. Tag the RC

Two version forms appear in this guide and they're not interchangeable
in writing even though PEP 440 normalizes them: `pyproject.toml` /
`__init__.py` use `0.5.0rc1` (no hyphen — the canonical PEP 440
form); git tags + the release-workflow's tag-shape regex use
`v0.5.0-rc1` (with hyphen). Mixing them in commit messages or PR
titles muddies the audit trail; stick to the no-hyphen form for
version-string fields and the hyphenated form for git tags.

```bash
git checkout main && git pull --ff-only
git tag v0.5.0-rc1
Expand Down
15 changes: 15 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@ dependencies = [
"httpx>=0.27",
]

[project.optional-dependencies]
# Spec observability §6 OTel mapping. Optional per charter §3.1
# principle 5 — backend mappings are pluggable rather than core
# dependencies. Plan to lift into a sibling ``openarmature-otel``
# package at v1.0 launch alongside ``openarmature-eval``.
otel = [
# Upper bound guards against the SDK 2.x release that removes
# ``opentelemetry.sdk._logs.LoggingHandler`` (currently emits a
# DeprecationWarning). Migration to
# ``opentelemetry-instrumentation-logging`` lands in Phase 6.1
# before bumping the upper bound.
"opentelemetry-api>=1.27,<2",
"opentelemetry-sdk>=1.27,<2",
]

[project.urls]
Repository = "https://github.com/LunarCommand/openarmature-python"
Specification = "https://github.com/LunarCommand/openarmature-spec"
Expand Down
103 changes: 81 additions & 22 deletions src/openarmature/graph/compiled.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@
CheckpointRecord,
NodePosition,
)
from openarmature.observability.correlation import (
_reset_active_dispatch,
_reset_active_observers,
_reset_correlation_id,
_reset_invocation_id,
_set_active_dispatch,
_set_active_observers,
_set_correlation_id,
_set_invocation_id,
)

from .edges import END, ConditionalEdge, EndSentinel, StaticEdge
from .errors import (
Expand Down Expand Up @@ -369,6 +379,18 @@ async def invoke(
pending_resume_states=pending_resume_states,
resume_invocation=resume_invocation,
)
# Spec observability §3.1: the correlation_id MUST be readable
# from anywhere within the invocation's async call tree via the
# language's idiomatic context primitive. Set the ContextVar
# BEFORE creating the delivery worker so the worker's captured
# context sees the correlation_id (asyncio.create_task snapshots
# the current Context at creation time). Reset on return so
# subsequent invocations get a fresh slate. Nested ``invoke()``
# calls (subgraph-as-node uses ``_invoke`` directly, not the
# public ``invoke``, so they don't re-set; see §3.1's
# "per-invocation is OUTERMOST invoke" wording).
correlation_token = _set_correlation_id(resolved_correlation_id)
invocation_token = _set_invocation_id(invocation_id)
worker = asyncio.create_task(deliver_loop(queue))
self._active_workers.add(worker)
# Auto-prune: when the worker completes (after the sentinel is
Expand All @@ -378,6 +400,8 @@ async def invoke(
try:
return await self._invoke(starting_state, context)
finally:
_reset_invocation_id(invocation_token)
_reset_correlation_id(correlation_token)
# Sentinel terminates the worker after it processes events
# already on the queue (including any error event we just
# dispatched on the failure path). Drain semantics live on
Expand Down Expand Up @@ -585,14 +609,27 @@ async def innermost(s: Any) -> Mapping[str, Any]:
innermost,
)

# Spec observability §3 / Phase 6 LLM-span hook: capability
# backends emitting from inside a node body (the
# llm-provider span instrumentation in OpenAIProvider) need
# to find the observers active for THIS invocation. Set the
# ContextVar around the chain invocation; reset in
# ``try/finally`` so an exception escaping the chain still
# restores the prior value.
observers_token = _set_active_observers(context.full_observers())
dispatch_token = _set_active_dispatch(lambda event: _dispatch(context, event))
try:
final_partial = await chain(state)
except RuntimeGraphError:
raise
except Exception as e:
# A raw exception (node-raised or middleware-raised) escaped
# the chain unrecovered. Wrap as NodeException per §4.
raise NodeException(node_name=current, cause=e, recoverable_state=state) from e
try:
final_partial = await chain(state)
except RuntimeGraphError:
raise
except Exception as e:
# A raw exception (node-raised or middleware-raised) escaped
# the chain unrecovered. Wrap as NodeException per §4.
raise NodeException(node_name=current, cause=e, recoverable_state=state) from e
finally:
_reset_active_dispatch(dispatch_token)
_reset_active_observers(observers_token)
# Engine's canonical merge uses the ORIGINAL state per §2: "the
# transformed state is passed to ``next``, NOT to the engine's
# merge step." If middleware transformed state mid-chain, the
Expand Down Expand Up @@ -649,18 +686,29 @@ async def innermost(s: Any) -> Mapping[str, Any]:
list(self.middleware) + list(node.middleware),
innermost,
)
# Same active-observers scope as _step_function_node — parent
# middleware running before the descent should see the parent's
# observer set; the inner _invoke (called via ``node.run``)
# descends into its own context and sets a new scope from
# there.
observers_token = _set_active_observers(context.full_observers())
dispatch_token = _set_active_dispatch(lambda event: _dispatch(context, event))

try:
final_partial = await chain(state)
except RuntimeGraphError:
raise
except Exception as e:
# Same wrap as _step_function_node: a raw exception escaping
# the parent's middleware chain (e.g., a middleware bug or a
# projection error) becomes NodeException tagged with the
# SubgraphNode's wrapper name so §4 recoverable_state is
# preserved.
raise NodeException(node_name=current, cause=e, recoverable_state=state) from e
try:
final_partial = await chain(state)
except RuntimeGraphError:
raise
except Exception as e:
# Same wrap as _step_function_node: a raw exception escaping
# the parent's middleware chain (e.g., a middleware bug or a
# projection error) becomes NodeException tagged with the
# SubgraphNode's wrapper name so §4 recoverable_state is
# preserved.
raise NodeException(node_name=current, cause=e, recoverable_state=state) from e
finally:
_reset_active_dispatch(dispatch_token)
_reset_active_observers(observers_token)
return _merge_partial(state, final_partial, self.reducers, current)

async def _step_fan_out_node(
Expand Down Expand Up @@ -740,12 +788,23 @@ async def innermost(s: Any) -> Mapping[str, Any]:
innermost,
)

# Same observability §3 / LLM-span hook contract as
# _step_function_node: set the active observer set in scope
# around the chain invocation so capability backends emitting
# from inside the fan-out's parent dispatch (or any code
# running on its call stack) can find the observers.
observers_token = _set_active_observers(context.full_observers())
dispatch_token = _set_active_dispatch(lambda event: _dispatch(context, event))
try:
final_partial = await chain(state)
except RuntimeGraphError:
raise
except Exception as e:
raise NodeException(node_name=current, cause=e, recoverable_state=state) from e
try:
final_partial = await chain(state)
except RuntimeGraphError:
raise
except Exception as e:
raise NodeException(node_name=current, cause=e, recoverable_state=state) from e
finally:
_reset_active_dispatch(dispatch_token)
_reset_active_observers(observers_token)
merged_outer = _merge_partial(state, final_partial, self.reducers, current)
# Spec §10.3 + §10.7: the fan-out's own completion DOES save —
# one record once the fan-out as a whole has finished and
Expand Down
Loading
Loading