v0.4.0
A minor bump rather than a patch: the Wasmtime upgrade below changes the
wovyr-tools wasi feature's public surface, and this project follows semver
from 0.3.0 onward.
Fixed
-
A quota breach reports figures at the scale they actually occur. The
llm_cost_per_day_usdmessage formatted every amount{:.4}, so a breach at real
per-call scale — agpt-4o-minireply costs on the order of$0.000008— read
llm_cost_per_day_usd: 0.0000 + 0.0000 exceeds limit 0.0000: it named the metric
and then told the operator nothing about what was spent, what was requested, or
what the ceiling was. A newusd_precisionpicks the decimal places needed to show
three significant digits (two for human-scale money, capped at eight), and the
three figures in one message share that precision so the comparison lines up
digit for digit — the admission check runs before the call, so its delta is0.0,
and a bare0.00beside eight-decimal siblings reads like a different unit
(crates/wovyr-tenancy/src/quota.rs). Live:
llm_cost_per_day_usd: 0.00000420 + 0.00000000 exceeds limit 0.00000100, with a
dollar-scale budget still reading in plain dollars. -
image_generateis available toworkflows run --local, not just
agents run --local. The tool was registered inline in the agent command's own
body, so a workflowtoolactivity naming it failed with a bareunknown tool
even with a provider key configured — while the same activity worked against the
server, whose one shared registry has it. Registration moved into
local_registry, the constructor both CLI paths already funnel through, so the
asymmetry is now unrepresentable rather than merely fixed — the argument HLTH-901
made for the executor, applied to the registry
(apps/wovyr-cli/src/main.rs,
workflow.rs). It stays conditional on a
configured key (it needs a real, billed API), and the workflow runner now builds
one gateway shared by its registry and executor, soimage_generateroutes
through the same retry/failover/breaker pipeline as every other call. Guarded by
tests asserting both paths produce identical tool sets at each privilege level,
and that the tool's presence follows the key rather than the command. -
The test suite no longer reads or writes the developer's real
~/.wovyr.
cargo test --workspaceresolved durable stores through
wovyr_config::paths::*, which readHOME/USERPROFILE— so every run left
test tenancy quotas (prj-quota-test,prj-async-block) sitting in live local
state, wrote workflow checkpoints and event logs (test-exec-1.*) into the real
workflow store, and appended test entries to the real tamper-evident audit
chain (found at ~380 KB and growing on the machine this was fixed on). Beyond
the pollution, it meant a test run and awovyr devon the same machine shared
files, and pre-existing local state could influence results.AppState::for_test
already isolated the two stores whose contents tests assert on, but everything
elsefrom_envbuilds — the audit chain, tenancy, the workflow store, the KMS
root key, secrets, the async-run and webhook-outbox files — still landed in the
real directory.wovyr_config::rootgained a process-wide root override
(set_root_override/redirect_to_scratch/root_override), which
AppState::for_testand theauthz_matrixintegration test call before building
anything (root.rs,
state.rs). Overriding one function covers
every resource directory, because they all already resolve through
root::wovyr_dir()— the previous comment onfor_testcalled this "a larger
change that would have to thread a state-root parameter through every
crate::config::*_dir()helper", which was not the case; that comment is
corrected. Redirection is per process, not per test: tests in one binary
share a scratch root exactly as they previously shared the real one, isolated
from the user rather than from each other, so the two per-AppStatestore swaps
stay. Guarded by a test asserting the suite's resolved root is under the temp dir
and is not the home-derived path, pluswovyr-configunit tests for the
override's first-call-wins semantics and its reach across everypaths::*
helper. -
wovyr plugin build <project>works with a relative path. The documented
flow —wovyr plugin new hello-toolthenwovyr plugin build hello-tool— failed
for every relative argument with "no build output at hello-tool\target\…".
build_cmdderived a relative--target-dirfrom the project argument and then
ran cargo withcurrent_dir(project), so cargo resolved that path a second time
against the new cwd and wrote the module tohello-tool/hello-tool/target/…while
the command looked for it one level up. An absolute argument happened to work,
which is why the round-trip test — using a scratch temp dir — never caught it. A
newbuild_pathsresolves the project once, up front, so every downstream path is
cwd-independent (apps/wovyr-cli/src/scaffold.rs),
with unit tests pinning that both resolved paths are absolute and that the target
dir is never re-resolved against the project dir. Verified against the real binary:
plugin new hello2 && plugin build hello2now stageshello2.wasm+ a
digest-completeplugin.yaml, with no nestedhello2/hello2/directory. -
Three shipped example workflows run again.
greet-and-fetch.yaml,
saga-order.yaml(all five activities) andsupport.yamldeclared
type: functionwith noname, which RM-GA-P4 HLTH-901 turned into a permanent
activity failure when it unifiedfunctionandtoolonto one tool-invoking
dispatch — so each died on its first activity. Two of them are reachable straight
off a README quickstart line. All three now nameechoexplicitly.
The comment incrates/wovyr-runtime/src/lib.rs
that justified the unification claimed "every real example/test that uses
type: functionalready expects a tool invocation" — it did not, and that wrong
premise is why the breakage shipped; it now records what actually broke, and notes
thatwovyr-workflow's own engine tests still use namelessfunctionactivities
(they supply their own executor and never reach this dispatch, so they were never
evidence either way). A new test walks everyexamples/workflows/*.yamland fails
if afunction/toolactivity lacks aname—Definition::from_yamlcan't
catch this, sincenameis legitimately absent onwait/human/for_each, so
the breakage only ever surfaced when a human ran the example. -
A quota set on a nonexistent project is refused instead of silently accepted.
PATCH /api/v1/projects/{id}/quotawrote to the quota map by raw id with no
existence check, so a typo'd project — or an empty id from a doubled slash —
returned200and persisted a limit under a key no run would ever consult, while
GETreturned200 {}for the same ghost project, even though
GET /api/v1/projects/{id}itself correctly 404s. An operator got a success
response for a budget enforcing nothing: the same silent-no-op class as an unpriced
model reporting$0. Both handlers now check the project exists first
(crates/wovyr-server/src/tenancy.rs), with
a regression test asserting both routes 404 and that the rejected write leaves no
stored limits behind. -
A rolled-back saga is reported as
Failed, notCompleted. After a clean
rollback the engine transitioned the execution toCompleted, so a saga whose
activity failed was missing from?status=failedand listed among the
successes — while its own event log saidworkflow_failedand the CLI printed
"rolled back after failure". Every durable surface (Engine::status/query/
list,GET /api/v1/workflows?status=, any dashboard over them) reads that
checkpoint status, so the wrong value was the one operators actually see. The
terminal state is nowFailed— the compensation succeeded, the workflow did
not — andCompensationCompletedin the event log is what distinguishes "failed,
and rolled back cleanly" from a bare failure.(Compensating, Completed)is
removed from the transition table and(Compensating, Failed)added, so the old
state is now unreachable by construction rather than merely unused
(state.rs,
engine.rs). A failed rollback still stays
non-terminal inCompensating, by design. Chosen over adding a distinct
Compensatedstate, which would have been a breaking wire/on-disk change for a
distinction the event log already carries;
compensation-engine.md's state
diagram — whose ambiguity is arguably what licensed the original behavior — now
says so explicitly. -
Real cost is recorded behind an OpenAI-compatible gateway that renames the
model.PriceBook::pricematched a model id only from the left (exact, then
longest known key that is a prefix), so a vendor-prefixed id matched nothing:
OpenRouter answers a request forgpt-4o-miniwithopenai/gpt-4o-mini, and cost
is deliberately keyed on the model the upstream reports it billed, so every
call loggedno price for model and no default configuredand reported
cost_usd: 0.000000. That silently reinstated the failure class RM-AIM-P1 PRV-101
existed to remove —wovyr-server's per-projectllm_cost_per_day_usdbudget is
fed from this number, so on such a deployment it never accrued and never denied.
price()now retries the exact-then-longest-prefix lookup against a prefixed id's
bare name (openai/gpt-4o-mini→gpt-4o-mini,anthropic/claude-sonnet-5→
claude-sonnet-5) before falling through to the configured default and the warn.
Deliberately a fallback, not a rewrite of the primary path, so an operator
override keyed on the full prefixed id still wins
(crates/wovyr-provider/src/pricing.rs).
Verified live: the same run that reported$0now reportscost_usd: 0.000034
with no price override configured and no warn. Three of the four new tests were
confirmed to fail against the pre-fix lookup; the fourth (an override on the
prefixed id beating the stripped fallback) passes either way by construction and
is an ordering guard rather than a regression test. One test also pins the
fallback's blast radius: the vendor segment is only stripped, and matching after
that is the same loose longest-left-prefix rule as always
(vendor/o1-turbo-previewresolves to theo1entry, exactly as the bare id
already did). The request path needed no change — OpenRouter accepts the bare ids
resolve_model/resolve_embedding_modelalready send (gpt-4o,gpt-4o-mini,
text-embedding-3-smallall verified against the live API) and normalizes them
server-side; this was only ever about pricing the response. -
The streaming path logs its computed cost too. PRV-101's notes recorded that
cost_usdwas logged atdebug(target: "wovyr.pricing") from both
parse_responseandStreamAccumulator::finish, but only the non-streaming path
ever did — and streaming is the default forwovyr agents run --streamand the
server's SSE route, so the path an operator most needs to watch cost accrue on was
the silent one. The cost itself was always computed and charged; only the
observability line was missing
(crates/wovyr-provider/src/openai.rs). -
WOVYR_LOGworks at all now; every binary was logging atTRACE. The level
filter had no effect whatsoever — notWOVYR_LOG, notRUST_LOG, not the
documentedwarndefault — so every CLI run (and everywovyr devserver, which
shares the same process and therefore the same subscriber) emitted the full
hyper_utilconnection-pool firehose: dozens ofTRACElines per HTTP request,
drowning the actual output.init_loggingcomposed theEnvFilteras an element
of the layer vector (vec![filter.boxed(), fmt_layer]), andLayer for Vec<L>
combinesregister_callsiteby returning the highest interest across its
elements (tracing-subscriber 0.3.23,src/layer/mod.rs:1788-1801).fmt::Layer
uses the defaultregister_callsite, which returnsInterest::always(), so every
callsite resolved toalways,tracingcached it as unconditionally enabled, and
the vector's ownenabled()— anall()that would have correctly returned false
— was never consulted. The filter is now a distinctLayeredstep around the
output layers, which short-circuits toneveras soon as the outer layer says so
(layered.rs:435-450), in a namedcomposefunction the regression test drives
directly, so the test exercises the production composition rather than a look-alike
(crates/wovyr-telemetry/src/logging.rs).
Confirmed to have teeth by temporarily restoring the old composition and watching
it fail on the leakedTRACE/DEBUG/INFOevents, then verified end to end
against the real CLI in all four modes: default (warn),WOVYR_LOG=error
(silent),WOVYR_LOG=info,hyper_util=off(gatewayINFO, nohyper), and
WOVYR_LOG_FORMAT=json. Filter resolution moved into aresolve_filter(explicit, fallback)taking values rather than reading the environment, so the documented
WOVYR_LOG→RUST_LOG→warnprecedence is unit-tested without mutating
process-global environment variables. Note for OTLP deployments: the filter
now applies to the OTLP trace/log layers too (the intent its old doc comment
already claimed), and the instrumented hot-path spans (agent.run,
gateway.chat,workflow.activity,api.*) areINFO-level — so at thewarn
default nothing is exported. SetWOVYR_LOG=infowhen exporting traces; before
this fix the point was moot, since an inert filter meant OTLP received everything
down toTRACE. -
The release pipeline's GHCR push is no longer denied. The
container image (GHCR)job built and uploaded every layer, then failed the manifest write with
denied: permission_denied: write_package— pushing
ghcr.io/punarduttrajput/wovyr:0.3.2. The workflow'spermissions: packages: writewas never the problem: the package had first been created by a local,
label-lessdocker push, which makes it a user-owned package connected to no
repository, and an unlinked package grants this repository'sGITHUB_TOKENno
write access at all. Every image now carries
org.opencontainers.image.source— the label GHCR uses to link a package to
its repository — stamped both in
deployment/docker/Dockerfile(so a manual
local push links it the same way CI's does) and in
.github/workflows/release.yml'slabels:
(so it names the pushing repository rather than a hardcoded one, alongside
per-buildrevision/version). Linking the already-created package is a
one-time operator step that no label can do retroactively — package settings →
"Manage Actions access" → add the repository with the Write role, or delete
the package and let the next release recreate it auto-linked; both paths are
documented in the workflow header and in
DISTRIBUTION.md.