feat: osa manifest command + clean convention contract - #13
Conversation
Add a public `osa manifest` command that emits a convention's authored
definition as JSON, so a consumer in a different interpreter (e.g. the
Amacrin CLI installed as an isolated tool) can obtain the manifest via
`uv run osa manifest` instead of importing the convention.
Restructure the convention model so definition and build artifact are
cleanly separated:
- config/limits are authored, on each component; `release` is a pure
nested build artifact {image, digest, source_ref}, absent pre-build.
- hooks and ingesters are symmetric: both a `Component` (name, config,
limits, optional nested release), hook adds `feature`, ingester adds
schedule/initial_run.
- one typed model tree, serialized at the edge with
model_dump(by_alias=True, exclude_none=True) — no custom serializers,
no domain/wire two-layer.
FieldDefinition drops its serializer (edge exclude_none omits absents).
Pairs with the OSA server contract change (config/limits on the hook,
release = {image, digest, source_ref}) — deploy them together.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
|
Greptile SummaryThis PR adds a portable convention manifest command and aligns deploy payloads with the new component contract. The main changes are:
Confidence Score: 5/5This looks safe to merge. The hook and ingester release paths now use separate key spaces. Object-form entry points load through the supported entry-point API. No blocking issues found in the updated code.
What T-Rex did
|
| Filename | Overview |
|---|---|
| osa/cli/deploy.py | Adds the shared typed manifest model and separates hook releases from the ingester release. |
| osa/cli/main.py | Adds the manifest command and loads convention entry points through EntryPoint.load(). |
| osa/types/schema.py | Introduces typed field definitions for manifest and deploy serialization. |
Comments Outside Diff (1)
-
General comment
Optional ingester RuntimeConfig fields leak null into manifest and deploy serialization
- Bug
- A separately installed convention whose Pydantic
RuntimeConfighas an optional field defaulting toNoneproduces"optional_token": nullinosa manifest. Binding releases retains the same null in the deploy wire. This violates the stated recursive no-null contract even though the outer serialization usesexclude_none=True.
- A separately installed convention whose Pydantic
- Cause
build_manifestfirst convertsRuntimeConfiginto a plain dictionary withRuntimeConfig().model_dump()withoutexclude_none=True. Once nested inside the component's dynamicconfigdictionary, the outerConventionManifest.model_dump(exclude_none=True)does not remove dictionary values that areNone.
- Fix
- Serialize RuntimeConfig with
model_dump(exclude_none=True)when constructing the ingester component. If arbitrary nested config dictionaries are supported, also apply a recursive null-pruning policy at the serialization edge and add a test containing optional nested config values.
- Serialize RuntimeConfig with
- Bug
Reviews (2): Last reviewed commit: "fix: address PR review — release-map col..." | Re-trigger Greptile
| if built is not None: | ||
| image, digest = built | ||
| releases[conv.ingester_info.name] = ComponentRelease( | ||
| image=image, digest=digest, source_ref=source_ref |
There was a problem hiding this comment.
Component Names Collide Across Kinds
When an ingester has the same name as a hook, this assignment overwrites the hook's entry in the shared releases map. bind_releases() then gives both components the ingester image and digest, whereas the previous separate hook and ingester maps could not collide.
Context Used: Be extra harsh on architectural and layout inconsi... (source)
Artifacts
Repro: focused executable pytest harness for the same-name component collision
- Contains supporting evidence from the run (text/x-python; charset=utf-8).
- Keeps the command output available without making the summary code-heavy.
There was a problem hiding this comment.
Fixed in d7650a1. bind_releases now takes hook releases (matched by name) and the single ingester release as separate arguments, so hooks and the ingester no longer share a name keyspace and can't collide. Added a regression test (test_same_name_hook_and_ingester_do_not_collide).
| info = next((hi for hi in _hooks if hi.name == h.__name__), None) | ||
| if info is None: | ||
| continue |
There was a problem hiding this comment.
Duplicate Hook Names Select Wrong Metadata
The registry does not require hook names to be unique, but this lookup selects the first global HookInfo with the same function name. If two convention packages define hooks with that name, the later convention can receive the first hook's limits, cardinality, and output columns in both its manifest and deploy payload.
Context Used: Be extra harsh on architectural and layout inconsi... (source)
Artifacts
Repro: focused duplicate hook registration and manifest harness
- Contains supporting evidence from the run (text/x-python; charset=utf-8).
Stack trace captured during the T-Rex run
- Keeps the raw stack trace available without making the summary code-heavy.
There was a problem hiding this comment.
Valid, but pre-existing — the identical global-registry lookup is already on main's deploy(), so it's out of scope for this PR. Tracked separately in #14 (root cause: the flat global _hooks registry keyed only by name; fix direction is to resolve hooks within their owning convention).
| @classmethod | ||
| def to_field_definitions(cls) -> list[dict[str, Any]]: | ||
| """Convert this schema's fields to server FieldDefinition dicts. | ||
| def to_field_definitions(cls) -> list[FieldDefinition]: |
There was a problem hiding this comment.
Public Method Changes Element Type
to_field_definitions() previously returned dictionaries, but now returns FieldDefinition instances. Existing consumers that use field["name"], field.get(...), or pass the result directly to a JSON encoder will fail even though the deploy wire remains unchanged; keep the dictionary-returning method as a compatibility wrapper or add a separately named typed method.
Context Used: Be extra harsh on architectural and layout inconsi... (source)
Artifacts
Repro: executable consumer compatibility test harness
- Contains supporting evidence from the run (text/x-python; charset=utf-8).
Repro: verbose test output showing three compatibility errors and the retained model dump shape
- Keeps the command output available without making the summary code-heavy.
There was a problem hiding this comment.
Intentional and won't change here. Returning a typed FieldDefinition (instead of a raw dict) is the point of this change — moving off untyped dicts at the edges. to_field_definitions() is an internal serialization helper with no known external callers, osa-py is pre-1.0, and the only in-repo caller (build_manifest) is updated; the deploy wire is unchanged. A dict-returning shim would re-introduce the smell we're removing, so we're accepting the typed-return break.
| ui = _ui(ctx) | ||
|
|
||
| for ep in importlib.metadata.entry_points(group="osa.conventions"): | ||
| importlib.import_module(ep.value) |
There was a problem hiding this comment.
Object Entry Points Cannot Load
A convention entry point using the valid package.module:register form reaches this command, but import_module() treats the full value as a module name and raises before emitting JSON. Load the entry point through its EntryPoint object so object-style registrations can be resolved.
Context Used: Be extra harsh on architectural and layout inconsi... (source)
There was a problem hiding this comment.
Fixed in d7650a1. Switched to ep.load() (which resolves both module- and object-form entry points) at all three call sites — manifest, deploy, and test.
- bind_releases: bind hook releases (by name) and the single ingester release separately, so a hook and the ingester sharing a name can no longer collide in a shared keyspace (adds a regression test). - entry-point discovery: use ep.load() instead of import_module(ep.value) so object-form entry points (package.module:attr) also load, across the manifest/deploy/test commands. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
What
Two changes that ship together:
osa manifestcommand — emits a convention's authored definition as JSON. A consumer in a different interpreter (notably the Amacrin CLI installed as an isolateduvtool, which cannot import the convention) can now get the manifest viauv run osa manifestin the project env, instead of importing the convention or reaching into osa-py internals.Clean convention model — definition and build artifact are now separated:
config/limitsare authored, on each component;releaseis a pure nested build artifact{image, digest, source_ref}, absent before a build.Component(name,config,limits, optional nestedrelease); a hook addsfeature, an ingester addsschedule/initial_run.model_dump(by_alias=True, exclude_none=True). Removed the old two-layer domain/wire split and all four@model_serializers (includingFieldDefinition's).Why
The old wire buried authored
config/limitsinside the buildrelease, which forced a two-model design and hand-written serializers to reproduce an inconsistent null/absent wire. Moving authored fields onto the component makes the model self-consistent, so plainmodel_dump(exclude_none=True)produces the wire.Contract coupling
Pairs with the OSA server contract change (
config/limitson the hook,release={image, digest, source_ref}). Breaking pair — deploy together.Tests
359 pass, ruff + ty clean. Added
TestConventionManifest(component-base symmetry, uniformbind_releases, release-less vs bound wire, recursive no-null invariant); updated deploy/docs-gate/field-definition tests. Verified byte round-trip: anosa deploybody validates against the new server DTO.🤖 Generated with Claude Code