Skip to content

Load testing foundations: YAML contract, engine levers, CLI load mode, run-flows Action (Phase 0+1) - #45

Merged
moosebay merged 44 commits into
mainfrom
api-load-testing-feasibility
Aug 8, 2026
Merged

Load testing foundations: YAML contract, engine levers, CLI load mode, run-flows Action (Phase 0+1)#45
moosebay merged 44 commits into
mainfrom
api-load-testing-feasibility

Conversation

@moosebay

@moosebay moosebay commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Phase 0 + Phase 1 (independent items) of the load-testing initiative, per docs/superpowers/specs/2026-08-08-load-testing-design.md. Six tasks, each individually reviewed with fix rounds, plus a whole-branch final review (zero Critical findings) and a closing fix wave.

New capabilities

  • CLI load mode: flow run f.yaml --vus N --duration 30s (ad-hoc) and --scenario <name> (from the new additive load: YAML block, constant-vus executor). Aggregate console table (p50/p95/p99/RPS/Err% per step) and additive load_report object in the JSON report. Lean execution keeps memory flat; per-VU isolation verified by all-pairs tests under -race.
  • run-flows GitHub Action (actions/run-flows/): composite action packaging released CLI binaries for CI, with JUnit/JSON reports, job summaries, and its own test workflow (fires on this PR).
  • Engine seams for Phase 2: scenariorunner VU scheduler, loadmetrics HDR aggregation, CreateFlowRunner functional options, TypeSpec load-metrics envelope (frozen wire shapes incl. threshold verdicts + environment fingerprint).
  • Measured baseline: guarded benchmark harness + docs/superpowers/specs/phase0-bench.md (~850 RPS per 10 VUs clean on dev hardware; VUs=50 findings root-caused to connection-pool defaults — Phase 2 input).

Behavior changes (per spec §4.2 register — ship as CLI minor)

  1. YAML exports now carry version: 2; imports accept absent/2, reject >2.
  2. HTTP assertions in yamlflow files now actually run (previously silently dropped on import) — flows whose assertions never executed may now fail, correctly.
  3. run: blocks execute in dependency order with strict failure modes: an unknown flow: name aborts pre-flight, an unknown name inside depends_on is ignored with a warning on stderr exactly as before (shipped example files put cross-flow step names there, so only cycles and duplicate flow names are hard errors), malformed entries error, dependency-failure skips are explicit, and a failed dependency skips-and-continues (previously the whole run aborted).
  4. devtools version / load_report.worker_version now report the real build version (ldflags injection in build:release).

Zero-default-change enforcement: 10-fixture golden round-trip corpus (byte-stable), default-path byte-identity proven against base-commit binaries, full gate suite green including new server:test:race.

Known/pre-existing issues documented, not fixed here: exporter drops run: depends_on and sub_flow_trigger nodes (goldens document both in-fixture); TestFlowRun_WebSocket flake (pre-existing, first-priority ticket); JUnit output carries no load data (documented in --help).

No visual surface — CLI/engine/CI only; desktop load-results UI is a separate future plan.

🤖 Generated with Claude Code

moosebay added 30 commits August 8, 2026 18:04
CreateFlowRunner now accepts variadic functional options. Existing call
sites (server flowexec session, CLI runner, tests) compile untouched and
keep the CPU-derived default concurrency.

WithMaxConcurrency(n) overrides the node-level parallelism cap; n <= 0 is
ignored so callers can pass a config value through unconditionally.
Adds an engine-agnostic scheduler that runs a caller-supplied iteration
function across a fixed pool of virtual users. It knows nothing about
flows: callers pass any callback.

Bounds are Duration, MaxIterations and context cancellation; whichever
comes first stops new iterations being issued, and in-flight iterations
are always drained before Run returns. Iteration errors are counted in
the summary and never abort the scenario. Sequence numbers are handed
out exactly once, contiguously from zero.

Invalid profiles (no VUs, no stop condition, nil callback) are rejected
before any work starts.
Long load runs retain every response body in the flow variable map, so
memory grows with iteration count. WithLeanMode(true) makes HTTP request
nodes write a placeholder instead of the decoded body.

The seam is FlowNodeRequest.LeanMode: that struct is built in exactly one
place in the workspace, so the flag reaches every node without touching
any node constructor or the flow builder. The body is swapped in
buildResponseVar, before it can be copied into the flow output, which
also folds the duplicated duration conversion out of RunSync/RunAsync.

Assertions are evaluated by ResponseCreateHTTP against the raw response
rather than the variable map, so they keep working on a dropped body;
only downstream extraction from response.body is given up, which is the
point of the mode.

Off by default: no option means byte-identical output to before.
The default-concurrency test compared against the package variable, which
would still pass if the default were changed to a constant. Assert the
variable itself is MaxParallelism() so that regression is caught.

Also states in the scenariorunner docs when ctx.Err() is returned and
that panics inside an iteration are not recovered.
Characterization tests for the yamlflowsimplev2 Import -> Export
pipeline, one fixture per family (request headers/assertions/templates,
if/for/for_each control flow, js/wait, graphql, websocket, sub-flows,
multi-flow run block, environments/credentials).

Each case asserts round-trip stability (export(import(export(import(x))))
is a fixed point) and pins the stable output as a .golden snapshot. This
snapshot captures today's behavior including known bugs: HTTP assertions
are silently dropped on import (GraphQL assertions survive correctly),
credentials and active/global environment selection are not re-exported.

sub_flow_trigger is deliberately excluded from the sub-flows fixture: it
has a pre-existing node-ID tracking bug (converter_node.go processSteps)
where the flow node's ID is overwritten to the flow's start-node ID but
the NodeSubFlowTrigger.FlowNodeID implementation record keeps the
original ID, so the exporter's lookup always misses and silently drops
the step. That breaks round-trip stability outright (re-import errors
with "depends on unknown step"), independent of anything in this change.
Not fixed here since it's outside this change's scope.
Standalone packages/server/pkg/loadmetrics package for load-test metrics:
HDR-histogram aggregation keyed by (step, status-class), interval frames via
Aggregator.Flush, lossless Merge across frames, and ClassifyStatus for
mapping HTTP status/transport errors (incl. timeout detection) to buckets.

Adds github.com/HdrHistogram/hdrhistogram-go as a new packages/server
dependency (1us..10min range, 3 significant figures per the frozen contract).

TDD: loadmetrics_test.go written first against the not-yet-existing API
(confirmed RED via compile failure), then loadmetrics.go implemented to
green. Covers percentile correctness against a known uniform distribution,
merge equivalence across split aggregators, status classification incl.
timeout detection, concurrent Record under -race, and RPS math.
Add a version field to the yamlflow document: absent (0) is treated as
the current version for backward compatibility, and versions newer than
this build supports are rejected with a clear error naming the offending
value and the supported ceiling. Export always stamps the current
version as the first key of the document so the schema version is
visible without parsing the rest of the file.

Golden corpus updated (-update): every fixture gains exactly one
"version: 2" line, nothing else changes.
HTTP request assertions were parsed from YAML and correctly merged
through templates (mergeHTTPRequestDataStruct), but processRequestStep
never converted them into mhttp.HTTPAssert records, so
WorkspaceBundle.HTTPAsserts stayed empty for every HTTP request on
import. They always exported fine (the exporter reads straight from
HTTPAsserts), which is why the bug was invisible round-trip: assertions
just quietly vanished on the way in.

Mirrors the GraphQL assertion conversion in processGraphQLStructStep,
which never had this bug: HTTPAssociatedData gains an Asserts field,
processRequestStep converts finalReq.Assertions into HTTPAssert records
bound to the HttpID of the request, and mergeAssociatedData folds them
into the bundle.

Golden corpus updated (-update): only the fixture exercising HTTP
request assertions changes, and only by having its assertions reappear
(including the use_request template-merge case, which now correctly
shows both the assertions from the template and the ones added by the
step itself).
Adds the four load-testing metrics/artifact models to the spec, following
the existing per-domain .tsp conventions (plain namespace, camelCase fields,
Protobuf.WellKnown.Timestamp/Map, small nested helper models):

- LoadMetricEntry: one (step, status-class) bucket for a reporting interval
  - count/errorCount/bytes, hdrHistogram bytes, p50/p90/p95/p99/maxUs
- LoadMetricFrame: intervalStart/intervalMs + LoadMetricEntry[]
- LoadRunReport: total (LoadStats) + perStep (LoadRunStepStats[], which
  spreads LoadStats alongside its step/statusClass key - mirrors how
  CommonTableFields<T> is spread into per-domain child models)
- LoadFailureArtifact: step/vu/iteration, request{method,url,headers,
  bodySample}, optional response{status,headers,bodySample} (absent on
  transport-level failures), resolvedVariables map, error, capturedAt

LoadStatusClass enum members (TwoXx/ThreeXx/FourXx/FiveXx/Error/Timeout)
mirror packages/server/pkg/loadmetrics.StatusClass's wire values; header
key/value shape mirrors HttpResponseHeader/GraphQLResponseHeader.

packages/spec/dist is gitignored and not committed anywhere in this repo
(confirmed via `git log -- packages/spec/dist`), so generated output is not
included here - regenerate with `pnpm nx run spec:build`. Verified the
generated .proto/Go/TS appear correctly and packages/server, packages/db,
packages/spec, packages/auth-lib all still `go build ./...` cleanly.

Also runs `go mod tidy` in packages/server now that the workspace builds,
correcting hdrhistogram-go's require-block placement (it was marked
`// indirect` in the previous commit because go mod tidy could not resolve
the whole module until spec:build produced packages/spec/dist).
Composite action that downloads the released devtoolscli binary for the
runner OS/arch, runs a .yamlflow.yaml file, and publishes a job summary
plus JSON/JUnit reports as outputs (json-report, junit-report, success).

Replaces hand-rolled pnpm/nx CLI builds in consumer CI: "latest" resolves
to the highest cli@* release tag via git ls-remote (the repo also cuts
desktop@/web@ releases on the same tracker), the exact asset is verified
with curl -fsI before downloading, and errors name the tried URL.
Linux/macOS only; every step is shell: bash.
Exercises the composite action end to end on ubuntu-latest and
macos-latest: downloads the latest published cli@ release, runs the
smoke fixture, and asserts the json-report/junit-report files and job
summary were produced. Triggered on pull_request for actions/** changes
plus workflow_dispatch.
Replace the DIY GitHub Actions snippet (which built via pnpm nx run
cli:build - a server-only target missing the cli tag, so the resulting
binary could not actually run flow commands) with the actions/run-flows
composite action. Keep a corrected manual alternative using install.sh
for Windows/air-gapped cases, invoked as `devtools` (the name install.sh
actually installs it as, not `devtoolscli`).
Wraps the resolvedVariables map type per the repo prettier config (checked
by root:lint:format); no semantic change - verified spec:build output is
byte-identical before and after.
The claim() comment said all stop conditions are checked before the
sequence number is reserved. Only cancellation and the deadline are:
the iteration bound is enforced after the atomic add, by discarding an
over-limit claim, so next overruns MaxIterations by up to VUs.

That distinction matters. A maintainer trusting the old comment could
delete the post-add discard believing a pre-check covered the bound,
which would silently break the exact iteration count. Moving the check
before the add would be worse still: several workers could read the same
pre-add value and all conclude they were under the limit.

Comment only; no behaviour change.
Configurable flow-runner concurrency, scenariorunner VU scheduler,
opt-in lean execution mode. Review clean after fix round 1.
RunMultipleFlows previously ran run: block entries in file declaration
order and only checked a dependency if that dependency happened to have
already run earlier in that same order, so an unknown or forward
declared dependency name was silently ignored, and a failed dependency
made the whole function return immediately with no record of what
happened to the flows that never got a chance to run.

Replace the ad-hoc map[string]interface{} re-parse of the run: block
with the typed yamlflowsimplev2 structs (gets the depends_on
scalar-or-list form for free), then topologically sort entries with
Kahn algorithm before executing anything, breaking ties by original
list order for determinism. Unknown dependencies and dependency cycles
are now hard errors naming the offending value:

  unknown dependency "Missing" in run block (known flows: A, B)
  dependency cycle in run block: A -> B -> A

Flows still execute sequentially. A flow gated by a failed or skipped
dependency is now recorded and reported as skipped (status plus reason,
through the logger and any configured reporters) instead of silently
never attempted; flows with no such gate still run even if an unrelated
earlier flow failed. The overall call still returns a non-nil error
whenever any flow failed or was skipped, preserving the existing
failure-gate contract.
Adds TestAggregatorKeysByStatusClass, tying ClassifyStatus's output directly
into Record/Key bucketing (the shape task 5's ingest will actually use and
asserting 5xx outcomes also count as errors. Also covers the zero-code/
nil-error edge case in TestClassifyStatus, and simplifies TestMergeRPSMath's
histogram setup to a single RecordValues call.
EOF
)
Composite action packaging the released CLI for CI, test workflow,
docs. Review clean, zero findings above Minor.
Code review flagged three observable behavior changes in RunMultipleFlows
beyond the brief's three permitted changes. All three are confined to
run: parsing/execution and remove a silent-failure mode, so they are
sub-parts of change 3 (dependency-ordered run: execution) rather than a
separate, undisclosed change. Pinning each with a dedicated test instead
of leaving them as incidental side effects of the rewrite:

1. TestRunMultipleFlows_UnknownFlowPreventsAnyExecution - flow not
   found is now a pre-flight check across the whole run block before
   anything executes, not a mid-loop discovery. A run block listing a
   known flow followed by an unknown one used to run the known flow and
   only then fail on the unknown one; now nothing runs, verified by the
   mock server never receiving the known flow request.

2. TestRunMultipleFlows_SingleFailureMessageShape - the aggregate error
   format changed. It used to be just the failing flow raw error text,
   picked via unordered map iteration once more than one flow failed.
   It is now the failing flow name and status wrapped around that same
   error text, built from the deterministic run order. Pins the prefix
   and suffix this package controls; deliberately does not pin the
   OS-level dial error text in the middle, which is not something this
   package produces or should assert byte for byte.

3. TestRunMultipleFlows_MalformedRunEntrySurfacesError - a run entry
   that is not a flow mapping used to be silently dropped by the old
   hand-rolled parser, via a failed type assertion that just continued
   past it. Parsing through the typed yamlflowsimplev2 struct means the
   same document now fails to unmarshal instead of quietly running
   fewer flows than declared.

No production code changes; these tests exercise behavior already
shipped in the dependency-ordering commit.
Code review verified a pre-existing, undisclosed gap that interacts with
this task's deliverable: MarshalSimplifiedYAML synthesizes the run:
block purely from flows: declaration order (the "Generate default Run
configuration" step in exporter.go) and never reads depends_on at all,
so any dependency graph declared in run: is silently destroyed on
export. An exported-then-rerun file always degrades to plain
declaration order, regardless of what the original run: block said.

Not fixed here - fixing the exporter is a fourth behavior change outside
this task's three permitted changes, and belongs on the hygiene backlog
instead. Documented so it is visible rather than silently relying on
fixture luck:

- New golden fixture run_deps_lost_on_export.yaml declares flows: in an
  order that is deliberately NOT a valid topological sort of its own
  depends_on chain (C, B, A for a C-depends-on-B-depends-on-A chain, the
  reverse of the correct A, B, C). The committed golden shows the
  synthesized run: block reproducing that same wrong flat order with no
  depends_on at all, so the destruction is visible in the diff rather
  than hidden behind an order that happens to look right.
- Both that fixture and sub_flows.yaml (which documents the separate
  sub_flow_trigger export bug) now carry a short comment naming the gap
  and the exact lines responsible, so a future contributor does not have
  to re-derive it from scratch.

No production code changes.
Finding 1 from review: TestMergeRPSMath only exercised the RPS formula
against a hand-built Frame, never the real Flush() path, so a regression
reintroducing a blind echo of the nominal interval would go undetected.

Adds TestAggregatorFlushIntervalReflectsElapsedTime: drives real Flush()
twice with different sleep windows (20ms, 80ms) against a 5-minute nominal
interval, asserts Interval tracks actual elapsed time (not the nominal
constant) and differs in magnitude between flushes, and asserts IntervalStart
advances. Verified the test has teeth by temporarily mutating Flush to echo
the nominal interval instead of computing the real elapsed time - confirmed
it fails for exactly the mutated fields, then reverted (net diff to
loadmetrics.go is zero; the interval-unused design itself was independently
reviewed and ruled correct, matching the design doc's flushed-at-interval-
and-at-run-end semantics).
Finding 2 from review: LoadRunReport only carried total/perStep, omitting
two of the three things the design doc assigns to it (RunReport = merged
frames + threshold verdicts + environment fingerprint for baseline
comparability - docs/superpowers/specs/2026-08-08-load-testing-design.md
section 3.3, explicitly "the hard-to-retrofit piece designed in Phase 0").

Controller ruling was to add the fields now (optional), not defer them,
since a deferred doc comment would recreate the retrofit risk Phase 0
exists to remove.

Adds:
- LoadThresholdVerdict: expression (string, the threshold as configured),
  success (boolean), observedValue (optional string, stringified since
  thresholds may target durations, rates, or counts). The success field
  name mirrors HttpResponseAssert/GraphQLResponseAssert.success - the
  closest existing precedent for "verdict of evaluating an expression"
  in this spec.
- LoadEnvironmentFingerprint: workerVersion/region/machineClass, all
  optional strings. Modeled as its own small nested model (not inline
  fields on LoadRunReport) mirroring flow.tsp's Position model - a small
  named value group with no decorators, no primary key, referenced
  directly as another model's field type.
- LoadRunReport gains thresholds?: LoadThresholdVerdict[] and
  environment?: LoadEnvironmentFingerprint, both optional (absent for
  exploratory runs with no thresholds, or for local/desktop runs with no
  cloud environment). Doc comments on both note the shape is frozen in
  Phase 0 but nothing populates them until thresholds/the Stresseur
  worker fleet ship in Phase 2.

Regenerated via spec:build; verified byte-identical output across two
consecutive cache-bypassed runs (idempotent), and packages/server and
packages/spec still `go build ./...` cleanly.
Golden round-trip corpus, version field, HTTP assertion import fix,
run-block dependency ordering with strict failure modes. Review clean
after fix round 1.
HDR aggregation package + TypeSpec models incl. threshold verdicts and
environment fingerprint. Review clean after fix round 1.
Ignore .superpowers SDD scratch in prettier.
Adds the `load:` section from the load-testing design spec: named scenarios
that reference a flow by name, so flows are never edited to be load-tested.

Only the constant-vus executor is implemented; any other value errors naming
the offending executor, the supported set, and that ramping-vus and
constant-arrival-rate arrive in Phase 2. Every validation error names the
scenario it came from.

Scenarios ride on WorkspaceBundle purely so the YAML round trip preserves
them - they are not database-backed, and the field documents that.

Existing goldens are byte-unchanged; the new load_scenarios fixture pins the
block's export, including duration normalization to Go's canonical form.
Wires the VU scheduler, the flow engine and the metrics envelope together:
each virtual user gets its own HTTP client (so its own cookie jar and
connection pool), its own instance of every flow node, its own persistence
side-channels and its own metrics aggregator, which are merged at the end.

Duration reaches the scheduler through RunProfile only - deriving it from a
context deadline would make every successful timed run report
DeadlineExceeded.

Lean mode is always on, and the response side-channel is drained and
discarded so nothing persists per iteration; the drain measures response
sizes on the way past, which is where the report's byte counts come from.

A completed run is a success even with failing requests. Only an unreachable
target - every VU failing its very first iteration - is a setup failure.

Node graphs are built per VU rather than per iteration: node implementations
hold configuration only, so a rebuild buys no isolation while costing ~52% of
a zero-latency iteration.
The console gets the aggregate table - p50/p95/p99, RPS and error rate per
step plus a TOTAL row - and a context block above it that states which steps
the numbers cover, since lean mode only reaches HTTP request nodes.

The JSON report keeps writing the bare array of flow results it always has.
Only a load run switches it to an object, so the additive load_report has
somewhere to live and no existing consumer sees a change. Inside it the
metrics ride as the spec's LoadRunReport, which is what makes the CLI's
report the N=1 case of the message the fleet will stream later.

Status classes cross that boundary through an explicit mapping, with
loadmetrics.StatusClass as the source of truth; a round-trip test fails if
either side gains a value the other lacks. Per-step rows are sorted so map
iteration order never reaches the file.
Adds load mode to `flow run`. --scenario picks an entry of the file's load:
block; --vus with --duration and/or --iterations describes a profile inline.
The two forms are mutually exclusive, since a scenario already carries a
complete profile and combining them would silently discard one.

Load mode is decided from which flags the user passed rather than from their
values, so `--vus 0` is a load run with a bad profile - and says so - rather
than a silent fall-through to a functional run.

A load run drives exactly one flow: the scenario's, the positional argument,
or the file's only flow. Anything ambiguous names the candidates instead of
guessing.

Exit codes follow the load-testing convention: a run that completed is a
success even with failing requests, since gating on error rates is what
thresholds will do. Only a run that could not happen exits non-zero.

Without any load flag the command behaves exactly as before, verified by
diffing console and JSON output against a binary built from the base commit.
scenariorunner and loadmetrics are the two packages whose whole job is to be
correct under concurrency, and the plain suite runs without -race. This gives
them a dedicated target so the race detector is a gate rather than something
someone remembers to run.

Scoped to those two packages and -count=3 to stay fast enough to run
routinely. Wiring it into CI is separate.
moosebay added 14 commits August 8, 2026 20:46
An aggregator's interval begins when it is constructed, and construction
happens while VUs are being built. Left alone, the wall time the report
divides by includes setup, so RPS reads low by however long building the node
graphs took - which grows with the VU count, exactly when the number matters.

Flushing the empty setup frame away restarts every interval together at the
instant the scenario starts.

The new test also pins the surrounding contract: RPS comes from real elapsed
time, never from the aggregator's nominal flush interval.
The isolation assertion sampled a single node per VU through a randomly
ordered map range, so a build-once-and-share regression only tripped it when
the samples happened to differ. Under a shared-graph mutation it caught the
regression 92 times out of 100 - and that rate falls as flows grow, since
more nodes make distinct samples more likely, so it degraded exactly as
fixtures got realistic.

Now every node is compared across every pair of VUs over a sorted key list.
The same mutation is caught 100 times out of 100, and the failure names the
node and the pair.
A soak whose first iteration per VU hit a cold target and then ran cleanly
for half an hour exited 1 with no table and no JSON. The exit code was right;
throwing the measurements away was not, because a failed run's numbers are
the ones someone actually needs.

Run now assembles the report before deciding the error and returns both, on
the setup-failure path and the cancellation path alike. Result.Ran() tells a
caller whether a report exists at all - false only when the run never
started, so a bad profile still prints nothing but its error.

The CLI renders the table and writes the JSON whenever a run executed, then
exits non-zero on the failure.
LoadScenarios rides on WorkspaceBundle so the YAML round trip keeps the
load: block, but there is no schema behind it. Importing such a file into a
workspace therefore loses it, and a later export will not bring it back -
silently, which is the wrong way to lose data.

Import now says so at warn level, naming the count and the scenarios. Storage
stays out of scope; this only makes the gap visible until it exists.
load: YAML scenarios, loadrun VU execution wiring scenariorunner +
loadmetrics, aggregate console/JSON reporting, load flags, race target.
Review clean after fix round 1; all 10 binding contract additions verified.
Measures what one load worker can actually generate, driving the real
apps/cli/internal/loadrun engine (not a reimplementation of its scheduling)
against a local, in-process httptest target with a fixed 5ms handler latency
- no external network dependency of any kind.

apps/cli/test/loadbench/loadbench_test.go: the target server (a /single route
and a /chain route sharing one atomic token counter), the two flow fixtures,
and fixture-correctness tests that run in the default suite (no guard). The
chained-flow fixture chains for real - step N sends the exact token step N-1's
response issued - which TestChainedFlowReallyChains and
TestChainedFlowMultipleIterationsStillChain both pin. Chaining goes through a
response header rather than the body: load mode always runs in lean mode,
which replaces the whole decoded body with a fixed placeholder once
assertions run, so a body-based chain would silently chain the placeholder
instead of a real per-response value. setupFlow duplicates loadrun's own test
helper (unexported, so it can't be imported) rather than inventing a third way
to wire a flow - the load-mode docs already flag this pattern's duplication of
cmd wiring as an accepted tradeoff.

apps/cli/test/loadbench/integration_loadbench_test.go: the actual RPS/
percentile matrix across VUs x {1, 10, 50} x {single-get, chained-5-step},
gated by both a loadbench_integration build tag and RUN_LOADBENCH=true so it
never runs by default. VUs=50 against this fast a target pushes throughput
high enough (thousands/sec) that packages/server/pkg/httpclient.New()'s
reliance on http.DefaultTransport's default MaxIdleConnsPerHost=2 exhausts
this machine's ephemeral port range - confirmed independently of the flow
engine with a raw net/http repro. Cells run VUs-major, each in its own t.Run
(so its server and DB are torn down before the next cell starts), separated
by a 35s cooldown longer than this machine's TIME_WAIT - otherwise one cell's
port pressure bleeds into the next and inflates its error rate, which is
exactly what an earlier, uninstrumented version of this harness got wrong
(a VUs=50 cell reporting 98.5% errors that were mostly leftover pressure from
the VUs=10 cell three seconds earlier).

docs/superpowers/specs/phase0-bench.md: the results table, environment block,
methodology, and the full anomaly writeup above - flagged throughout as
dev-hardware-only numbers that gate spec section 3.5 capacity math and
section 6 pricing as an upper bound, pending a Fly shared-cpu-machine run.
Guarded loadbench matrix driving real loadrun; measured dev-hardware
numbers in docs/superpowers/specs/phase0-bench.md. Review clean,
zero findings above Minor.
Root-causes the two version-string defects together: version.go's version
becomes a var so -ldflags -X can inject it, and taskfile.yaml's
build:release now does so, so `devtools version` identifies the build
instead of always printing v0.1.0.

Also: design-doc dates the "current state" table to branch base 36d6306
and documents the run: skip-and-continue semantics; adds direct unit tests
for the run: topological sort (declaration-order tie-break, self-dependency
cycle, duplicate-flow-name error) plus integration coverage for the
transitive skip cascade and unrelated-flow continuation; fixes a duplicate
flow name in a run: block producing a blank "dependency cycle in run
block: " error instead of naming the duplicate; guards the yamlflowsimplev2
golden test against silently passing on an emptied corpus; adds a
zero-request FormatLoadTable case (the GraphQL-only-flow shape); documents
that JUnit output carries no load data; fixes goimports ordering in
importer_load_test.go; and gives finalize.sh the same "why no set -e"
rationale write-summary.sh already has.

No engine behavior change except the duplicate-flow-name error message
(explicitly scoped: parseRunEntries only, topoSortRunEntries untouched).
Goldens untouched.
$GITHUB_STEP_SUMMARY is a unique file per step, so the later assert step
could never observe the summary the composite action appended inside its
own step — the greps read a freshly created empty file and the check
failed deterministically on both ubuntu-latest and macos-latest.

Replace the three summary greps with jq assertions on the JSON report the
summary is rendered from: both smoke flows present, status success, and a
non-zero duration. Those are exactly the fields write-summary.sh reads, so
summary data correctness is covered transitively. A comment records why
the summary must not be grepped again.
topoSortRunEntries hard-errored on any depends_on name that was not a
flow in the same run: block. Shipped example files list cross-flow *step*
names there — a pattern users copied because the pre-topological-sort code
silently ignored anything it did not recognise — so the hard error broke
4 of 5 CLI integration fixtures and every user file following them.

Unknown depends_on names now drop out with a warning on stderr that names
the offending value, the valid flow names, and the fact that step-level
dependencies in run: are unsupported and ignored; execution proceeds
exactly as if the name had not been written. Cycles and duplicate flow
names stay hard errors — they are structurally invalid, not a
compatibility surface — and a run: entry whose flow: value names a
nonexistent flow still aborts pre-flight.

Example fixtures are deliberately left untouched: keeping them working
unmodified is the point. Behavior-change register 4.2 row 2 updated.
…med after

build:release interpolated PLATFORM into the output filename only. GOOS and
GOARCH were never set, so every matrix row in release-go.yaml built for the
architecture of its own runner and simply renamed the result. Both
cross-compiled rows share a runner with their x64 sibling, so cli@1.0.3
published a linux-arm64 asset and a win32-ia32 asset that were byte-identical
x86-64 binaries and could not run on the platforms they advertised.

Derive GOOS/GOARCH from PLATFORM (darwin/linux pass through, win32 to windows;
x64 to amd64, ia32 to 386, arm64 to arm64) and export them next to the
existing CGO_ENABLED=0 — the CLI is pure Go, so every target cross-compiles.
Also derive BINARY_SUFFIX=.exe for windows targets, which release-go.yaml
never sets, so Windows assets stop shipping extensionless. An explicitly
passed BINARY_SUFFIX still wins, and with no PLATFORM the build falls back to
the host toolchain exactly as before.

Verified locally: linux-arm64 is ELF ARM aarch64, linux-x64 is ELF x86-64,
win32-ia32.exe is PE32 Intel 80386, all with distinct checksums. Existing
1.0.3 assets are left untouched; 1.1.0 ships correct ones.
@moosebay
moosebay merged commit 25299f8 into main Aug 8, 2026
11 of 12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant