Skip to content

v0.4.0

Choose a tag to compare

@github-actions github-actions released this 01 Aug 16:58
· 26 commits to main since this release

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_usd message formatted every amount {:.4}, so a breach at real
    per-call scale — a gpt-4o-mini reply 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 new usd_precision picks 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 is 0.0,
    and a bare 0.00 beside 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_generate is available to workflows run --local, not just
    agents run --local.
    The tool was registered inline in the agent command's own
    body, so a workflow tool activity naming it failed with a bare unknown 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, so image_generate routes
    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 --workspace resolved durable stores through
    wovyr_config::paths::*, which read HOME/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 a wovyr dev on 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
    else from_env builds — 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::root gained a process-wide root override
    (set_root_override/redirect_to_scratch/root_override), which
    AppState::for_test and the authz_matrix integration 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 on for_test called 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-AppState store swaps
    stay. Guarded by a test asserting the suite's resolved root is under the temp dir
    and is not the home-derived path, plus wovyr-config unit tests for the
    override's first-call-wins semantics and its reach across every paths::*
    helper.

  • wovyr plugin build <project> works with a relative path. The documented
    flow — wovyr plugin new hello-tool then wovyr plugin build hello-tool — failed
    for every relative argument with "no build output at hello-tool\target\…".
    build_cmd derived a relative --target-dir from the project argument and then
    ran cargo with current_dir(project), so cargo resolved that path a second time
    against the new cwd and wrote the module to hello-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
    new build_paths resolves 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 hello2 now stages hello2.wasm + a
    digest-complete plugin.yaml, with no nested hello2/hello2/ directory.

  • Three shipped example workflows run again. greet-and-fetch.yaml,
    saga-order.yaml (all five activities) and support.yaml declared
    type: function with no name, which RM-GA-P4 HLTH-901 turned into a permanent
    activity failure when it unified function and tool onto 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 name echo explicitly.
    The comment in crates/wovyr-runtime/src/lib.rs
    that justified the unification claimed "every real example/test that uses
    type: function already expects a tool invocation" — it did not, and that wrong
    premise is why the breakage shipped; it now records what actually broke, and notes
    that wovyr-workflow's own engine tests still use nameless function activities
    (they supply their own executor and never reach this dispatch, so they were never
    evidence either way). A new test walks every examples/workflows/*.yaml and fails
    if a function/tool activity lacks a nameDefinition::from_yaml can't
    catch this, since name is legitimately absent on wait/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}/quota wrote to the quota map by raw id with no
    existence check, so a typo'd project — or an empty id from a doubled slash —
    returned 200 and persisted a limit under a key no run would ever consult, while
    GET returned 200 {} 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, not Completed. After a clean
    rollback the engine transitioned the execution to Completed, so a saga whose
    activity failed was missing from ?status=failed and listed among the
    successes
    — while its own event log said workflow_failed and 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 now Failed — the compensation succeeded, the workflow did
    not — and CompensationCompleted in 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 in Compensating, by design. Chosen over adding a distinct
    Compensated state, 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::price matched 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 for gpt-4o-mini with openai/gpt-4o-mini, and cost
    is deliberately keyed on the model the upstream reports it billed, so every
    call logged no price for model and no default configured and reported
    cost_usd: 0.000000. That silently reinstated the failure class RM-AIM-P1 PRV-101
    existed to remove — wovyr-server's per-project llm_cost_per_day_usd budget 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-minigpt-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 $0 now reports cost_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-preview resolves to the o1 entry, exactly as the bare id
    already did). The request path needed no change — OpenRouter accepts the bare ids
    resolve_model/resolve_embedding_model already send (gpt-4o, gpt-4o-mini,
    text-embedding-3-small all 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_usd was logged at debug (target: "wovyr.pricing") from both
    parse_response and StreamAccumulator::finish, but only the non-streaming path
    ever did — and streaming is the default for wovyr agents run --stream and 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_LOG works at all now; every binary was logging at TRACE. The level
    filter had no effect whatsoever — not WOVYR_LOG, not RUST_LOG, not the
    documented warn default — so every CLI run (and every wovyr dev server, which
    shares the same process and therefore the same subscriber) emitted the full
    hyper_util connection-pool firehose: dozens of TRACE lines per HTTP request,
    drowning the actual output. init_logging composed the EnvFilter as an element
    of the layer vector (vec![filter.boxed(), fmt_layer]), and Layer for Vec<L>
    combines register_callsite by returning the highest interest across its
    elements (tracing-subscriber 0.3.23, src/layer/mod.rs:1788-1801). fmt::Layer
    uses the default register_callsite, which returns Interest::always(), so every
    callsite resolved to always, tracing cached it as unconditionally enabled, and
    the vector's own enabled() — an all() that would have correctly returned false
    — was never consulted. The filter is now a distinct Layered step around the
    output layers, which short-circuits to never as soon as the outer layer says so
    (layered.rs:435-450), in a named compose function 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 leaked TRACE/DEBUG/INFO events, 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 (gateway INFO, no hyper), and
    WOVYR_LOG_FORMAT=json. Filter resolution moved into a resolve_filter(explicit, fallback) taking values rather than reading the environment, so the documented
    WOVYR_LOGRUST_LOGwarn precedence 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.*) are INFO-level — so at the warn
    default nothing is exported. Set WOVYR_LOG=info when exporting traces; before
    this fix the point was moot, since an inert filter meant OTLP received everything
    down to TRACE.

  • 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's permissions: packages: write was never the problem: the package had first been created by a local,
    label-less docker push, which makes it a user-owned package connected to no
    repository, and an unlinked package grants this repository's GITHUB_TOKEN no
    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's labels:
    (so it names the pushing repository rather than a hardcoded one, alongside
    per-build revision/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.