Skip to content

Read a workflow stage as JSON in every SDK - #285

Merged
jeremy merged 2 commits into
mainfrom
workflow-stage-json
Oct 27, 2026
Merged

jeremy merged 2 commits into
mainfrom
workflow-stage-json

Conversation

@jeremy

@jeremy jeremy commented Oct 25, 2026 •

Copy link
Copy Markdown
Member

Why

hey-sdk read a workflow stage's threads out of the stage's web page, because HEY served the stage only as HTML. That broke hey-cli's "nothing scrapes a web page" rule and the API → SDK → CLI pipeline: the SDK carried an HTML parser per language (Go golang.org/x/net/html, Rust scraper, hand-written ones in Kotlin and Swift), and every change to the page's markup could break them quietly. HEY now serves the stage as JSON.

Depends on basecamp/haystack#9103. Keep this a draft until that merges and deploys: against a HEY without it, GET /workflows/{id}/stages/{id}.json has no JSON to answer.

Used by basecamp/hey-cli#380 (hey workflow stage view). Its command code needs no change: Go's WorkflowsService.GetStage(ctx, workflowID, stageID) (*WorkflowStageView, error) keeps its signature, struct fields and JSON tags.

What changes

Spec. GetWorkflowStage answers WorkflowStageDetail (id, name, created_at, updated_at, workflow_id, app_url, topics), each topic a StagedTopic: the Topic members as _topic.jbuilder emits them (no entries), plus staging_id (required), emails_count, notes_count. Smithy has no mixins in this spec, so StagedTopic lists the topic members itself. The @mediaType("text/html") blob WorkflowStageHTML is gone. Not paginated, as HEY's isn't.

Hand-written stage read, every SDK. Same public types and fields, reduced from the JSON in HEY's order: TopicID/topic_id ← id, Subject/subject ← name, EntryCount/entry_count ← emails_count (the email count the old parser read off the card's "N emails, M notes" line), StagingID/staging_id ← staging_id.

  • Go: GetStage sends the generated request as is (.json, Accept: application/json) and refuses a 200 with no JSON body. parseWorkflowStageHTML, findNode, attr, visibleNodeText and isVisuallyHidden are gone (nothing else used them), along with an orphaned workflowStageIDRe comment. golang.org/x/net drops to an indirect requirement (oapi-codegen still pulls it in).
  • Rust: Workflows::stage is get_stage plus From<WorkflowStageDetail> for WorkflowStageView. The scraper parser is gone, and with it the scraper and ego-tree dependencies.
  • Kotlin: stage is getStage plus WorkflowStageView.from(detail). internal/Html.kt is gone.
  • Swift: stage is getStage plus WorkflowStageView(detail). Services/Html.swift is gone.
  • TypeScript: the generated getWorkflowStage answers the typed stage.

The generators' text/html path stays. No modelled operation uses it now. It's still covered by the generator fixtures (Rust, Kotlin, Swift) and by the core tests, which now use a route of their own (Rust: a test Route; Kotlin: GET_WORKFLOW_STAGE.copy(html = true); Swift: a copy with html = true; TypeScript: the stage's metadata marked HTML for the test's duration). AGENTS.md no longer gives the workflow stage as the HTML example.

Conformance. The paths.json stage case now mocks HEY's JSON and asserts /workflows/8801/stages/5512.json, Accept: application/json, and the view: both threads' staging and topic ids, the full thread's subject and email count, the sparse thread's defaults. The Go, Rust, Kotlin and Swift runners already send it through the hand-written stage. TypeScript keeps it not-applicable, since its assertions are on the hand-written view, which TypeScript doesn't have. The exclusion's reason and the runner's check on it are updated.

Drift. No snapshot or provenance change. GET /workflows/{workflow_id}/stages/{id} was already in spec/route-snapshot.json (its route takes (.:format)) and already modelled, so the reverse check accounts for it. The forward check strips .json before comparing. The haystack PR adds a view, not a route, so drift-regen against it would pin provenance to an unmerged SHA for no change in the snapshot. I didn't run it.

Public API changes (breaking for Rust, Kotlin, Swift, TypeScript; none in Go's pkg/hey)

  • Rust: WorkflowStageView::parse(&str, i64) removed. Workflows::get_stage returns GetWorkflowStageResponseContent (= WorkflowStageDetail), not String. GetWorkflowStageOutputPayload removed. WorkflowStageDetail, StagedTopic and impl From<WorkflowStageDetail> for WorkflowStageView added. Workflows::stage, WorkflowStageView and WorkflowStageTopic are unchanged.
  • Kotlin: WorkflowStageView.parse(String, Long) removed; getStage returns GetWorkflowStageResponseContent; WorkflowStageView.from added.
  • Swift: WorkflowStageView.parse(_:stageId:) removed; getStage returns GetWorkflowStageResponseContent; WorkflowStageView.init(_:) added.
  • TypeScript: getWorkflowStage resolves data to the stage object, not a string.
  • Go: go/pkg/hey is unchanged; go/pkg/generated gains JSON200 and the new types and loses GetWorkflowStageOutputPayload.

parse can't stay on any honest terms: its input was the page, and keeping it would keep the parsers this PR exists to remove. The generated get_stage return type breaks the Rust API anyway. So this PR carries the breaking label (which skips api-compat-rust, and also Go's api-compat; Go's pkg/hey surface is unchanged: apidiff against main's go/pkg/hey reports no change, compatible or not). MIGRATING.md has the before/after.

HEY served a workflow stage only as its web page, so GetWorkflowStage was
modelled as an HTML blob and Go, Rust, Kotlin and Swift each parsed the page
for the stage's threads. HEY now answers the stage as JSON: the stage, and
each thread as the topic JSON HEY answers elsewhere with its staging id and
email and note counts.

Model that answer (WorkflowStageDetail, StagedTopic) and send the read as
JSON. The hand-written stage read keeps its signature and its view in every
SDK, reduced from the JSON: topic_id from id, subject from name, entry_count
from emails_count, in HEY's order. The HTML parsers go, with Rust's scraper
and ego-tree and Go's direct golang.org/x/net dependency. The generators'
text/html path stays, covered by their fixtures and by core tests on a route
of their own.

Rust, Kotlin and Swift lose WorkflowStageView::parse, and every generated
get_stage answers the stage rather than a string: noted in MIGRATING.md.
The crate no longer depends on it, and cargo deny --locked refuses a lock
file that would have to change.
@jeremy
jeremy force-pushed the workflow-stage-json branch from 90c8279 to 76ce9f0 Compare October 26, 2026 05:03
@jeremy

jeremy commented Oct 26, 2026

Copy link
Copy Markdown
Member Author

🤖 Rebased onto main at 582760f (the v0.34.0 release, #284). One file conflicted: MIGRATING.md. #284 added a "Go, TypeScript and Rust: the first release after 0.33.1" section in the same spot where this PR added its own "first release after 0.33.1" section. 0.33.1 → 0.34.0 has shipped without this change, so its section now reads "Rust, TypeScript, Kotlin and Swift: the first release after 0.34.0" and sits above #284's. The text is unchanged. Everything else, including the version bumps in Cargo.toml, the lock files and the READMEs, merged on its own. Staying a draft until basecamp/haystack#9103 merges. That PR's follow-up batches the stage JSON's counts and preloads, and doesn't change the JSON's shape.

@jeremy
jeremy marked this pull request as ready for review October 27, 2026 03:48
Copilot AI balanced review requested due to automatic review settings October 27, 2026 03:48
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-27T03:50:44.259860Z 76ce9f0 Draft marked ready
🔒 Security Review ✅ Completed 2026-10-27T03:52:04.941021Z 76ce9f0 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The API-breaking JSON contract depends on an inaccessible upstream PR, so a maintainer must verify its response shape and deployment before merging.

No open issues 🎉

What changed in this PR

Moves workflow-stage reads from HTML scraping to typed JSON across all five SDKs, aligning the API → SDK → CLI pipeline while preserving the hand-written stage-view interfaces. Deployment depends on the server support described in basecamp/haystack#9103.

Changes:

  • Models stage details and staged topics in Smithy and regenerates all SDK responses.
  • Replaces HTML parsers with JSON-to-view conversions and removes unused parser dependencies.
  • Updates unit tests, conformance fixtures, and migration documentation while retaining generic HTML transport coverage.

[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

File Description
typescript/​tests/​client.test.ts Tests stage JSON and retains HTML transport coverage.
typescript/​src/​generated/​schema.ts Adds typed stage and topic responses.
typescript/​src/​generated/​operations.ts Removes stage HTML metadata.
swift/​Tests/​HeyTests/​WorkflowStageTests.swift Removes obsolete HTML-parser tests.
swift/​Tests/​HeyTests/​WorkflowsServiceTests.swift Tests JSON conversion and error reporting.
swift/​Tests/​HeyTests/​RouteTests.swift Asserts the stage route uses JSON.
swift/​Tests/​HeyTests/​RequestTests.swift Tests JSON requests and generic HTML support.
swift/​Tests/​HeyTests/​CacheTests.swift Uses a test HTML route for cache coverage.
swift/​Sources/​Hey/​Services/​WorkflowsService+Conveniences.swift Converts JSON details into the existing stage view.
swift/​Sources/​Hey/​Services/​Html.swift Deletes the HTML parser.
swift/​Sources/​Hey/​Generated/​Services/​WorkflowsService.swift Returns typed stage JSON.
swift/​Sources/​Hey/​Generated/​Routes.swift Marks the stage route as non-HTML.
swift/​Sources/​Hey/​Generated/​Models/​WorkflowStageDetail.swift Adds the stage-detail model.
swift/​Sources/​Hey/​Generated/​Models/​StagedTopic.swift Adds staged-topic fields and counts.
swift/​Sources/​Hey/​Generated/​Models/​GetWorkflowStageResponseContent.swift Aliases the JSON response model.
spec/​shape-fingerprint.json Records the stage response fingerprint.
spec/​hey.smithy Defines the JSON stage contract.
rust/​hey-sdk/​tests/​services_workflows.rs Replaces parser tests with JSON-view coverage.
rust/​hey-sdk/​src/​services/​workflows.rs Replaces scraping with typed conversion.
rust/​hey-sdk/​src/​generated/​types.rs Adds stage and staged-topic types.
rust/​hey-sdk/​src/​generated/​services/​workflows.rs Returns typed stage JSON.
rust/​hey-sdk/​src/​generated/​routes.rs Disables HTML for the stage route.
rust/​hey-sdk/​src/​client.rs Preserves HTML coverage with a dedicated test route.
rust/​hey-sdk/​PARITY.md Documents JSON-stage parity with Go.
rust/​hey-sdk/​Cargo.toml Removes HTML-parser dependencies.
rust/​Cargo.lock Removes unused parser dependency entries.
README.md Updates the browser-form service description.
openapi.json Publishes the generated JSON response schemas.
MIGRATING.md Documents breaking APIs and conversion replacements.
kotlin/​sdk/​src/​commonTest/​kotlin/​com/​basecamp/​hey/​WorkflowStageTest.kt Removes obsolete HTML-parser tests.
kotlin/​sdk/​src/​commonTest/​kotlin/​com/​basecamp/​hey/​WorkflowsServiceTest.kt Tests JSON conversion and missing stages.
kotlin/​sdk/​src/​commonTest/​kotlin/​com/​basecamp/​hey/​RouteTest.kt Asserts the stage route uses JSON.
kotlin/​sdk/​src/​commonTest/​kotlin/​com/​basecamp/​hey/​RequestTest.kt Tests JSON requests and generic HTML support.
kotlin/​sdk/​src/​commonTest/​kotlin/​com/​basecamp/​hey/​HooksTest.kt Checks JSON decoding errors reach hooks.
kotlin/​sdk/​src/​commonMain/​kotlin/​com/​basecamp/​hey/​services/​WorkflowsService.kt Converts JSON details into the existing view.
kotlin/​sdk/​src/​commonMain/​kotlin/​com/​basecamp/​hey/​internal/​Html.kt Deletes the HTML parser.
kotlin/​sdk/​src/​commonMain/​kotlin/​com/​basecamp/​hey/​generated/​services/​WorkflowsService.kt Returns typed stage JSON.
kotlin/​sdk/​src/​commonMain/​kotlin/​com/​basecamp/​hey/​generated/​Routes.kt Disables HTML for the stage route.
kotlin/​sdk/​src/​commonMain/​kotlin/​com/​basecamp/​hey/​generated/​models/​WorkflowStageDetail.kt Adds the stage-detail model.
kotlin/​sdk/​src/​commonMain/​kotlin/​com/​basecamp/​hey/​generated/​models/​StagedTopic.kt Adds staged-topic fields and counts.
kotlin/​sdk/​src/​commonMain/​kotlin/​com/​basecamp/​hey/​generated/​models/​GetWorkflowStageResponseContent.kt Aliases the JSON response model.
go/​pkg/​hey/​workflows.go Replaces parsing with JSON-to-view conversion.
go/​pkg/​hey/​services_test.go Tests JSON reads and non-JSON rejection.
go/​pkg/​hey/​retry_policy_test.go Renames and documents the existing retry test.
go/​pkg/​generated/​client.gen.go Adds stage models and JSON response decoding.
go/​go.mod Makes golang.org/x/net indirect.
conformance/​tests/​paths.json Verifies JSON requests and stage-view fields.
conformance/​runner/​typescript/​runner.ts Updates the stage-view exclusion check.
conformance/​runner/​typescript/​README.md Explains the JSON-stage coverage exclusion.
conformance/​runner/​typescript/​not-applicable.json Updates the excluded fixture and rationale.
conformance/​runner/​typescript/​fixture-inventory.json Renames the stage fixture entry.
conformance/​runner/​rust/​Cargo.lock Removes unused parser dependency entries.
AGENTS.md Updates documentation for retained HTML support.

Review effort: Balanced

@jeremy

jeremy commented Oct 27, 2026

Copy link
Copy Markdown
Member Author

Marked ready now that basecamp/haystack#9103 has merged (02b092e9bb). There are no review threads. Codex's code and security reviews completed on 76ce9f0 with no findings. Copilot reviewed 76ce9f0 and has no open issues.
Review bodies: 1 note answered, nothing to change.

  • Copilot's review asked for the stage JSON's shape to be verified against haystack#9103. Checked against 02b092e9bb: workflows/stages/show.jbuilder answers id, name, created_at, updated_at, workflow_id, app_url and topics, which matches WorkflowStageDetail. Each topic is the topics/_topic partial (id, name, the timestamps, status, account_id, app_url, creator, contacts, extenzions, collections, is_forged_sender, latest_entry) plus staging_id, emails_count and notes_count, which matches StagedTopic.

Open for a decision: when to merge relative to haystack's deploy, for @jeremy. This PR drops the HTML parser in every SDK, so a release cut before 02b092e9bb reaches production would read the stage from a HEY that doesn't serve it as JSON yet.

@jeremy
jeremy merged commit 201fce4 into main Oct 27, 2026
36 checks passed
@jeremy
jeremy deleted the workflow-stage-json branch October 27, 2026 04:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants