Skip to content

chore(release): promote main to release for @workflowbuilder/temporal 0.1.0 - #166

Merged
piotrblaszczyk merged 23 commits into
releasefrom
main
Sep 21, 2026
Merged

piotrblaszczyk merged 23 commits into
releasefrom
main

Conversation

@piotrblaszczyk

Copy link
Copy Markdown
Contributor

What this is

Promotion of main to release for the first npm release of @workflowbuilder/temporal (0.1.0, bumped in #165). main has been the only source of release since #165; this PR is that step.

What release receives

Everything on main since the SDK 2.3.0 release: the temporal package and its release prep (#156), the manual-only docs deploy (#164), the version bump (#165), plus the SDK, UI and docs work merged in between. Only @workflowbuilder/temporal gets a tag; the SDK and UI keep their pending changesets and stay at their current versions.

After the merge

  1. From the tip of release: pnpm publish --dry-run --no-git-checks in packages/temporal, then the manual publish of 0.1.0 (npm cannot register a trusted publisher for a name that does not exist yet).
  2. Trusted publisher for release-temporal.yml and "Require 2FA and disallow tokens" on npm.
  3. pnpm release:tag temporal: the workflow sees the version on npm, skips the publish and creates the GitHub Release from the CHANGELOG.

The docs site is not deployed by this merge (deploy-docs.yml runs by hand only) and stays as it is: the site still documents SDK 2.3.0, and the temporal package has no pages on it yet.

examples-workflowbuilder and others added 23 commits August 13, 2026 13:53
* feat(examples): add a StackBlitz-runnable starter for the SDK

* fix(examples): point StackBlitz file param at app.tsx

* feat(docs): embed the runnable starter and fix its example nodes

* fix(examples): show only the preview pane in the StackBlitz embed

* fix(docs): fixed link to Stackblitz so it works after the merge
* feat(ui): add @workflowbuilder/ui and @workflowbuilder/ui-tokens packages

* fix(ui): correctness fixes from review

- SegmentPicker: type onChange/onSelect/onClick event as React.MouseEvent
  instead of MouseEventHandler (types vs runtime mismatch in public API)
- Modal: apply className and ...rest to the same root element with ref,
  not split across the modal and content divs
- Button: log a clear error for children that match no variant instead of
  silently rendering null
- tokens: drop the swallowing try/catch in ejectTokens so a failed write
  aborts the build instead of feeding empty/stale JSON to the CSS step
- tokens: lift tsconfig lib to es2021 (String.replaceAll)
- add per-package lint-staged configs so in-package tsc runs on commit

* refactor(ui): review polish pass

- remove empty output.json and stale TODO in src/index.ts
- rebrand vite lib + combine-css plugin names away from Overflow UI
- Shape: replace '' sentinel with explicit 'default'
- Snackbar/NodePanel JSDoc match actual behavior; check-built-css comment
  no longer references a non-existent stylelint rule
- Switch: drop redundant styles prop (keep className)
- Tooltip: inline the constant open/close delays, drop the speculative
  delay context (placement context kept)
- tokens: scope codeChunks to generateCSSBundle; fix double-slash token dir

* test(ui): add Vitest suites for ui + tokens pure logic

Set up Vitest in @workflowbuilder/ui (jsdom) and @workflowbuilder/ui-tokens
(node) and wire both into the root pnpm test.

Extract the pure helpers that were buried in component files into sibling,
non-barrel modules so they can be unit-tested without rendering: date-picker
date-utils (parseDateValue/normalizeInitialValue/dayjsTokenToDateFns), and the
menu/tooltip placement mappers. Public types are re-exported so the package API
is unchanged.

Coverage: button structural guards, rangeBetween, getValidShape, the date
parsing/timezone logic, placement->side/align + offset mapping, and toFileName.
50 ui + 4 tokens tests. Exclude specs from the dts build so they don't ship.

* chore(ui): release-prep for npm publish

- narrow @base-ui/react peer range to ~1.4.0 (1.5/1.6 regressed transitions)
- resolve the bundle-vs-dependency split: date-fns, react-day-picker, clsx and
  Phosphor are bundled into dist, so move them to devDependencies; only the
  external react-textarea-autosize stays a runtime dependency (peers unchanged)
- rewrite npm description/keywords away from the diagram-library boilerplate
- fix the README dev section (real pnpm --filter commands; no preview-page) and
  drop leftover 'Overflow UI' branding
- correct CLAUDE.md: ui-tokens is private (not in changeset ignore)

* ci(release): scoped per-package release tags + ui publish workflow

Retire the single-package v* tag scheme now that the repo publishes two
packages. Each package releases via its own scoped tag and workflow:
- @workflowbuilder/sdk@X.Y.Z -> release-sdk.yml (trigger + version-parse updated)
- @workflowbuilder/ui@X.Y.Z  -> release-ui.yml (new; OIDC trusted publisher,
  provenance, build:ui, version/idempotency checks, GitHub Release)

Add a UI lint/typecheck/test/build job to pr-check. Update RELEASE.md and
CLAUDE.md for the scoped-tag two-package flow and correct the stale changeset
ignore claim. Each package needs its own npm trusted publisher (manual, noted
in RELEASE.md).

* chore(ui): make @base-ui/react a regular dependency, not a peer

ui is the sole consumer of Base UI and is version-locked to 1.4.x (later
versions regressed transitions), so it should own that pinned version rather
than delegate it to every consumer via a peer range. Move @base-ui/react to
dependencies (catalog: -> 1.4.1 at publish); react / react-dom stay peers as
true singletons. Consistent with react-textarea-autosize, the other external.

* fix(ui): ship react-day-picker styles so the calendar renders

react-day-picker v9 ships no styles of its own; the component never imported
its stylesheet, so the calendar grid, month caption and nav rendered with
browser defaults (vertical nav, bordered day buttons). Import
react-day-picker/style.css so libInjectCss bundles it into dist (and the docs
combined index.css), giving the calendar its real layout.

* fix(ui): theme the date-picker calendar and add day hover

react-day-picker ships a default blue accent and no hover state. Re-theme it
to the design tokens: selected days get a filled --ax selected background
instead of the accent ring, today uses a themed accent color, and day buttons
gain a hover background (react-day-picker has none). New tokens:
--ax-public-date-picker-date-{hover-background,selected,today}-color.

* fix(ui): theme the date-picker month-nav chevrons

react-day-picker paints the prev/next chevrons with --rdp-accent-color, which
defaulted to a raw blue and clashed with the rest of the themed calendar. Paint
them with a neutral nav token instead, and retheme --rdp-accent-color (focus
rings, range endpoints) to the design-token accent so no raw blue remains.

* fix(ui): IconSwitch uncontrolled icon swap + Accordion double toggle

- IconSwitch picked the thumb icon from the controlled `checked` prop, so in
  uncontrolled mode the icon never swapped. Render both icons and swap them via
  the switch's `data-checked` state in CSS (works controlled + uncontrolled).
- Accordion fired `onToggleOpen` twice when the chevron was clicked: the
  Collapsible button's toggle and the header's onClick both ran. The header is
  the single click target now (Collapsible is display-only, controlled via
  isExpanded); also fixed aria-expanded to reflect isExpanded.

* docs(ui): correct and extend the CHANGELOG for the beta

- @base-ui/react is a regular dependency now, not a peer (the entry still
  claimed peer); react / react-dom are the only peers.
- document the review-driven breaking changes (Switch styles prop removed,
  Shape '' -> 'default', SegmentPicker event type, Modal prop target) and add a
  Fixed section (calendar styling, Accordion double-fire, IconSwitch, Button).

* chore(ui): release as 2.0.0 with a fresh changelog

First release under the @workflowbuilder/ui name, versioned 2.0.0 to sit on the
Workflow Builder 2.x line. Rewrite CHANGELOG.md from scratch: a short note that
the library moved from @synergycodes/overflow-ui (with a link to its old
changelog) plus a single 2.0.0 entry covering the highlights and the migration
deltas. Update the README technology note to match.

* fix(ui): wrap remaining component CSS in cascade layers, guard against regressions

Several component stylesheets (node ports, radio buttons, edge labels,
snackbar, node icon/description, base typography) shipped rule bodies
outside the ui.base/ui.component layers, letting unlayered CSS beat
layered CSS regardless of specificity - the same bug class that shipped
collapsed decision-node ports (WB-190).

Extend check-built-css.ts with a second, independent check that fails
the build if any dist CSS rule sits outside an @layer block, with
documented exceptions for :root custom-property blocks, the deliberate
react-day-picker override, and verbatim ui-tokens output.

* chore(ui): Apache-2.0 license, externalize @phosphor-icons/react, export barrel gaps

- License: Apache-2.0 (was MIT), LICENSE file copied from root and added
  to the published files array, publishConfig.access set to public -
  matches the packages/sdk precedent.
- @phosphor-icons/react moves from devDependencies to a regular
  dependency and joins the Vite externalPackages list, so it's imported
  rather than bundled - same treatment as @base-ui/react.
- Export CollapsibleProps and IconSwitchProps, which were already
  reachable through the barrel but not exported as types.
- Extract tooltip's open/close delay constants and placement context
  into tooltip-context.ts so the barrel export of tooltip.tsx no longer
  leaks internals.
- Fix a stale "Overflow UI" reference in css-layers.md.

* chore(tokens): add lint/typecheck scripts, README, and CI coverage

packages/tokens had no eslint config, no lint/typecheck scripts, and no
README, and its checks weren't wired into pr-check.yml despite feeding
packages/ui's build. Add the non-React eslint re-export used by
packages/types and packages/execution-core, document the tokens.json ->
Style Dictionary -> --ax-* CSS pipeline, and fold lint/typecheck/test
into the existing UI CI job.

* docs: document packages/tokens in CLAUDE.md workspace tables

* chore(docs): remove orphaned apps/docs/src/generated/ui-api.json

Stale generated artifact from an earlier TypeDoc setup; unreferenced
anywhere in the repo and not produced by the current docs build.

* chore: sync pnpm-lock.yaml after rebase onto main

* fix(ui): require react 19 in peer range

* refactor(ui): rewrite built-css layer check on postcss

Replaces the hand-rolled CSS scanner with postcss (already a
devDependency). Also tightens the :root exemption: a top-level :root
block now passes only when every declaration is a custom property.

* fix(ui): export WithIcon from the barrel so TypeDoc resolves the icon prop

* fix(ui): pin the select caret to the trigger's far edge

* refactor(tokens): derive token-set paths once from a validated manifest

- config.ts names sets exactly as tokens.json exports them; buildManifest()
  validates the config up front and lists the available keys on mismatch
- Style Dictionary runs with brokenReferences: 'throw' - a dangling token
  reference fails the build instead of shipping literal {token.path} values
- dist/tokens.css inlines the primitives (no relative @import), so the file
  survives being copied out of the package alone

* fix(ui): stamp the @layer order statement into every built stylesheet

In the built barrel, libInjectCss evaluates per-chunk CSS before the entry
CSS, so the order statement loaded last and the cascade inverted (ui.base
beat ui.component) for every consumer following the README import path -
secondary/ghost buttons rendered white-on-transparent. Duplicate statements
are no-ops, so stamping the statement into each dist stylesheet makes any
load order safe: barrel, subpath imports, and files copied out alone.

combine-css-bundle now also throws when dist/assets is missing instead of
silently skipping the published entrypoints.

* fix(ui): layer react-day-picker styles under ui.base

The calendar was the one component whose styles lived outside the layer
contract: react-day-picker's stylesheet shipped unlayered and our theming
had to stay unlayered too, winning by specificity. The stylesheet now joins
ui.base at import time (@import ... layer(ui.base)) and the theming moved
into @layer ui.component, winning by layer order alone.

* fix(ui): inject box-sizing only into known styling contexts

The plugin visited every rule, so @Keyframes steps got box-sizing too and
the declaration became part of the animation (animated values override
normal author styles while running). Injection now allowlists plain styling
contexts (media/supports/container/layer) and skips anything unknown - a
missed redundant declaration is easy to spot, a wrongly injected one is not.

vite-plugin-dts excludes root-level *.spec.mts: a spec escaping the filter
drags vitest's type graph into the ts program and stalls the build.

* feat(ui): extend the built-CSS guard with order, name, and surface checks

Three new checks close the guard's blind spots:
- every dist stylesheet must lead with the @layer order statement (the
  inverted-cascade class was invisible to the old checks)
- only layer names declared in src/styles/layers.css may appear - a typo
  creates an undeclared layer that silently wins the cascade
- every *.css entry in package.json exports must exist in dist, and no dist
  stylesheet may use @import (self-containment)

The rdp- substring exemption is gone: react-day-picker styles are layered
now, so the guard runs with no exceptions. Shared FailureReport type, all
parsing through postcss.

* fix(tokens): reject duplicate config sets and match primitives by basename

Two latent traps from adversarial review: a set listed twice (or in both
groups) made two builds race for one output file, and the theme filter
matched primitive names as substrings of the whole source path - a set
whose kebab name appears in the directory prefix (e.g. 'Tokens' vs
./dist/tokens/) would have silently emptied every theme.

* fix(ui): close guard and plugin gaps found in adversarial review

Guard (check-built-css.ts):
- allowlist matches exact dist paths, not basenames - a component entry
  named 'tokens' no longer inherits the token files' exemption
- exports check walks conditional-export objects, not just string targets
- layers.css is parsed for the @layer statement with a clear error instead
  of an unchecked .first cast that crashed on a leading comment
- comments inside :root token blocks are no longer flagged

Plugin (postcss-box-sizing.mts):
- ancestor walk is properly typed (Document in the parent chain surfaced
  TS2322 under strict) and the :root skip covers grouped/qualified selectors
- dropped the inert vendor-prefix stripper; spec grew to 10 cases

tsconfig now includes the root build .mts files and scripts/ so these files
leave the typecheck blind spot; specs and vite.config.mts stay out - with
allowImportingTsExtensions (needed for the config's .mts imports) or a
vitest-importing spec in the program, tsc stalls for minutes.

* docs(ui): record the box-sizing plugin decision

Decision log for keeping build-time per-rule injection over lint-based and
selector-based alternatives (box-sizing does not inherit; portals escape
subtree selectors; a global reset leaks onto consumer elements). css-layers.md
points at the mechanism; CHANGELOG no longer claims styles.css establishes
the layer order - every stylesheet carries it now.

* fix(ui): focus the field when clicking input and text-area wrapper padding

The padding sits on the wrapper, not the form element, so clicks in that
area never focused the field. Forward pointerdown to the element;
adornment clicks keep their own behavior.

* fix(ui): soften the modal exit with symmetric ease timing

ease-out on the exit transition drops most opacity in the first frames,
which reads as an abrupt vanish; the pre-migration modal used symmetric
ease. Applied to popup and backdrop in both directions.

* fix(ui): split build tooling into its own ts program

Extending tsconfig.json's include with root build files made every tool
that reads the package tsconfig (TypeDoc in the docs generator, and
vite-plugin-dts before it) pull them into its program - TypeDoc hung
indefinitely. The component program is src-only again; build files and
scripts typecheck through tsconfig.node.json, chained in pnpm typecheck.

* fix(ui): keep react-flow handle state rules content-box

The build injects border-box per rule, so hover/connecting states of the
opted-out handle silently shrank from 1rem to 0.75rem. State rules now
repeat the opt-out; the plugin spec documents the contract.

* fix(tokens): fail the build when set keys collide after file-name normalization

* fix(tokens): clean dist before building so renamed sets leave no stale output

* fix(ui): fail the combine step when dist/assets holds no component CSS

* feat(ui): ship variable defaults inside the ui.base layer

Unlayered :root defaults raced consumer overrides by load order: a lazily
loaded component stylesheet silently restored the default. The build now
wraps top-level :root blocks in @layer ui.base (new postcss plugin for
component CSS, copy transform for the generated token files), so any
unlayered consumer override wins by layer rules. check-built-css drops
its :root and token-file exemptions and also rejects var() calls whose
first argument is not a dashed ident (var(), var(no-dashes)).

* feat(ui): widen react peer range to ^18.0.0 || ^19.0.0

Base UI itself supports 17-19 and no React-19-only API is used; the ^19
peer was declared from the build target, not an actual requirement.

* fix(ui): close the modal on a backdrop click

Dialog.Popup carried a full-screen wrapper, so every click landed inside
the popup and Base UI never saw an outside press - the modal could only
be dismissed with the close button or Esc. The popup is now the dialog
window itself (centered, capped at the viewport with its own scrolling
content area), and the orphan .backdrop-close class is gone.

* fix(ui): raise the date-picker popover like select and menu

Its Positioner was the only one without the shared popup class, so a
calendar opened inside a modal rendered behind it - which the SDK papered
over with a global z-index override. The 4px sideOffset goes away with
it: the shared class already carries that gap.

* fix(ui): raise the modal backdrop above host panels

At z-index 0 the backdrop sat below the consuming app's own panels, so
they stayed undimmed while a dialog was open and absorbed the clicks that
should have dismissed it.

* refactor(ui): trim inline comments to the traps worth keeping

Cut prose that paraphrases the code or records why a change was made -
that belongs in the commit and the PR. What stays is the handful of
non-obvious constraints: react-day-picker's selection quirks, the spec
shape that stalls the dts build, the cascade rule the state selectors
depend on.

* fix(ui): stop the text area overriding its typography class

The `font` shorthand set the size too, so a text area given ax-public-p10
rendered at the inherited 16px instead of 12px - and the taller field
pushed the properties panel into scrolling. Only the family is inherited
now; size, weight and line height come from the typography class.

---------

Co-authored-by: Jan Librowski <jan.librowski@synergycodes.com>
Co-authored-by: librowski-synergy <librowski-synergy@MacBook-Air-librowski-synergy.local>
Co-authored-by: librowski-synergy <librowski-synergy@SC-HD9N5Y40T6.local>
* refactor: migrate consumers to @workflowbuilder/ui and fix Base UI API breaks

* fix(sdk): stop publishing unresolvable ui dependency + leaked types

The SDK bundles @workflowbuilder/ui and @base-ui/react into its dist, but
declared them as runtime dependencies - so a published @workflowbuilder/sdk
would 404 on @workflowbuilder/ui (not published) at npm install. Move both to
devDependencies (they are inlined, consumers don't install them).

Add @workflowbuilder/ui to the dts plugin's bundledPackages so its types are
inlined into dist/index.d.ts instead of leaking unresolvable
import('@workflowbuilder/ui') references into the public type surface. Verified:
no real ui/base-ui imports remain in dist/index.d.ts.

Remove the obsolete docs/overflow-ui.md (described the old external
@synergycodes/overflow-ui local-dev flow this migration replaces).

* fix(sdk): finish overflow-ui migration for files main added after the branch point

Rebasing onto current main pulled in commits landed after this branch
diverged (WB-339's use-on-connect.tsx, PR #48's language-selector spec,
PR #48's ai-studio undo-redo buttons) that still imported the retired
@synergycodes/overflow-ui package. Point them at @workflowbuilder/ui
like every other already-migrated call site, and regenerate the
lockfile for the fully rebased dependency graph.

* fix(sdk): externalize @base-ui/react and @phosphor-icons/react from bundle

@workflowbuilder/ui is bundled (not externalized) into the SDK, so
@base-ui/react was being inlined transitively into dist/index.js even
though the SDK never imports it directly. @phosphor-icons/react was
already a direct SDK dependency but was likewise getting bundled a
second time on top of the copy demo/ai-studio install directly.

Add both to the SDK's Vite external list and move @base-ui/react from
devDependencies to dependencies, mirroring the treatment already used
for i18next/jsonforms/immer/zustand and matching how packages/ui
declares them.

* fix(sdk): repoint dead modal CSS selectors at Base UI signal

The datepicker z-index fix (index.css) and the variable-suggestions
backdrop suppression (variable-text.module.css) both keyed off
`.base-Modal-root` and `.mantine-Popover-dropdown`, class names from
the retired MUI Base + Mantine modal/popover. Neither selector matched
anything after the Base UI migration, silently reintroducing both bugs:
a datepicker opened inside a modal rendered behind it, and the
variable-suggestions backdrop no longer disappeared when a modal opened
on top of it.

ModalProvider now toggles a `wb-modal-open` class on <body> whenever the
SDK's modal store has a modal open - a stable signal owned by the SDK
rather than coupled to @workflowbuilder/ui's internal markup. Both
selectors are repointed at it; the datepicker fix additionally keys off
Base UI's own `[data-open]` popup attribute, since the DatePicker's
popover class is a hashed CSS module class internal to
@workflowbuilder/ui.

* docs(changeset): clarify Base UI DOM changes and dependency shape

Update the move-ui-library-in-repo changeset: @base-ui/react is now a
regular dependency of the SDK rather than an inlined implementation
detail, and note explicitly that internal DOM structure and class
names of the bundled UI changed (MUI Base + Mantine -> Base UI), so
consumer styles or tests written against internal class names may need
updating. Stays a minor bump.

* fix(sdk): declare layer order in module stylesheets using ui.component

Three SDK stylesheets open @layer ui.component without declaring the
ui.base/ui.component order first. Module-graph order currently loads the
declaration earlier by luck; an import reorder or dynamic import would
register ui.component first and invert the cascade inside the ui layer -
react-day-picker's raw defaults would then beat the calendar theming.
Duplicate order statements are no-ops, so the prepends are free.

* fix(ai-studio): pin react-is to the react 19 line for recharts

Removing the overflow-ui/MUI subtree left react-is resolvable only from
stale transitive copies, and pnpm rewired recharts' react-is peer to
17.0.2 (supplied by a vitest transitive). react-is 17 does not know
React 19 element symbols, so isFragment() is always false and fragment
children inside charts would be silently dropped. Pinned in the catalog
next to react/react-dom (must move in lockstep) and declared by
ai-studio to steer the peer resolution.

* docs(sdk): shorten the modal-backdrop suppression comment

* fix(sdk): keep the modal dialog mounted so enter/exit transitions run

ModalProvider mounted Dialog.Root conditionally: it appeared with open
already true and unmounted on close, so Base UI's transition lifecycle
(data-starting-style / data-ending-style) never ran in either direction -
on every Base UI version. The root now stays mounted with open driving
it, and the last modal content is retained through the exit transition
because the store clears it on close.

* build: bump @base-ui/react to the 1.7 line

The 1.4.1 pin guarded against a modal-fade regression attributed to Base
UI 1.6; the fade was actually broken by the SDK's own conditional Dialog
mounting (fixed in the previous commit) on every version. Caret range in
the catalog restores dependency dedupe for npm consumers of the published
packages; the workspace stays locked to an exact version via the lockfile.

* perf(sdk): stop bundling @workflowbuilder/ui CSS twice

dist/style.css carried every ui rule twice: once through the explicit
@import of ui's index.css and once through the JS module graph (each
bundled ui chunk carries a libInjectCss-injected CSS import). The JS
graph vector stays - it also tree-shakes CSS of components the SDK does
not use; the redundant @import goes. Verified by marker counts halving
in the rebuilt bundle (typography 24 -> 12) and a visual pass on demo.

* docs: note that app builds consume the prebuilt packages/ui dist

* docs(sdk): extend the archival banner over the UI-library swap and fix token paths

The decision log's sections 3-4 describe the overflow-ui Vite alias and
import layout as current; the banner now covers the swap to the in-repo
library and corrects the disproven CSS-extraction claim. The token doc
pointed readers into a node_modules symlink instead of packages/ui/dist.

* docs(changeset): cover derived type shapes and the restored modal fade

* fix(deps): resolve app react from the catalog

A caret react next to catalog react-dom is the exact drift the catalog
comment warns about - React 19 requires the pair to match (React error 527).

* fix(sdk): layer the xyflow stylesheet and drop its duplicate JS import

xyflow CSS arrived through two vectors (a bare css @import and a JS-side
import in diagram.tsx), both unlayered. Once the ui library's port rules
moved into @layer ui.component, unlayered xyflow beat them and node ports
rendered as raw 6px dark dots. The stylesheet now joins the declared (and
previously unused) ext-lib layer, which the ui layer outranks; the
duplicate JS import goes away. Verified in demo: styled ports and the
node-as-port connection target both render from the layered rules.

* fix(sdk): declare a single top-level cascade layer in the stylesheet

UI chunk CSS arrives through the JS module graph and can parse before
index.css; its stamped statement then registered top-level ui first and
the SDK's reset/ext-lib/ui declaration could only append, flipping the
effective order to [ui, reset, ext-lib] - xyflow beat every component
style. Now every stylesheet opens with the identical
@layer ui.base, ui.component; statement, xyflow and the SDK resets join
ui.base, and no second top-level layer exists to race against.

Verified live in demo: only one order statement registers, xyflow's
handle defaults sit in ui.base and lose to ui.component, and an
unlayered :root override prepended before all component CSS still wins.

* fix(sdk): gate the modal portal out of server-side rendering

The always-mounted portal evaluated createPortal(..., document.body) on
every render, so SSR of a closed editor threw ReferenceError.

* fix(sdk): match the date-picker trigger override to the new markup

DatePicker renders Popover.Trigger as the button itself, so the
.date-picker > div > button override matched nothing and date/datetime
fields lost their 2.5rem trigger height.

* docs(ui): align base-ui claims with the ^1.7.0 dependency

README and CHANGELOG promised a validated 1.4.x pin and blamed later
versions for transition regressions; the regression was our conditional
mounting, and the actual dependency is ^1.7.0. Changeset records the
dependency-contract change.

* docs(sdk): drop dead promises and add an npm path to the token guide

README advertised a scroll-thumb-hover token that does not exist and
claimed the html/body/root 100vh sizing ships in the package. The token
guide only described monorepo paths and suggested editing generated dist
CSS. Both now describe what actually ships and how an npm consumer
overrides tokens.

* fix(sdk): drop the global popover z-index override

The rule flattened every open Base UI popup to 1002 while a modal was
open - including Select and Menu, which sit at 10000 - so the variable
suggestions panel at 3000 painted over an open dropdown. It also used
!important against the README's own contract and matched consumer
popups. The date-picker positioner now carries the shared popup class in
@workflowbuilder/ui, which is what the rule was working around; the body
class stays for the variable-suggestions backdrop.

* build(deps): pin base-ui to an exact 1.7.0

The keep-mounted modal depends on Base UI's transition lifecycle, and
the range ships to consumers - a caret would let a minor change it
without our test.

* refactor(sdk): trim inline comments to the traps worth keeping

* refactor(sdk): drop dead rules from the dynamic typed input stylesheet

The stylesheet declared .container--select twice; merge the two into
one rule and drop its > select child, which never matched since the
component renders the UI library Select. Remove the unused
.cursor-pointer and .reset-button classes, and a comment explaining
why .date-picker needs no descendant selector.

---------

Co-authored-by: Jan Librowski <jan.librowski@synergycodes.com>
Co-authored-by: librowski-synergy <librowski-synergy@MacBook-Air-librowski-synergy.local>
Co-authored-by: librowski-synergy <librowski-synergy@SC-HD9N5Y40T6.local>
)

* feat(docs): add live @workflowbuilder/ui component gallery

* docs: point UI-library references at @workflowbuilder/ui

* feat(docs): expand UI Library into page-per-component reference

* refactor(ui): export component prop types and add @default tags

* feat(docs): generate UI Library props and CSS tables from source via TypeDoc

* docs(ui): render examples in isolated fixed-size previews with card-style props/CSS tables

Wrap every UI Library example in a shadow-DOM ComponentPreview so components
are styled only by @workflowbuilder/ui (isolated from Starlight CSS), shown in
a fixed 2:1 dotted preview box that matches the original Overflow UI docs.
Collapse each example island to a single representative instance.

Rework the generated Props and CSS-variable references from tables into card
lists: props show a 'required' chip (required-first) instead of a line-wrapping
'?' marker, with Type/Default/description rows; CSS variables group into
Color/Size. Drop the now-unused example-frame styles.

* docs(ui): add live previews to diagram-component pages

Give the Edge, NodeIcon, NodeDescription and NodePanel pages the same shadow-DOM
ComponentPreview the UI components use, rendering each as a standalone example
(NodePanel compositions; EdgeLabel variants positioned relatively outside a
canvas) for parity with the original Overflow UI docs. NodeAsPortWrapper stays
props-only, matching the reference. Adds @phosphor-icons/react to the docs app
for the example icons.

* docs(ui): anchor the Status example to a positioned container

Status is an absolutely-positioned corner badge; rendered standalone it had no
positioned ancestor and floated to the wrong place. Wrap it in a relative box
that stands in for the node/field it marks, so it sits in the top-right corner
as intended.

* docs(ui): @base-ui/react is no longer a peer dependency

The UI Library overview told consumers to install @base-ui/react alongside the
package. It is now a regular dependency that installs automatically; react and
react-dom are the only peers.

* ci(docs): build UI before docs in the deploy workflow

@workflowbuilder/ui components and generate-ui-api.mjs's TypeDoc pass both
need packages/ui/dist to exist; add a Build UI step ahead of Build docs,
mirroring the existing Build SDK step.

* fix(docs): harden the UI API generator and guard component coverage

- Fix the generator's lint errors (renamed vars for clarity, imported
  node:process, top-level await with process.exitCode instead of
  process.exit/main().catch, matching tools/preflight.mjs's pattern).
- Strip internal engineering notes (matching /missing token/i) out of CSS
  variable comments instead of rendering them on the public docs pages.
- Treat an unresolved props type as fatal (process.exitCode = 1) instead of
  a warning, so a renamed/typo'd type can't silently ship an empty page.
- Add collectVariantProps() to merge Button's discriminated-union variant
  props (Label/Icon/IconLabel) into one deduped table with per-variant
  notes, and switch the TypeDoc entry point from index.ts (resolve) to
  src/components (expand) so those variant-only prop types get full
  reflections.
- Add a check-ui-component-coverage.mjs guard, wired into generate:ui-api,
  asserting every packages/ui/vite.config.mts componentEntries item has a
  matching COMPONENTS entry, so a new published component can't ship
  without a docs page.
- Run generate:ui-api before astro check in typecheck, so the coverage
  guard and generator failures surface there too.

The unrelated PropRow/CssVar -> PropertyRow/CssVariable renames in
props-table.astro / css-variables-table.astro are a lint-driven cleanup in
the same generator/docs-api area.

* docs(ui): document Collapsible and Icon switch, align Styles guidance

- Add a Collapsible page: intro, live example, Usage, a hand-authored
  Parts table for Collapsible.Button/Collapsible.Content (no exported
  prop type to key a generated table on, same precedent as NodePanel),
  and generated Props/CSS variables tables. List it in the components
  index.
- Add an Icon switch section to the Switch page documenting IconSwitch
  (live example, Usage, generated Props/CSS variables tables).
- Rewrite the overview's Styles section: importing from the package root
  auto-injects the layer order/reset/tokens setup, so only tokens.css is
  needed; the styles.css + tokens.css pair only applies to the
  per-component subpath-import path. Matches packages/ui/README.md and
  packages/ui/css-layers.md.

Depends on the collapsible/icon-switch COMPONENTS entries and the
Button-variant TypeDoc entry point switch landed in the generator commit.

* fix(ui): correct segment-picker shape prop's @default doc tag

The runtime default (shape = 'default' in the forwardRef destructure) was
documented as @default '' in the TSDoc comment, which the UI Library docs
render verbatim.

* chore: sync pnpm-lock.yaml with workspace typescript resolution

Resolves vite-plugin-dts, vite-plugin-svgr, and i18next/react-i18next to a
single typescript@5.9.3 peer resolution instead of a stale mixed
5.6.3/5.9.3 set, matching --frozen-lockfile.

* refactor(ui): drop redundant Partial around WithIcon in ModalProps

* fix(docs): include shared prop types in TypeDoc entry points

Helper types like WithIcon live in src/shared, outside the components
entry tree, so they got no reflection and their members (Accordion's
and Modal's icon prop) silently vanished from the generated tables.

* fix(docs): load the ui stylesheet at document level for portalled previews

Modal, Menu, Select, Tooltip and DatePicker portal their popups to body,
outside the shadow roots that carry the preview styles - the popups
rendered unstyled. The library CSS is fully layered with no reset, so
loading it globally is safe for the Starlight theme.

* docs: drop the stale import-order requirement from the ui setup page

Every built stylesheet has carried the @layer order statement since the
stamping fix, so 'import styles.css before any component' is no longer a
correctness requirement; styles.css also ships no reset, only typography.

* refactor(ui): export the full DatePicker props surface

The docs generator reads the exported DatePickerProps, but the props the
component actually accepts (value, defaultValue, placeholder, valueFormat,
type, error) lived on an unexported local widening type - the generated
table documented none of the props the page's own example uses. The
widening moves into the exported type, TSDoc included.

* fix(docs): document union and overload components through the variant merge

SegmentPicker's discriminated union was flattened first-wins: the table
claimed value is always required and typed defaultValue as never,
contradicting the page's own controlled/uncontrolled prose. NavButton
was generated from its flat base type, so children - the prop its page
is about - never appeared. Both now list their variant prop types, and
a variant's 'foo?: never' exclusion counts as the prop being absent
there instead of polluting the merged type.

* fix(docs): fail loudly on every silent-empty path in the ui-api pipeline

Four holes of the same class - the tool swallowing a problem and shipping
a plausible-looking page:
- a moved/renamed component directory made extractCssVariables glob
  nothing and exit 0 (page claims 'no CSS variables')
- an unknown slug in PropsTable/CssVariablesTable rendered the empty
  state instead of failing the build (typo = false page in production)
- a first-party type hidden behind an unresolvable utility wrapper
  (Partial/Omit) dropped its props with no trace - the class that once
  got the library reshaped to suit the generator
- findTypeByName picked the first of duplicate type names silently

* ci: build the docs site on PRs that can break it

Every docs gate (ui-api generator, component-coverage guard, astro page
rendering) used to run for the first time at release-time deploy - a
broken docs change merged green and surfaced weeks later as a red
deploy. Path-filtered to docs/ui/tokens changes so unrelated PRs pay
nothing.

* docs(changeset): record the exported ui prop-type surface

* docs: pin section index pages to the top of their sidebar groups

The index pages sorted alphabetically inside their own groups (the 'UI
Components' link sat 7th within UI Components). Order 0 plus an Overview
label matches the plugins/nodes convention; link lists use exact
component symbol names, and the section description gains sentence case.

* fix(ui): export the NavButton variant prop types from the barrel

The changeset announced them, but only NavBaseButtonProps left the
package - the variant types lived in component files outside every
barrel. Verified with a consumer-side tsc probe against dist.

* docs(ui): add default tags for runtime defaults missing from the tables

The generator reads defaults from JSDoc only; Tooltip placement,
IconSwitch variant and EdgeLabel size/state/type had runtime defaults
with no tag, so their table cells rendered empty.

* fix(docs): stop marking variant-only props as globally required

Absent variants were filtered out before the every() check, so
SegmentPicker documented value (controlled) and defaultValue
(uncontrolled) as simultaneously required - a call that cannot exist.
Required now means required in every variant; the variant note says
where a prop is required otherwise.

* ci(docs): require an MDX page for every generated component entry

The coverage guard only cross-checked vite entries against generator
entries; an entry with no page rendering its slug passed silently.

* ci(docs): widen the docs gate path filter

starlight-typedoc reads packages/sdk sources, and root manifests plus
the workflow file itself shape the build - none of them triggered the
gate, so an SDK change could break the docs build unnoticed until
deploy.

* fix(docs): let oversized examples shrink or grow the preview stage

The fixed 2/1 stage with overflow:hidden clipped wide examples
(Snackbar) on narrow layouts. The ratio is now a preferred size:
shadow-root children cap at the stage width and content restores the
automatic minimum height. Also corrects the isolation comment -
inherited typography crosses the shadow boundary by design.

* docs: complete the stateful examples and describe Snackbar's scope

Nine usage blocks called useState without importing it, so a copied
example failed to compile. Snackbar now says it is purely
presentational - no positioning, stacking, or auto-dismiss.

* docs: align pages with what the code actually does

NodeAsPortWrapper stretches the existing target handle's hit area and
adds no highlight of its own - the page claimed the whole node becomes
a highlighted port with no preconditions. The missing-token note now
describes the comments as historical markers (several referenced tokens
exist in the export today). Overview drops the 1.4.x pin claim, and the
custom-node guide gains the missing ui package install step.

* docs: fix the dead ui-library link and the headless heading

/ui-library/ has no index page - the section entry is /ui-library/overview/.
The 'Headless components' heading sat above a paragraph describing a fully
styled library built on headless primitives.

* fix(docs): scope and classify the generated CSS variable tables

Subcomponents with their own page had their variables repeated on the
parent: Button listed 17 of NavButton's, Switch 12 of IconSwitch's, and
overriding them there does nothing. The Color/Size split now follows the
value through the design tokens instead of guessing from the name, which
put edge-stroke-width under Color and the snackbar status borders under
Size.

* refactor(docs): share the component list between generator and guard

The guard regex-scraped COMPONENTS out of the generator source, so a
change in quote style would yield an empty list and pass having checked
nothing. Both now import the same module; the remaining scrape (the vite
entry list, which is TypeScript) fails on an empty result.

* style(docs): rename a loop variable flagged by the lint rule

* fix(docs): follow interface references when collecting props

The reference branch accepted only type aliases, so a prop type written
as an interface would drop its members from the table with no warning -
the entry point of the same function already handled both forms.

* fix(docs): generate the last two hand-written API tables

NodePanel had no generator entry: its CSS variable list was written by
hand and covered 10 of the 20 variables in source. The useEdgeStyle
parameter table was hand-written too, so its type is now exported and
documented from source. An entry may carry dir: null when it documents
an API but owns no stylesheet - the edge variables stay on the Edge page
instead of splitting off to the hook.

* docs: drop the private icons package from the NodeIcon example

The snippet imported Icon from @workflow-builder/icons, which is marked
private and cannot be installed from npm - a consumer copying it hits an
unresolvable dependency. It now uses Phosphor, which ships with the
package, and says that any icon library works.

* fix(docs): say when a component forwards native attributes

Input documented four props while its own example used placeholder, value
and onChange - and no page said the component extends a native element's
attributes. The generator now detects the forwarded element by walking the
prop type (through Omit/DetailedHTMLProps wrappers and first-party
aliases) and each affected page states it in one line, instead of the
table listing ~280 DOM attributes or staying silent.

* docs: say that example icons need installing

The examples import @phosphor-icons/react. It is a dependency of the
library, not something a consumer may import without declaring it - under
pnpm's strict node_modules that import does not resolve. The install page
now says so once, and the NodeIcon page points at it.

* docs: drop the prose about icon libraries

The example's import speaks for itself.

* docs: drop the missing-token section

The comments it describes are stripped from the generated tables and do
not survive minification into dist, so a reader of the docs has no way to
encounter them.

* refactor(docs): trim inline comments to the traps worth keeping

---------

Co-authored-by: Jan Librowski <jan.librowski@synergycodes.com>
Co-authored-by: librowski-synergy <librowski-synergy@MacBook-Air-librowski-synergy.local>
Co-authored-by: librowski-synergy <librowski-synergy@SC-HD9N5Y40T6.local>
* fix(execution-core): make failed runs close as Failed in Temporal
…Route node_started event through error policy (#79)

* fix(execution-worker): assign event sequence numbers in the workflow

* fix(execution-core): route a failed node_started through the error policy

* fix(execution-worker): serialize event emits to preserve SSE cursor order

* chore(tests): run tests recursively across all workspaces

---------

Co-authored-by: Dawid Aksamski <dawid.aksamski@synergycodes.com>
* feat(execution-core): require start node

* feat(sdk): add isStartNode node-data flag

* feat(ai-studio): mark trigger as start node via isStartNode flag

* feat(backend): detect workflow entrypoints via data.isStartNode

* feat(demo): mark trigger as start node via isStartNode flag

* feat(docs): mention isStartNode flag

---------

Co-authored-by: Dawid Aksamski <dawid.aksamski@synergycodes.com>
* feat(execution-core): emit node_skipped events

* fix(execution-core): tolerate node_skipped emit failure and emit skips in fatal waves

* feat(execution-core): report error_route_not_taken for dormant error branches

* docs(execution-core): add replay rule for emit-set changes, fix stale references

* fix(ai-studio): use existing text token for muted colors, add skipped state to visualize card

---------

Co-authored-by: Dawid Aksamski <dawid.aksamski@synergycodes.com>
* feat(execution-core): add execution_incomplete terminal state

* fix(execution-core): treat falsy nextPort as no port in dead-end detection

* refactor(types): single source of truth for terminal statuses and event types

* fix(backend): close the snapshot-window and cancel races on terminal states

* fix(execution-worker): refuse to overwrite a terminal execution status

* refactor(execution-core): compute root liveness upfront and return the dead end from propagate

* test(execution-core): cover the deleted-join-edge dead-end shape

* docs(execution-core): correct dead-end causes and errorRoute absorption claims

* refactor(ai-studio): derive execution status colors from semantic text tokens

---------

Co-authored-by: Dawid Aksamski <dawid.aksamski@synergycodes.com>
#110)

* feat(execution-core): add write-time secret redaction for event payloads

* feat(execution-core): record step inputs on node_started with redacted payloads

* refactor(execution-core): record visible node ids instead of output copies on node_started

* docs(execution-core): replace ticket IDs in shipped comments with follow-up slugs

* docs(execution-worker): cite Temporal blob-size warn limit

---------

Co-authored-by: Dawid Aksamski <dawid.aksamski@synergycodes.com>
* feat(temporal): add @workflowbuilder/temporal plugin package

Packages the Temporal integration as a Temporal Plugin instead of app-level
wiring. Registers the three activities on SimplePlugin, ships the workflow
runner under /workflow for the consumer re-export pattern, and moves the
client engine to /client.

Private packages (execution-core, types) are bundled into dist through a
single seam file that imports them by relative path, so the emitted types
stay self-contained. Ships with private: true until the first publish is
approved.

Worker and backend still run on their own copies of the workflow files;
repointing them onto the package is the follow-up PR.

* refactor(execution-worker): run the worker and backend on @workflowbuilder/temporal

Replaces the hand-wired Temporal setup with the plugin. The worker now declares
only what is its own (one executor per node type, the database as the store port)
and hands the rest to WorkflowBuilderPlugin; workflows.ts re-exports runWorkflow
so Temporal's bundler picks it up. The backend starts and cancels runs through
TemporalWorkflowEngine.

Deletes the files that moved into the package in the previous commit, so the
workflow logic exists in one place again. The task queue name and the workflow
id convention now come from the package instead of being repeated in both apps.

No behaviour change: the same graph runner, the same activities, the same
timeouts.

* fix(ai-studio): keep the event stream open while cancelling a run

Cancel closed the SSE connection before sending the DELETE, so the
execution_cancelled event that the worker emits a moment later never reached
the client. The panel stayed in 'running' with no terminal log line, even
though the run had been cancelled correctly on the backend.

Cancelling is asynchronous: the endpoint only asks Temporal to cancel, and the
terminal event arrives over the stream afterwards. The stream adapter already
closes the EventSource once it sees a terminal event, so dropping the early
disconnect is all that is needed.

* build(temporal): align the Temporal SDK packages on 1.23.0

@temporalio/plugin was added with a ^1.16.0 range and resolved to the latest
release, while worker, client and workflow stayed pinned at 1.16.0 from the
existing lockfile. That left the worker tested against a plugin seven minor
versions ahead of it, which the catalog comment explicitly says must not
happen.

Raises the catalog and the published peer range to ^1.23.0 so the lower bound
we ship matches the line we actually test on. @temporalio/plugin is worth
watching here: it declares neither dependencies nor peers, yet its types come
from worker, client and common, so a drift between them is invisible until
something breaks at runtime.

* docs: point references at the code moved into @workflowbuilder/temporal

The refactor moved the workflow, the event emitter and the client engine into
the package; the documentation kept pointing at their old paths.

- replay-audit.md referenced the deleted run-workflow.ts, which matters because
  the package README's versioning section cites this document
- the worker README described a file tree and a sandbox rule that no longer
  exist, and named TemporalEngine
- the backend README pointed at the deleted temporal-engine.ts
- execution-core's README and diagram still said TemporalEngine
- drain-events.ts pointed at the emitter's old location

DECISION-LOGS.md is updated by hand: the collector fails on
apps/backend/tenant-context-port.decision-log.md, which uses a "Proposed /
Landed" header instead of the "Date" the script expects. That break predates
this branch. The missing entry for terminal-states.decision-log.md is added
here too.

* fix(temporal): name the plugin workflowbuilder.WorkflowBuilderPlugin

The plugin registered itself as '@workflowbuilder/temporal', an npm package
name, which matches neither Temporal's written standard nor what their shipped
plugins do. This string appears in users' worker logs and is part of what
Temporal reviews.

Their Technical Review Standards ask for `my_library.MyPlugin` and every
example in the plugins guide passes `organization.PluginName`, while their own
@temporalio/interceptors-opentelemetry registers 'OpenTelemetryPlugin' and
@temporalio/ai-sdk registers 'AiSDKPlugin'. The dotted form satisfies the
standard and the docs at once, so it is the safer pick; a comment records why,
and a test pins it so it does not drift back.

* build(temporal): make @temporalio/plugin a peer dependency

It was the only @temporalio/* package installed as a regular dependency, so it
could resolve to a different version than the worker, workflow and client the
consumer pins. Temporal releases these together and their types reference each
other across package boundaries, so a drift surfaces as confusing type errors.

@temporalio/plugin is the easiest one to get wrong: it declares no dependencies
or peers of its own, yet its types come from worker, client and common. Nothing
stops it from moving ahead on its own, which is exactly what happened here
before the versions were realigned.

The reference worker now declares it too, so the repo exercises the same
install shape a published consumer gets rather than relying on the package's
devDependencies. README documents the one-version rule.

* docs(temporal): trim the plugin name comment to what protects the code

The rationale for picking the dotted format over the bare class name belongs to
the review thread, not to a comment that has to stay correct for years while
Temporal's own plugins keep doing their own thing. What stays is what stops
someone from breaking it: it is user-visible, Temporal reviews it, and it is
not the npm package name.

* fix(deploy): build @workflowbuilder/temporal for the production image

The runtime stage installs with --prod, so tsup is absent and the package's
`prepare` cannot build itself; .dockerignore also keeps dist out of the build
context. Since backend and execution-worker now depend on the package, the
image build failed outright on `tsup: not found`.

Adds a package-build stage that installs with dev dependencies and builds the
package, then copies only the resulting dist into the runtime stage. `prepare`
is dropped for the package as well as the root, since the copied dist is what
it would have produced.

Nothing in CI builds this image, so this only surfaced when the image was
built by hand. Verified by running both compose commands in the built image:
each now fails on unreachable infrastructure rather than on module resolution.

* build(temporal): declare the Temporal SDK packages the way Temporal's own plugins do

Reverses the earlier move of @temporalio/plugin to peerDependencies, and takes
client and workflow with it. The reason is mechanical: pnpm does not link peers
into a workspace package's node_modules, because a package linked from the repo
gets no per-peer variant. Locally the package's devDependencies hid this; a
--prod install removes them and the built dist can no longer resolve its own
imports. In the production image packages/temporal/node_modules/@temporalio
contained only what the package declared itself.

Each entry point imports exactly one @temporalio package at run time (plugin,
client, workflow respectively), so those three are now dependencies. Both of
Temporal's own plugins do the same: @temporalio/ai-sdk keeps client, workflow
and plugin as dependencies, and @temporalio/interceptors-opentelemetry keeps
plugin there too.

@temporalio/worker stays an optional peer: no entry point imports it at run
time, it is the consumer's process to create, and its native core-bridge is
146 MB, so a second copy would be expensive.

The intent behind the review comment is kept — the SDK packages still cannot
drift apart — but it is now enforced by pinning them here rather than by
requiring the consumer to install a matching set. README documents what the
consumer installs, and knip records why execution-worker keeps
@temporalio/workflow: the workflow bundler resolves it from that workspace
while compiling workflows.ts, even though no source file imports it.

* ci: run the release workflows on Node 24 so the OIDC publish works

The Trusted Publisher exchange is done by the bundled npm, and only npm 11.x
(Node 24) performs it natively when no token is configured. release-sdk.yml was
moved to 24 in fabf16c after the publish failed on authorization; the other two
workflows were written against the older shape and kept Node 22.

That went unnoticed because neither package has ever been published: the
registry returns 404 for @workflowbuilder/ui and @workflowbuilder/temporal, so
the publish step in those workflows has never actually run. The first real
release would have failed on authorization.

Also fixes release-ui.yml, which is outside this PR's scope but carries the
identical defect and the same never-executed publish path. The comment records
why the version is what it is, so it does not get levelled with the rest of the
repo later.
* build: make the pre-commit prettier respect the root .prettierignore

Every workspace re-exports the root lint-staged config, and lint-staged runs each
one with that workspace as the working directory. Prettier resolves .prettierignore
relative to the working directory, so from packages/foo it found nothing and
formatted files the root ignore list excludes.

apps/icons/src/utils/icons.gen.ts is in that list, is not gitignored, and was
therefore being rewritten by the hook on every commit that touched it, while the
CI format job left it alone. The two disagreed.

Passing the path explicitly, resolved off the config module's own location, pins it
to the root file from every workspace.

* test(temporal): replay recorded histories through Temporal's own replayer

Adds the repo's first live Temporal test harness. The determinism test in
execution-core is re-execution equivalence: it runs runGraph N times against
identical mocks and compares the call sequence. That proves a run is reproducible
against itself, which is rules 1-8 of the replay audit, but it structurally
cannot see rule 9. A change that is perfectly self-consistent and still strands
every in-flight run passes it green.

The new suite starts a real Temporal, runs a graph, then does three things with
the result: counts scheduled activities per type, replays the history it just
recorded, and replays a committed history from histories/. Only the last one
catches cross-version drift, which is why it is the one that matters at review
time.

The graph is start to (left, right) to join. The fan-out is deliberate. It is the
only shape that puts two commands in a single workflow task, which is where the
runner's Promise.all becomes visible to Temporal.

Verified by adding a fourth emitEvent to runGraph. The count assertion fails with
emitEvent 11 against 10, and the committed-history replay fails with a
DeterminismViolationError out of Temporal. The self-replay test stays green, as
it should, because that history was recorded by the same modified code.

Recorded histories are prettier-ignored so they stay byte-identical to what
`temporal workflow show --output json` emits, keeping the two interchangeable.

The audit's "Why no live runReplayHistory test" section is rewritten. The cost
objection it recorded did not survive contact: no Java runtime, in-memory server,
about two seconds. The real division of labour between the two forms is
cross-version coverage, not primitive coverage.

Tests, devDependencies and docs only, so nothing ships and no changeset is due.

WB-527

* docs(temporal): say what a red cross-version replay means before the first release

The replay rules were written as though the package had shipped: a failing
history meant patched() or a major with a drain note. Neither applies today. The
package is private with no published version, so no run recorded by an older
build exists and nothing can be stranded.

Splits the guidance by publication state. Before the first release a red test is
a design signal, that a command reached a path meant to be left alone, and
re-recording is a legitimate fix once the change is understood. After the first
release it is a compatibility break and re-recording destroys the only evidence
of what the published version did.

Adds a triage list for reading the change, since only some edits move the command
sequence. Both halves are verified rather than asserted: a stand-in for the
durable-pause seam (an update handler registered unconditionally plus an unreached
condition) replays the committed history green, while one extra emitEvent in
runGraph fails it with a DeterminismViolationError.

WB-527

* Apply suggestion from @librowski

Co-authored-by: Jan Librowski <janlibrowski@gmail.com>

* docs: shorten the root lint-staged comment to its reason

The comment walked through how Prettier resolves .prettierignore against the
working directory. That mechanism is recoverable from the two lines below it.
The reason the path is pinned to the root file is not, so only that stays.

WB-527

* test(temporal): trim replay test comments to what the code cannot show

The replay harness landed with comments that largely restated the code under
them: test names, assertion contents, a plain Promise.all, the shape already
spelled out by the nodes and edges arrays. Those go stale the moment the file
moves and add nothing at review time.

What stays is the part the code cannot carry. Why Temporal replay covers ground
that runner re-execution structurally cannot. That regenerating the committed
history destroys the cross-version baseline. The wave order, which the runner
derives topologically rather than reads from the fixture.

Replaces the arithmetic note on EXPECTED_ACTIVITY_COUNTS with named constants,
so the expression no longer needs prose to explain what its two 1s were.

WB-527

---------

Co-authored-by: Jan Librowski <janlibrowski@gmail.com>
* feat(temporal): per-node-type activity options and node labels in Event History

Two changes on one piece of plumbing, which is why they land together. Node
activities were proxied once at module scope with a single blanket profile, so
neither a per-type timeout nor a per-node summary could be expressed. The proxy
now moves inside the runner port and is built per call from the node.

Event History listed a column of identical executeNode rows. Each node activity
is now scheduled with the node's authored label as its Temporal Summary, so the
history reads like the diagram it came from.

The label reaches the engine the same way errorPolicy and role already do: lifted
out of data.properties by the backend mapper onto BaseNode. It is lifted rather
than read out of config because an engine consumes it, which is the rule that
already governs that mapper. description, the other shared property, stays in
config since nothing reads it. Blank and non-string labels are dropped, because an
empty Summary renders as nothing where Temporal would otherwise fall back to
showing the activity type.

Per-type timeouts and retry caps arrive through createRunWorkflow on the /workflow
entry point rather than through plugin options. The TypeScript SDK compiles the
workflow bundle from the consumer's own workflows.ts, so the worker-side plugin
cannot reach into it; the profiles have to be declared where the bundle is built.
Re-exporting runWorkflow unchanged keeps today's behaviour, so the documented
one-liner still works and nothing needs migrating.

Profile entries are whole ActivityProfile values, not partials. A partial would let
a caller set a timeout and silently drop the retry cap, and what Temporal falls
back to is unlimited retries with backoff, which on a permanently failing model
call is an unbounded bill. Requiring both fields makes that a compile error.

resolveNodeActivityOptions is a pure exported function so the guardrail is testable
without a Temporal environment: a type with no entry must resolve byte-identically
to the old 10m/2 attempts. Its test also pins that a node type named `constructor`
or `toString` cannot pick up an Object.prototype member as activity options, which
a plain record lookup would have allowed.

Verified: 33 tests in the package, 99 in the backend, 146 in execution-core, lint
and typecheck clean across temporal, backend, execution-worker and execution-core.
The bundling test passing is the load-bearing one here, since it proves the factory
and the per-call proxy survive Temporal's own bundler.

The remaining half of the Summary task is the hero screenshot, which needs the live
stack and a human at the keyboard.

WB-523 WB-524

* docs: correct the backend and worker ready signals

The "Agent signals" table promised two log lines that no longer exist. The
backend logs `backend listening` with the url as a structured field, not
`Backend running on <url>`, and the worker logs `execution worker started` with
the queue as a field, not `Execution worker started on task queue: <queue>`.
Both are lowercase.

Found by using the table: a wait loop grepping the documented strings never
matched while both processes were up and serving. That is the exact failure the
table exists to prevent, and it is silent, since a missing ready signal is
indistinguishable from a slow start.

Also notes that these two go through a structured logger, so the message text is
the stable part and the trailing JSON fields are not.

* docs: stop adding changesets for @workflowbuilder/temporal until it ships

The package is still `private: true` at 0.0.0, so a changeset entry describes a
change against a version nobody ever installed. Wording that is correct for an
update ("now scheduled with", "keeps the previous behaviour", "no migration
required") reads as nonsense in a first release, and the release procedure already
has the maintainer rewrite the generated CHANGELOG by hand.

One changeset is queued to seed the first release notes. That single entry gets
expanded at publish time to describe the package as it ships, instead of the first
CHANGELOG being assembled from a development diary.

Worth stating explicitly because the tooling does not enforce this either way:
`private: true` blocks publishing, not versioning, so `pnpm changeset status`
lists the package regardless and an added changeset would be consumed normally.

The pr-check guard now skips while the package is private. Keying it on `private`
rather than a date or a comment means the first publish turns it back on by
itself, with no one having to remember.

* fix(temporal): validate node activity profiles eagerly and stop sharing mutable defaults

Four findings from a review pass over the previous commit.

**A malformed profile looked like a node failure.** proxyActivities was called inside
the runner port, so a bad profile threw at scheduling time, where runNode catches it,
emits node_failed, and hands it to the graph's errorPolicy. Under 'continue' the error
is absorbed into nodeOutputs and the run closes as completed: a typo in worker
configuration produced a green run with a node that never did its work. Profiles are
now checked once, as the workflow is built, and the message names the config path and
the offending node type.

The check has to be ours. Temporal's validateActivityOptions only asserts that some
timeout is present, so a duration like '30 minutes' passes it and fails later when the
command is built, and the retry shape is never validated at all.

**The defaults were shared, not copied.** The resolver spread the profile shallowly, so
the returned `retry` was the very object exported as DEFAULT_NODE_ACTIVITY_PROFILE.retry.
One assignment changed the retry cap for every node for the rest of the process. The
existing test asserted only `not.toBe` on the outer object, so its title promised an
immutability the code did not have. Both default profiles are now frozen including the
nested object, the resolver copies `retry` as well, and the test asserts the guarantee
its name claims. A caller-supplied profile is no longer aliased either.

**The blank-label contract lived in the wrong place.** Trimming and rejecting an empty
label was implemented only in the reference backend, but any consumer can build the
workflow input and this package is the published unit. The resolver now enforces it, and
ignores a non-string label rather than throwing on `.trim`. The backend keeps its own
guard: that one is about `label` on BaseNode staying clean for everything else that
reads a node.

**A test title had become false.** "maps data.properties to config verbatim" was already
approximate, since errorPolicy was lifted before this branch, and adding `label` made it
plainly wrong. Renamed to what it actually pins, with the lifted fields asserted.

Not fixable, documented in the README instead: a misspelled profile *key* cannot be
validated, because the workflow sandbox has no access to the executor registry. Such an
entry silently never applies and the node keeps the default profile.

WB-523 WB-524

* fix(temporal): make the profile validator agree with Temporal and with its own promises

Three correctness bugs in the validator added by the previous commit. None of them
affects this repo, whose worker uses the zero-config runWorkflow with an empty profile
map, and all three would have shipped to external consumers.

**The duration check disagreed with the type in both directions.** It rejected '1.5h',
which type-checks and which Temporal parses as 90 minutes, so a type-valid value threw
at runtime. It accepted '0s', '0m' and '00h', which the server treats as unset: the
command is refused, the workflow task retries forever, and the run sits in Running with
no terminal event. That is the exact failure mode the assert exists to prevent.
Decimals are now allowed and the value must be positive.

Worth stating plainly, because the type invites the mistake: `${number}` also admits
'-5m' and '1e3s'. The runtime narrows the format deliberately, so the error message and
the README now describe the grammar that actually survives rather than gesturing at
"a duration".

**The validation was not eager.** createRunWorkflow runs in the consumer's workflows
module, which Temporal's bundler keeps behind a lazy importWorkflows() called from
initRuntime per workflow instance. A bad profile therefore produced a green
Worker.create followed by every run wedging in a WorkflowTaskFailed loop, with no
execution_failed and no status change in the product's own database. The README
promised the opposite. assertNodeActivityProfiles is now exported so worker setup can
run it outside the sandbox, where a throw fails the deploy, and the README describes
what each call site actually catches.

**The exported resolver disagreed with the assert.** After Object.hasOwn, the
`?? DEFAULT_NODE_ACTIVITY_PROFILE` branch was unreachable on any path the assert had
seen, so `{ 'my/agent': undefined }` returned the default in a consumer's test while
production threw. Removed, so the two exported surfaces answer the same for the same
input.

WB-523 WB-524

* fix(temporal): describe every rejected profile, clamp the summary, check profiles at worker start

Polish pass over the profile validator before merge.

**An undefined entry threw a bare property-access error.** The resolver deliberately
does not default such an entry away, but it then dereferenced `profile.retry` and
produced "cannot read properties of undefined", naming neither the node type nor the
rule, while the map-wide assert named both. The per-entry check is now one function
called from both places, so the two cannot word the same problem differently.

**The Summary is clamped to 200 characters.** Nothing enforces a limit on this path
today: 3 MB summaries were observed going through unchallenged. But the server already
carries a 400-byte `userMetadataSummarySize` that newer validators read, and the label
is copied whole into every ActivityTaskScheduled event, so an unbounded display string
is worth capping either way.

Note the arithmetic, since the clamp is measured in characters: 200 of them stay under
400 bytes for Latin text, but not for CJK or emoji. Capping bytes would need a UTF-8
length in the workflow sandbox, which is a question worth answering only once something
actually enforces the limit.

**The plugin takes the profile map and validates it.** Passing it to
`WorkflowBuilderPlugin` makes a bad profile fail `Worker.create`, so a deploy fails
instead of every run wedging on first activation. Nothing on the worker side reads the
map, and the two sides are not linked automatically, so the README now shows one shared
constant handed to both rather than a separate hand-written assert.

The plugin also warns when a profile is keyed by a node type with no registered
executor. That is the one configuration mistake the sandbox genuinely cannot catch,
since the workflow has no access to the registry. A warning rather than a throw,
because one workflow bundle may serve several workers that each register a subset of
the node types.

Plus the README sentence that implied node.label populates itself: lifting it out of
the editor's properties belongs to whatever builds the WorkflowExecutionInput, and the
reference backend's mapper is named as the worked example.

* docs: write down what earns a code comment, and cut this branch to that bar

The rule was given twice in review and both times the code drifted back, so it now
lives in CLAUDE.md next to the other code-quality conventions rather than in anyone's
head. Two things earn a comment: what the code cannot show (external behaviour, a
constraint that lives elsewhere, a hazard with no visible trace) and a decision other
developers need so it does not get lost. One line by default, three as the ceiling.
Anything longer belongs in a README or a decision log, with the comment reduced to a
pointer.

Applied to this branch: 96 comment lines added, now 62. Nothing was deleted that
carried a fact, only the words around those facts. The zero-timeout server behaviour,
the Object.prototype hazard, the errorPolicy absorption and the reason the plugin
accepts a map it never reads all survive, in a third of the space.

Two removals worth naming. The blank-label rationale in the backend was a duplicate of
the same explanation in the package, where it belongs, and the function it sat above
says what it does in its name. The freeze note was floating between two constants and
now sits on the one it describes.

* fix(temporal): seal the profile map, clamp the summary by bytes, make the frozen defaults readonly

The Object.freeze on the two default profiles was invisible to TypeScript, because
the annotation erased the readonly modifiers. Tuning a default in place compiled
clean and threw inside the sandbox on first activation, wedging the workflow task.

The summary clamp counted UTF-16 code units against a byte cap: 200 characters of
CJK is 600 bytes, and a cut on an emoji left a lone surrogate that renders as
U+FFFD. It now normalises whitespace to one line, since Temporal renders the
Summary as single-line markdown, and clamps on code-point boundaries by byte.

createRunWorkflow now takes a validated, frozen snapshot of the profile map, so an
entry added or edited afterwards cannot reach the resolver unchecked. That makes
the per-node assert dead code, and one validation at one boundary replaces two at
different granularities.

Also: the plugin's profile-key warning goes through an optional logger rather than
straight to console, resolveExecutor no longer resolves a node type off
Object.prototype, and the concrete AI Studio node types declare the runner-level
fields the backend lifts instead of carrying them untyped.

* fix(temporal): gather the profile checks into one module and bound the retry cap to int32

Profile validation lived in three files, mixed with the resolver, the types and the
plugin. It is a designated growth area, since four wire-format checks are deliberately
deferred, so it now sits in one module whose header names what the boundary promises
and what it does not. The README carries the rest.

findProfilesWithoutExecutor moved with it and lost its logging. The module decides what
is wrong, the plugin decides how to report it, which also makes the warning testable
without spying on console.

maximumAttempts is now bounded to int32. Above that the proto field wraps: 2147483648
arrives as -2147483648 and 4294967296 as 0, and Temporal reads 0 as unlimited retries,
so an overflowing cap becomes its own opposite.

The summary clamp drops from 380 bytes to 300. 380 bytes of content serialize to a
409-byte payload against the server's 400-byte cap, because the margin was picked
without measuring the protobuf framing. The comment now states the reason that holds
whatever that cap turns out to be: the summary is copied into every
ActivityTaskScheduled event, so an unbounded one grows Event History for the life of
the run.

The profile-key warning claimed nodes of the misspelled type keep the default profile.
They get the custom one and then fail with no executor registered. It is the type you
meant that keeps the default.

* fix(temporal): put the duration bounds on the real cliffs and stop validating per node

The [1ms, 100d] window rejected values Temporal schedules happily. '365d' is a normal
startToCloseTimeout for a heartbeating activity, and '0.5ms' is half a million valid
nanoseconds. Worse, the comment and the error message blamed Temporal for a bound this
package had invented. Both ends now sit on what a protobuf Duration actually carries.

The floor is the more interesting half. A too-short but non-zero timeout fails loudly:
the activity times out, retries, and the run closes as failed with a legible message.
Only a zero duration fails silently, because the server reads it as unset and refuses
the command. So the floor only ever needed to exclude zero, which puts it at one
nanosecond rather than a millisecond above it.

The per-node assert added in the previous commit was the wrong shape for the right
goal. On the production path it was dead, since createRunWorkflow hands the resolver a
snapshot it already validated, and the only place it could ever have fired is inside
executeNode, where the graph's errorPolicy can absorb it into a completed run. That is
what the comment beside it warns against. The resolver splits in two instead: an
internal one that trusts the snapshot, and the exported one that validates the whole
map once and delegates. The descriptive message survives, the per-node cost does not,
and the README's advice is true again.

Also: the second bundling case produced a byte-identical module graph plus one string
literal, so it paid a webpack cold start for nothing; the reference worker passed a
logger the plugin could never reach without a profile map; and execution-core's README
now says why every lookup keyed by node.type goes through Object.hasOwn.

* refactor(backend): drop the conditional-spread trio for pickBy, and pin what it guarantees

Three lines of `...(x === undefined ? {} : { x })` where the name in the guard and the
name in the shorthand had to match, and a mismatch would have compiled. mapNode is also
about to grow a fourth lifted field, so the repetition was going to get worse rather
than stay put.

Verified rather than assumed: identical keys and identical JSON across every shape
(all present, one missing, none present), and the whole thing typechecks under strict.
remeda was already this repo's utility library in four workspaces.

The tests around it turned out to guarantee less than they looked. Only `label` pinned
that the key is absent; `role` and `errorPolicy` used toBeUndefined, which passes just
as happily when the key is present holding undefined. An errorPolicy the runner does
not recognise had no test at all, so nothing recorded that it vanishes entirely rather
than falling through to `config`. Mutating the mapper to keep undefined keys now turns
five tests red instead of two.

* docs(temporal): cut the claims that go stale, and the one the README could not keep

A reviewer pointed at a comment enumerating the runner-level fields on BaseNode, which
would have gone stale the moment a fourth arrived. The same shape turned out to be in
four other places: a comment in packages/types describing what the Temporal adapter does
with `label`, so the lower layer explained the upper one; a test comment pointing at a
file this branch had already moved half its tests out of; a measured ratio nobody would
re-measure; and a field description tied to today's single use of the logger. Each keeps
the reason and drops the detail, because reasons do not age.

The README carried a worse version of the same thing. It sent readers to
`apps/backend/src/domain/mapper/from-integration-data.ts`, and `files` publishes the
README, so anyone installing from npm was pointed at a path that does not exist for
them.

It also left readers with a false model of the duration grammar. Saying the bounds do
not narrow what Temporal accepts is true of the bounds and misleading overall, because
the grammar does narrow: Temporal parses with `ms` and takes '30 minutes' or '1 week'.
Somebody who knows that would have been refused with no explanation. Now it is stated.

Two smaller ones: a byte figure with no test behind it, and an appeal to how Temporal's
own AI SDK plugin works, which is a claim about code we do not control and nobody here
would notice changing. The profile-check section also lost its table and half its
length; the measured edge cases live in the follow-up task, not in the file someone
reads to start a worker.

* fix(temporal): stop forwarding unvalidated fields from a profile to proxyActivities

The validator checked two fields and the resolver spread the whole entry, so anything
else a caller's map carried went to proxyActivities unchecked. TypeScript does not catch
it either: excess-property checks only fire on fresh object literals, and a map built
from configuration is not one.

Where it lands is the problem. proxyActivities is called inside the runner port's
executeNode, so a throw from building the proxy or from invoking it is caught by the
graph runner and handed to the node's errorPolicy. Under 'continue' the run closes as
Completed with the node skipped. That is the exact failure the eager validation exists
to prevent, reached through the one door it was not watching.

The options object is now built from the two validated fields rather than spread, which
makes the path structurally impossible. On its own that would turn a wedged run into a
silently ignored setting, so the validator also rejects unknown keys, at both levels and
naming every one it found. Throwing rather than ignoring follows the same reasoning as
whole profiles over partials: two knobs are what this package deliberately exposes, and
a third is an API change rather than a typo.

Reproduced on dist before the fix, and all four new tests were checked by mutation:
restoring the spread reddens the resolver test, dropping the key check reddens the other
three.
…he throw site (#118)

* feat(execution-core): add transient/permanent error classes, surface attempt in node_failed

* build(temporal): add @temporalio/activity dependency

* feat(temporal): map classified executor errors to non-retryable failures

* fix(docs): trim error-classification comments to what the READMEs don't cover

---------

Co-authored-by: Dawid Aksamski <dawid.aksamski@synergycodes.com>
… namespace/TLS/API-key auth (#113)

* feat(backend): call any OpenAI-compatible endpoint from the AI adapt route

* feat(execution-worker): boot without an LLM key; AI nodes fail at call time

* feat(backend): Temporal namespace and TLS/mTLS/API-key connection config

* feat(execution-worker): Temporal namespace and TLS/mTLS/API-key connection config

* build(deps): put the AI SDK packages in the catalog

* feat(deploy): pass LLM endpoint and Temporal connection config through the demo stack

* fix(config): drop the OpenRouter key alias and built-in LLM defaults

OPENROUTER_API_KEY is no longer read. As an unconditional fallback for
AI_API_KEY it was sent as a bearer token to whatever AI_BASE_URL pointed
at, so an old .env plus a repointed endpoint leaked the OpenRouter
credential. There are no external deployments to keep compatible;
rename the variable instead.

AI_BASE_URL and AI_MODEL lose their code and compose defaults too, so
nothing in the code points outside the network. The OpenRouter values
live in .env.example only. AI is configured when all three AI_* vars are
set; otherwise the worker boots and names the missing ones, AI Agent
nodes fail with ai_not_configured, and the adapt route returns 501.

* fix(execution-worker): make ai_not_configured a permanent failure

Missing AI configuration cannot recover on retry, yet the plain
NodeExecutionError was retried once and lost its code crossing the
activity boundary — node_failed carried only the message. Thrown as
PermanentNodeExecutionError it stops on the first attempt and the code
survives via the classified-error envelope.

Adds a test through a real Temporal dev server asserting the
node_failed code, a single attempt, and the workflow's failure type;
the unclassified path is pinned alongside as the contrast.

* feat(deploy): pass Temporal TLS paths through compose and mount ./tls

The apps already read TEMPORAL_TLS_CA_PATH / _CERT_PATH / _KEY_PATH, but
compose passed none of them and the docs told users to edit the
manifest. Both services now take every TEMPORAL_* variable from one
shared YAML block, so they cannot drift, and mount ./tls (override via
TEMPORAL_TLS_DIR) read-only at /etc/workflowbuilder/tls. The directory
ships empty with a .gitignore so PEMs never reach git.

* feat(deploy): let an external Temporal retire the bundled cluster

Setting TEMPORAL_ADDRESS to an operated cluster or Temporal Cloud still
started temporal and temporal-db, and the apps' depends_on edges let that
unused stack block them. The bundled cluster, its volume, its debug UI and
the start-order edges now live in docker-compose.override.yml, applied by
default; COMPOSE_FILE=docker-compose.yml in .env leaves it out, so the
apps depend only on app-db. The debug UI is documented as showing the
bundled cluster only.

* test(config): isolate env tests from the runner's environment

loadEnv only stubbed the values a case supplied, so variables inherited
from the shell leaked into the fresh module and cases asserting "unset"
tested whatever the runner happened to carry. Every variable env.ts reads
is now unset before each import, derived from the module's own keys so a
new one cannot be missed, and restored afterwards.

* docs(site): document secured and external Temporal configuration

The standalone quick start covered AI_BASE_URL and keyless startup but
none of the Temporal connection variables. Adds the namespace, TLS,
API-key and mTLS table with the same semantics as the backend README,
plus Temporal Cloud and private-CA examples. Also corrects the LLM
section, which still described a built-in OpenRouter default, and moves
the env snippets to the dotenv grammar the highlighter actually has.

* fix(deploy): run compose from the project dir and ship both files to the VM

The workflow drove a VM-local docker-compose.yml with -f, which disables
the automatic override and ignores COMPOSE_FILE — after the bundled
cluster moved into docker-compose.override.yml the demo VM would have
run without Temporal. The deploy step now copies both compose files from
the repo on every run, executes compose from /app/ai-studio, and passes
the pushed tags as RUNTIME_IMAGE / WEB_IMAGE, so one compose file serves
local builds and the VM.

* fix(deploy): keep certificate files out of the image build context

The build context is the repo root and .dockerignore excluded only .env
files, so PEMs dropped into deploy/ai-studio/tls per the mTLS docs were
copied into the runtime image by `COPY . .`. The directory is now
excluded; the files reach the containers through the read-only mount
only.

* fix(config): ship .env.example with an empty AI_API_KEY

The examples carried the placeholder `sk-or-...`, which envOptional
treats as a configured key: a verbatim copy skipped the boot warning and
sent requests to OpenRouter with a bogus token, surfacing a provider 401
instead of the documented ai_not_configured / 501 paths. The value is now
empty and the key format lives in the comment.

* docs: align the root README with the no-default LLM configuration

The Full Stack Demo section still described AI_BASE_URL as defaulting to
OpenRouter and listed a two-variable setup, contradicting the code and
the docs site. It now mirrors the docs page: three variables, pre-filled
by setup:env, no built-in default.

* fix(deploy): refuse to start while OPENROUTER_API_KEY is still set

Compose no longer passes the retired variable, so a pre-rename .env came
up with AI silently off and only a warn-level log to explain it. A
compose-level guard now fails interpolation with a message naming the
rename and the two new variables; the README and .env.example carry the
upgrade note.

* docs: correct the Temporal failure mode and remove default-wording drift

The troubleshooting row claimed the backend exits on a contradictory
TEMPORAL_* setup; it connects on first use, so it boots, passes its
healthcheck and fails on the first Play. Also aligns wording across the
READMEs, .env.example files and docs page with the code: no built-in
LLM default, any credential implies TLS, provider-neutral phrasing.

* fix(deploy): keep custom TEMPORAL_TLS_DIR out of the image build context

Only deploy/ai-studio/tls was dockerignored, so TEMPORAL_TLS_DIR=./certs
with a key inside the checkout was copied into the runtime image by
COPY . . on a local build.

Exclude deploy/ as a whole (re-including only deploy/ai-studio/nginx,
the one file the Dockerfile copies) and *.pem/*.key/*.crt/*.cer/*.p12/
*.pfx repo-wide. Document that TEMPORAL_TLS_DIR supports exactly ./tls or
a directory outside the checkout.

Verified with control files: none of the in-repo locations reach the
build context; nginx/default.conf and a repo-root positive control do.

* fix(ci): persist deployed image tags in the VM's .env

The deploy step only exported RUNTIME_IMAGE / WEB_IMAGE for its own shell,
so any later compose command on the VM (worker restart after a model
change, the debug profile) fell back to the local ai-studio-* build names.

Rewrite the two image lines in /app/ai-studio/.env on every deploy instead,
leaving the rest of the file untouched, and drop the export so the deploy
itself runs off the persisted values.

Verified locally: after a simulated deploy against a stale .env, a fresh
process with no inherited variables resolves both deployed tags via
`docker compose config --images`; other .env lines and mode are unchanged.

* test(backend,execution-worker): prove TLS, mTLS and API-key transport end to end

The connection builder tests only compared the options object built from
fake certificate bytes; nothing showed the backend's gRPC client or the
worker's native transport would complete or refuse a real handshake.

Add a shared test harness in apps/tools (throwaway CA with server and
client leaves, a TLS-terminating proxy in front of the Temporal dev
server, and an HTTP/2 endpoint that records bearer tokens) and drive both
builders through it: private CA, mutual TLS, an untrusted server CA, a
client certificate from the wrong CA, an API key inside the TLS session,
and work in a non-default namespace. No Docker or Temporal Cloud needed.

* refactor(temporal-connection): one copy of the TEMPORAL_* rules

Backend and worker carried identical validation, TLS inference and
certificate reading, kept aligned only by a "keep in sync" comment, plus
duplicated defaults for the address and namespace. The option types were
the excuse, but both SDKs accept a plain apiKey string and the same tls
shape, so a narrow shared contract fits both uncast.

Move all of it into a private source-only workspace,
@workflow-builder/temporal-connection, behind a single temporalConfig()
returning { connection, namespace }; each app hands the connection object
to its SDK's connect call. Reading and validation still happen where they
did: in the backend's first-connection factory and before the worker
starts. An empty TEMPORAL_ADDRESS or TEMPORAL_NAMESPACE now falls back to
the default like the other variables already did.

The validation matrix lives once in the new package, and so do the TLS
connection tests and their harness, a describe.each over both SDK
transports; handing the built options to both connect calls there is the
compile-time proof of assignability. The app suites are pure unit tests
again and apps/tools is untouched. The workspace is added to the CI
execution job's filter list, knip and CLAUDE.md.

* refactor(ai-config): share the AI_* contract, keep the runtime reactions apart

The rule that AI_API_KEY, AI_BASE_URL and AI_MODEL must be set together
lived three times: the backend's adapt route, the worker's executor
factory and its startup warning. Both apps also normalised empty values
on their own.

Add a private source-only workspace, @workflow-builder/ai-config, whose
aiConfig() returns a complete config or the names of the missing
variables, and make all three sites read it. What each app does when AI
is unavailable is unchanged and stays in the app: the backend answers 501
after authorization and the guard, the worker boots and fails an AI Agent
node with permanent ai_not_configured only when a run reaches it.

The package README is the canonical description of the contract; both
app READMEs and .env.example files point at it. The first-run guides
named a template that does not exist and invited Play before the LLM
section; they now say every bundled template has AI Agent nodes and what
to expect without an LLM.

* docs: limit the "no external traffic" promise to model requests

Several docs said an internal AI_BASE_URL keeps all traffic in your
network, or that nothing in the code points outside it. The optional
web-search tool calls Tavily's API whenever TAVILY_API_KEY is set, a node
enables search and the model invokes the tool, regardless of where the
model runs.

Say "model requests stay inside it" instead, note that the Tavily key
must stay unset if nothing may call out, and remind readers that Temporal
and the database go wherever their addresses point. Wording only; no
behaviour changed.

* refactor: drop a header that restated the function, name the PEM reader

Remove the file header on the AI agent executor factory that repeated
what its name and body already say. The comments explaining the real
contracts stay: TEMPORAL_TLS is tri-state on purpose, and the AI
configuration error is deferred to node execution so a keyless worker
still boots. Rename the certificate reader readPemFile so call sites say
what kind of file they read.

* test(temporal-connection): remove the TLS test's temp PKI directories

* docs(deploy): the backend calls the LLM too, for the visualize route

* fix(deploy): check the retired key in the deploy script, not in compose

* fix(backend): validate TEMPORAL_* at boot, like the worker

* feat(ai-config): name a retired AI variable that is still set

* test(backend): pin the env defaults the deploy depends on

* test(temporal-connection): prove the plaintext default connects

* fix(ai-studio): drop the provider name from the disclaimer
…ransient (#130)

* fix(execution-core): make template resolution failures permanent

resolveTemplate threw plain errors, so a malformed or unresolved
reference was retried under the node profile even though a retried
node receives the same context and fails identically. The three throw
sites now raise PermanentNodeExecutionError with the codes
template_malformed and template_unresolved, so the engine stops on the
first attempt and the code reaches node_failed.

* fix(execution-worker): make no_branch_matched a permanent failure

A decision node with no matching branch was retried under the node
profile, although the same inputs yield the same non-match on every
attempt. The throw site now raises PermanentNodeExecutionError, so the
node stops on its first attempt and the no_branch_matched code reaches
node_failed for the first time: unclassified codes are dropped at the
activity boundary.

* feat(execution-worker): classify provider failures in the AI agent

The AI agent rethrew whatever the AI SDK threw, so a rejected API key
or a 400 burned every attempt the node profile allows. The catch block
now maps a provider response by status, at the throw site and nowhere
central: 401/403 and every other 4xx are permanent, 429, 5xx, 408 and
a request that never got an answer are transient. Anything that is not
a provider response passes through unclassified.

The provider's own error stays attached as the cause, so node_failed
keeps showing the provider's text; the HTTP status is in the message.
The log line gains the code. The worker README documents the table.

* test(temporal): cover a transient failure across the activity boundary

The boundary test proved the permanent and unclassified paths but not
the transient one. The new case runs a wrapped transient failure
through a real Temporal dev server and pins that the node retries to
the profile's limit, that node_failed carries the code and the attempt
it died on, and that the deepest cause's message is what reaches it.

* fix(execution-worker): read the provider status through an SDK retry wrapper

classifyProviderError only matched a bare APICallError, so with SDK retries
enabled every provider failure would arrive as a RetryError and fall back
to the profile's uniform retry. The classifier now unwraps lastError first.

Also: the status-less branch is reached for connection failures, not for a
request that timed out, so the message and README row say so; the README no
longer claims every failure is classified; the activity log line uses
instanceof instead of a cast; the decision comment fits the three-line
ceiling; the unclassified boundary case no longer borrows the
no_branch_matched code this branch made permanent.

* fix(execution-core): never report an empty message for a wrapped failure

extractDeepestError returned the deepest cause's message verbatim. A
failed fetch in Node ends in an AggregateError with an empty message,
so a provider that refused the connection reached node_failed, and the
AI Studio log panel, as a blank line. The walk now keeps the deepest
non-empty message; everything else about the chain is unchanged.

* test(execution-core): assert template failures with vitest matchers

The template tests grew a bespoke catch-and-return helper next to
twenty assertions written with toThrow; they now use toThrow with an
asymmetric matcher like the rest of the file. The graph-runner test
that models a template failure crossing an adapter builds the real
PermanentNodeExecutionError and asserts the code it now carries. The
comment above resolveTemplate restated the README and is gone.

* docs(execution-worker): say what node_failed shows and record two deliberate choices

The README claimed the HTTP status reaches node_failed. It does not:
the event reports the deepest cause, so the provider's text is what
the UI shows and the status lives only in Temporal's failure record.
The paragraph now says so, states that 409 is permanent on purpose,
and names the unparsable-2xx case as intentionally unclassified. Two
comments that restated the READMEs or overstated the log line are
trimmed to what the code cannot show.

* test(execution-worker): share the provider error fixture and pin the 2xx fall-through

The APICallError fixture was built in two test files; it now lives in
one. The classifier table is a single list keyed by class, with the
redundant classification literals dropped, and gains 409 plus the
unparsable-2xx case that passes through unclassified. The comment
about statusCode 500 moves back to the single-call test it describes,
and the decision test asserts with toThrow matchers.

* fix(execution-worker): stop telling decision authors to use an empty catch-all

The no_branch_matched message advised adding a catch-all branch with
no conditions, but a branch with no conditions never matches, so the
advice reproduced the failure it explained. The message now asks for
an always-true condition, the executor comment says why, and the test
pins the wording. Making an empty branch the catch-all is a separate
backlog item, marked with the follow-up slug in the comment.

* fix(ai-studio): give the Support Triage template a working catch-all branch

The "How-to / Other" branch had no conditions, which the decision
executor never matches, so any ticket classified as neither billing
nor bug failed the run with no_branch_matched. The branch now carries
an always-true condition, the only form the executor treats as a
catch-all today.

* docs(execution-worker): point the catch-all comment at the default-branch follow-up

The slug presumed that an empty branch will become the catch-all. The
backlog task proposes an explicit default flag instead, so the marker
now names the outcome neutrally.

* fix(execution-worker): report the refused address when a provider is unreachable

A refused connection reaches the SDK as an AggregateError with no message
of its own, so APICallError renders it as "Cannot connect to API: " and
node_failed showed a dangling colon. Only messages cross the activity
boundary, so the classifier now picks the first AggregateError entry as
the cause, where the array still exists.

Also covers the README's unclassified promise with the real SDK classes,
adds the untested RetryError and status-range fall-throughs, stops three
tests using no_branch_matched as an unclassified example now that the
code is permanent, and records the class change in the decision log.
…r path (#134)

* test(temporal): record cross-version replay histories for every runner path

Adds the fail-policy, incomplete-branch and cancel-mid-run scenarios next to
parallel-wave, recorded from the harness rather than the UI so the inputs carry
no secrets. The committed files replay through Worker.runReplayHistories, so one
broken history no longer hides the rest, and UPDATE_REPLAY_HISTORIES now takes a
scenario name so adding one does not re-baseline the others.

* test(temporal): assert per-node events and make the history prefix an input

The replay harness hardcoded the v0- prefix while the README promised
<version>- recordings at the first release, so following it would have
overwritten the baseline. REPLAY_HISTORY_VERSION now sets the prefix.

Restores the per-node event shape and the terminal error message, which
the scenario table had reduced to first/last event and status: the
activity count alone cannot tell node_failed from node_skipped.

Also trims scenario lists, guards against nested scenario names claiming
each other's files, and narrows settle() to WorkflowFailedError.
Both app tsconfigs excluded spec and test files, so the pre-commit
`tsc --noEmit` from lint-staged and `pnpm check` never saw them. CI does
not type-check these apps by design (see pr-check.yml). The exclusion
predates the first tests in these apps; removing it needs no code
changes. `vitest/globals` joins `types` in both apps, as in the packages,
so a spec that relies on `test.globals` type-checks too.
)

deploy-docs.yml ran on every push to `release`, so merging a release PR for one
package redeployed the whole site from that head. Between releases `main` runs ahead
of npm: today it documents `isStartNode`, which no published SDK has, and tells
readers to install @workflowbuilder/ui, which is not on npm. The workflow now runs
only through workflow_dispatch, and its first step refuses a production deploy
(SWA_ENV unset) from any branch but `release`, so a slip on the branch picker cannot
publish `main`. Preview environments may still build from any branch.
…rep (#156)

* chore(release): per-package release flow and temporal first-release prep

Release one package at a time. `pnpm release:version <pkg>` computes the
`changeset version --ignore` list from the workspace, so a release of one package
never bumps its siblings on `release`. `pnpm release:tag <pkg>` creates and pushes
exactly one scoped tag after checking that HEAD is the release head, the CHANGELOG
has the section and no tag exists yet. `.changeset/config.json` trades the static
`ignore` list for `privatePackages`, which is what frees the CLI `--ignore` flag.

Prepare `@workflowbuilder/temporal` for its first npm publish: drop `private`,
reset CHANGELOG.md to the bare heading so nothing leaks into release notes, trim
the README to the npm landing page (the detail moves to activity-profiles.md and
event-history-labels.md next to it, not linked until the docs site has pages), and
document the manual first publish. RELEASE.md moves from packages/sdk to packages/
because it covers all three published packages; `@workflowbuilder/ui` is not on npm
either and follows the same bootstrap.

* refactor(release): split the release scripts into sections over a shared module

Both scripts read as one block of statements with duplicated helpers and
hand-rolled argument parsing. The plumbing (fail, run, package names, workspace
listing, parseArgs) moves to tools/release-shared.mjs; each script is now a
short linear flow under section banners with a one-line comment per block.
release:tag prints each check as it runs instead of collecting them first.
Behaviour is unchanged.

* fix(temporal): declare the Temporal SDK packages as peer dependencies

`@temporalio/activity`, `client`, `workflow` and `worker` were regular dependencies on
`^1.23.0`, while a consumer's `@temporalio/worker` pins its siblings exactly. pnpm then
installs a second copy of `@temporalio/activity` next to the worker's own, and
`activityInfo()` reads an activity context that lives in the other copy. As peers they
resolve to the consumer's single copy (verified in a scratch consumer: old shape two
copies, new shape one). `@temporalio/plugin` stays a dependency, it has none of its own.
Node floor raised to 20.3.0 to match the SDK. README lists the four packages to install
and points at the two companion documents.

* fix(release): harden the release scripts and cover tools/ in CI

release:version refuses a changeset that names both a released and a skipped package,
a release of the SDK while UI (compiled into it) has pending changesets, and any branch
that is not `release-*` (the `release` branch blocks the `release/` namespace). It also
stops when git cannot report the branch. release:tag checks the exit code of
`ls-remote`, pushes with --no-verify so the pre-push formatter stays out of a release,
and reads the tag back from origin before it reports. The CHANGELOG heading rule is one
function with tests, mirrored by the awk in the release workflows: exact version token,
so a prerelease never passes for the release. pr-check lints, formats and tests tools/,
and its changeset guards count only changesets a PR adds, now also for the UI -> SDK
pair.

* docs(release): correct the procedure after review

Release branches are `release-<pkg>-X.Y.Z`; the SDK/UI bundling pair and one-package
changesets are spelled out; pushing to `release` deploys the docs; the trusted-publisher
limitation cites its sources and asks for a check in the npm UI first; the UI CHANGELOG
keeps only the heading above its first section so nothing leaks into release notes.

* docs(release): record replay histories at release time, and fix stale claims

The replay README requires every release of @workflowbuilder/temporal to record the
scenarios again under the version it ships, and the first release to retire the `v0-`
baseline; the runbook never said so, and its expected-diff list even contradicted it.
Both now agree, and the replay README no longer describes the package as private or
records under 1.0.0. The npm README gains the node-label sentence, a short "Failures and
retries" section for the two error classes the changeset advertises, and the statement
that the replay contract has no pre-1.0 exception. Corrected along the way: the lockfile
is never touched by a bump (`workspace:*` carries no version), `pnpm publish` rewrites
workspace devDependencies rather than stripping them, the breaking-change example points
at the consumed changeset in git history, a test comment names activity-profiles.md, the
SDK release title no longer repeats the package name, and CLAUDE.md speaks of all three
published packages.

* fix(release): fail closed on git errors and report npm honestly

release:tag read a failed git command as an empty answer, which the checks took for a
clean tree or a missing tag; the helper now stops the script with git's own message,
except for the Actions link printed after the push. The npm check told "not on npm" for
every failure, including a registry that could not be reached; it now distinguishes a
published version, a missing one (empty answer or E404) and a failed lookup, and says
which. release:version lists the replay-history recording among its next steps for the
package that carries a replay contract, with the version already filled in, so the
runbook and the tool name the same steps.

* fix(release): verify the pushed tag, the plan and the notes; one release-notes rule

release:tag now compares the sha of the tag on origin with HEAD after the push, so a tag
of the same name that someone else pushed in between is reported as theirs instead of as
a successful release, and it reads the CHANGELOG from the commit it is about to tag rather
than from the disk. release:version refuses a dirty tree (its next step is `git add -A`),
treats a `none` changeset as no release, and checks that the version on disk after the
bump is the one the plan announced. The CHANGELOG section rule lives once, in
tools/release-shared.mjs: a new `pnpm release:notes <pkg>` prints the section or fails
when it is missing or empty, the three release workflows call it before the publish
instead of carrying their own awk, and release:tag uses the same function. The workflows'
idempotency check now treats only npm's E404 as "not published"; any other npm failure
stops the run. pr-check also runs on PRs into `release`, its changeset guard reads only
the front matter, tools/ is formatted and linted by lint-staged and `pnpm format`, and
eslint gives tools/ Node globals instead of browser ones.

* fix(temporal): every Temporal package is a peer, and replay recordings are never overwritten

`@temporalio/plugin` was the one Temporal package left as a regular dependency after the
others became peers, so a consumer pinned to worker 1.23 could receive any newer 1.x of
an interface Temporal marks experimental. It is a peer now like the rest, execution-worker
declares it, and the README lists the five packages to install at one version and tells a
client-only backend to declare them too, since pnpm would otherwise pick its own. The
replay harness refuses to overwrite an existing recording unless REPLAY_HISTORY_OVERWRITE=1
is set: recording at release time is routine now, and a run without REPLAY_HISTORY_VERSION
would have silently rewritten the previous set and turned the cross-version guard into a
self-check. Two comments and the catalog note now describe the dependency shape as it is.

* docs(release): name the docs build source and drop the version from the UI README

The docs site deploys from the merged `release` head, which is the snapshot of `main` from
when the release branch was cut, not `main` at merge time; the runbook said otherwise. It
also mentions that the replay harness refuses to overwrite recordings. The UI README no
longer calls 2.0.0 the first release: the first version on npm will be whatever the pending
changesets add up to.

* fix(temporal): keep the Temporal peers out of devDependencies so --prod installs them

The package listed its @temporalio/* peers in devDependencies as well. pnpm then counts
each peer as satisfied and installs nothing for it, and the `--prod` install in
deploy/ai-studio/Dockerfile strips the devDependencies, leaving the built dist with no
@temporalio/client, plugin or workflow to import: backend and worker failed at startup,
while every local install worked and pnpm printed no warning. With the five removed
(`common` and `testing` stay, tests alone use them), pnpm installs the missing peers as
ordinary dependencies of the package, which survive `--prod`. Verified on a replica of the
runtime stage and of the package-build stage, and with the package's own typecheck, build
and tests. CLAUDE.md and the catalog comment record the rule.

* fix(release): count untracked files as dirty, read the workflow from HEAD, one npm rule

release:version's clean-tree check skipped untracked files while its next step is
`git add -A`, which adds them; the flag is gone and the message names them. release:tag
checked the workflow file on disk while reading the CHANGELOG from HEAD; both now come
from the commit that gets the tag. The shared npm rule loses a branch npm never takes
(exit 0 with empty output) so it matches the release workflows. pr-check-docs also runs
on PRs into `release`, the branch whose merge deploys the docs site. The tarball checklist
names peerDependencies, and the replay README's pre-release path names the overwrite flag.

* fix(temporal): declare @temporalio/worker as the test-only devDependency it is

Only the tests import @temporalio/worker; src mentions it in two comments and dist
never imports it. After the previous fix removed every @temporalio/* devDependency,
the tests ran on pnpm auto-installing an optional peer, which pnpm only documents for
required peers. The package now declares it in devDependencies next to common and
testing, and keeps it as an optional peer for consumers that run a Worker. --prod
still drops it from the package, which costs nothing because dist does not need it;
verified on a replica of the Dockerfile runtime stage. knip flags every referenced
optional peer regardless of devDependencies (it did on main as well), so the temporal
workspace lists it under ignoreDependencies with the reason. CLAUDE.md and the catalog
comment carry the refined rule, and the Code Quality table stops claiming that knip is
part of `pnpm check`.

* docs(release): the docs site deploys by hand from release

deploy-docs.yml stops running on push to `release` (#164), so the runbook no longer
says that merging a release PR deploys the site. Step 8 describes the manual run: from
`release` only, once the site describes what is on npm, typically after an SDK or UI
release; a release of a package the site has no pages for needs none.
`pnpm release:version temporal` consumed the one temporal changeset; the SDK and UI
changesets stay queued. The generated CHANGELOG section is rewritten in Keep a Changelog
form, with every symbol checked against the package's exports. The replay histories are
recorded again under 0.1.0 and the pre-release `v0-` baseline is gone: from this version
on, the four `0.1.0-*.json` files are the contract every change to the runner must
replay. The replay README names the files by `<version>-` and explains where `v0-` went.
@piotrblaszczyk
piotrblaszczyk merged commit f23a959 into release Sep 21, 2026
9 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.

4 participants