Commit Graph
7 Commits
Author SHA1 Message Date
fd35b47658 feat: create the cloud workspace in onboarding, and teach the empty home (#10959)
* [ee] feat: create a personal workspace on cloud signup instead of the demo invite

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* feat(frontend): land cloud users in their workspace after onboarding

Cloud signup creates exactly one workspace for the new user, so the picker
that followed onboarding was a page with a single choice on it. Switch to
that workspace and go to the home page instead, falling back to the picker
whenever there is a real choice: an invite to accept, several workspaces,
or none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* chore(frontend): remove the tutorial system

Deletes the guided-tour feature: the tutorials directory, the per-editor
wrappers, the home banner and button, the /tutorials route, and the
driver.js dependency they were built on. Also removes what only existed to
serve them — the `tutorialsToDo` / `skippedAll` / `isCurrentlyInTutorial`
stores, the `disableTutorials` prop chain through the flow editor, the
`?tutorial=` deep links, PopupV2's clickOutside exemption for the driver
popover, and the selector-anchor class on the flow editor tabs.

The backend `tutorial_progress` endpoints and table stay: nothing calls
them now, and removing them is a public-API break plus a migration that
would drop existing progress.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* feat(frontend): suggest Hub projects on an empty workspace home

A workspace with nothing in it showed only "Welcome to Windmill". Replace
that with a grid of ready-made Hub projects to import, and hide the search
box, kind toggles and the sort/filter row while the workspace is empty —
they would act on an empty list. A search that matches nothing still keeps
its controls and shows the no-match message.

The project list is seeded locally for now; the Hub endpoint that ranks
them is not there yet, and Import is still a placeholder.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* feat(frontend): make a new cloud workspace the thing onboarding produces

Signup already makes a personal workspace; nothing let its owner name it, and a
user who ended up without one landed on a workspace picker whose only action was
a button. Onboarding now ends on the workspace itself, and the empty home that
follows says what a workspace is for rather than "Welcome to Windmill".

Onboarding gains a third step that names the workspace signup created, prefilled
from the login provider's name or the email local part — `ruben@…` gives "Ruben's
workspace". Skipping the survey reaches it too: the questions are ours, the
workspace is theirs. Advanced settings swaps in the real creation form for
someone setting up for a team.

The workspace picker stands down when it has nothing to offer: no workspace to
enter and no invite to accept leaves one action on the page, so the page is that
action — one field, prefilled, "Create workspace". Both hand-overs hold a loading
state for 900ms and the app fades in behind them, so creating a workspace reads
as something that happened.

The empty home draws three static placeholder rows in the shape of real ones,
under a caption offering a template or the New menu. "Start from a template"
opens a popover listing the hub's projects, most-starred first, preloaded when
the empty state renders and paged as you scroll. Picking one opens the import
wizard in a dialog: its two destination steps are already answered by being in a
workspace, so it starts at the import itself and pages to the credentials step
with the animation the paged-modal pattern provides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): gate the empty state and repair the derived workspace id

Review findings from the first round.

The empty state offers a template import and the New menu, and neither checks a
permission — so an operator, or a workspace whose direct-deploy protection
cleared `showEditButtons`, was offered both. It now sits behind the same gate as
the create menu thirty lines above it.

`validateWorkspaceId` answers with the *reason* an id is unusable, so
`if (validateWorkspaceId(next)) break` stopped on the first invalid candidate and
returned it: someone named Global got the reserved `global`, and a 50-char seed
got a taken one. Invalid candidates are skipped instead, and when none works the
caller opens advanced settings rather than posting a name the server refuses.

Also: `rd` may be absolute (the CLI login sends one) and `goto` refuses those,
which would strand the caller on the "Creating …" screen with the workspace
already made; the hub host is parsed defensively, since the instance setting is
whatever an admin typed and `new URL` was throwing in render; `insert_workspace`
says which authorization its callers still own; and the two arrival animations'
comments now describe when they actually play.

Tests for the two pure helpers the review named: `defaultWorkspaceName` and
`hubProjectDescription`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): use unifiedSize on the new onboarding step's buttons

`size` and `color` are deprecated on Button; the new step copied them from the
survey steps above it. AGENTS.md: deprecated props survive at old call sites,
copying one forward is still a bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): let Skip wait for the workspace onboarding names

`loadWorkspaceStep()` was fired and dropped, while Skip and the use-case Continue
branch on `ownWorkspace` in their `finally`. Skip awaits one POST that starts
after those two GETs and can finish before them, so a first-frame Skip fell
through to `leaveOnboarding()` and landed in the workspace with the backend's
name — the step this flow exists for, silently gone. Both exits await the load;
`isSubmitting` already covers the wait.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* refactor: create the workspace in onboarding rather than at signup

Signup no longer makes a personal workspace, so the last onboarding step creates
one instead of renaming it — the same one-field `SimpleCreateWorkspace` the
workspace picker falls back to, so a user who leaves onboarding early meets the
form again rather than something new. The id now comes from the name they type
rather than from their email, and there is one creation path instead of two.

`insert_workspace` goes back to being private: the extraction existed only so the
EE signup path could call it, and nothing outside `create_workspace` does now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): count a pending invite as somewhere to go

Onboarding read `listUserWorkspaces`, which returns membership. An invite is a
`workspace_invite` row until `accept_invite` runs, so an invited teammate reached
the last step owning nothing and was walked into creating a personal workspace,
with the invite nowhere on the page. Invites are fetched alongside the
workspaces, the way the picker already gates the same decision.

A failed load now reads as placed rather than not: the picker can work the
decision out, while the create step's only way forward is creating.

The create form reports when it is handing over, so the Previous button beside
it stands down for the ~900ms rather than offering a way back out of a workspace
that now exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): open the template picker downward and size it from the popover

The popover's default positioning caps its height to the viewport, and the list
inside it carried a fixed one, so a capped box overflowed its own frame — visible
with the AI composer hidden, where the caption sits high and `placement: top`
left almost no room above it. It opens downward now, with flip fallbacks, at a
definite `min(72vh, 520px)`; the list fills what the header leaves, which is
still the definite height it needs to page.

`creating` on the create form becomes `onCreatingChange`: `$bindable(default)` on
an optional prop is banned, and this is something the form reports rather than
state it shares.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* refactor(frontend): drop the InfiniteList containerClass prop

Added for the template picker, which turns out not to need it: DataTable's own
container is already `h-full`, so `containerClass="h-full"` merged to nothing and
the height the list pages against comes from the flex chain above it. A prop with
no effect at its only call site is public surface for free. InfiniteList is back
to what it was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): stop a dismissed project import from running on

Modal reports dismissal only through its bindable `open` — the X, Escape
and the backdrop dispatch neither `confirmed` nor `canceled`. Bind it, so
clearing `pick` follows the dialog closing: re-picking the same project
opens it again, and a run still in flight is abandoned with a toast
instead of writing to the workspace with no UI in front of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): stop a dismissed project import from running on

Modal reports dismissal only through its bindable `open` — the X, Escape
and the backdrop dispatch neither `confirmed` nor `canceled`. Bind it, so
clearing `pick` follows the dialog closing: re-picking the same project
opens it again, and a run still in flight is abandoned with a toast
instead of writing to the workspace with no UI in front of it.

Also mark the inline-link buttons as sanctioned rather than oversights,
and give Log out `text-accent` instead of `text-blue-500`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): keep the stopped-import toast off the Finish path

`done` survives a retry, so Finish is clickable while the run is going
again, and its own closing reaches the same falling edge the X does.
Abandon the run either way; say it was stopped only when that is what
the click asked for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* feat(frontend): reach the hub importer from the create menu, and count the funnel

The picker only existed inside the empty state, which disappears as soon
as a workspace holds one item — nothing else in the product linked to
`/projects/import`. New → Import now offers a hub project, opening the
catalogue in a dialog: a popover anchored to an item inside an open
dropdown leaves two melt layers arguing over focus. The list and the
import dialog move up to ItemsList, so one dialog serves both doors.

`template_setup` records how the credentials step ended — `filled` only
when nothing was outstanding, `skipped` carrying how many rows were left
— and `template_abandon` records where a dismissed import was given up.
`template_picker_open` gains a key naming the entry point.

Also on the workspace picker: logging out is a text link on the line
that says who you are and an item in the settings menu, rather than the
page's accent action, and onboarding's Previous joins the row it belongs
to instead of hanging under the button that finishes the form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): make the import counters answer what they claim to

`template_abandon` folded a landed import closed with the X into `idle`,
the bucket read as "opened this and bounced" — three outcomes in one
number. It gets its own `done` stage.

`template_setup` counted skipped rows through `value`, which is an
increment: `skipped` accumulated rows while `filled` and `none` counted
imports, two units in one counter with no way to recover one from the
other. The row count becomes a bucket in the key, so every event is one
import and the buckets compare.

The import dialog also asked for the hub URL settings at init, and the
home list now mounts it for everyone on every arrival — two GETs for a
string only the project card renders. Deferred to the first pick.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix: sanitize the inferred username, and stop counting an unread setup step as clean

`loadUsernamePolicy` derived a username by stripping dots, so `O'Connor`
and `alice+demo` both produced values the `proper_username` constraint
refuses — posted invisibly by the simple form, which then failed with
nothing on screen explaining why. `usernameFromName` keeps only `[\w-]`
and answers undefined when nothing usable is left, which is already the
form's cue to open the full one.

The credentials step offers Finish when the export could not be read,
since it cannot tell what is outstanding — and that landed in
`template_setup` as `filled`, the bucket meaning the step came out
clean. It reports whether it checked anything, and an unread step counts
as `unchecked`.

Also drops an orphaned `.sqlx` entry left by the create-at-signup query
this branch abandoned, and rewrites the stepper's first-frame comment,
which argued from a meaning of `resourceCount` that main has narrowed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): cap the inferred username at what the column holds

`usr.username` is VARCHAR(50) while the provider name and email it is
derived from run to 255, and `create_workspace` inserts the value
untruncated — so a long first name failed the same way the invalid
characters did: posted invisibly, refused on insert, with nothing on
screen naming the field. Undefined instead, which the form already
routes to the full one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): show the import note where it applies, and drop onCreated's unused id

The note is about landing on top of what a workspace already holds, so
it belongs wherever the destination is an existing workspace. The route
already read it that way; the dialog, which always imports into the
current workspace, was hiding it. It costs one collapsed row.

`onCreated` was typed as taking the new workspace id, and the advanced
branch passed `''` because `CreateWorkspaceInner` does not report one.
No caller reads it — the form has already switched to the workspace by
then — so the argument goes rather than the lie staying.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): keep the empty-state toolbar reachable, and name the view counters

"Empty" here means the default listing found nothing, and a workspace
whose items are all archived looks exactly the same. The searchbar
carries "Only archived", so taking it off the pointer left those items
unreachable without hand-writing a query URL. Dimmed still, never
`inert`.

The disclosure named the counters that fire on a creation or an import
and not the three that fire on merely seeing the empty home or opening
either picker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): respect disable_hub in both hub-project entry points

An instance with the hub turned off still got the catalogue preloaded on
every empty home and an "Import a hub project" entry in the create menu
— an outbound request the operator has said not to make, and a door to
somewhere unreachable. Both now observe `disableHubStore`, the store the
script and flow hub pickers already read. With the hub off the caption
reads "Create a new one." rather than continuing a sentence whose first
half is gone.

The telemetry disclosure also scoped the create menu and picker counters
to the empty home, when both fire from the toolbar in a populated one,
and said a creation was recorded when what is recorded is the menu
opening.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* refactor(frontend): drop the catalogue preload rather than gate it twice

Warming the hub catalogue when the empty state rendered bought the time
between the caption appearing and someone clicking it, and cost two
defects: the request fired on instances with the hub turned off, and the
gate added for that raced `disable_hub`'s own load, which starts false
and stays false if the settings request fails.

The picker fetches on open instead. Measured: nothing before the click,
one request after it, 411ms to a filled list. `disableHubStore` still
hides the link and the menu entry, which cost no request and correct
themselves if the setting lands late.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* docs: count the home actions, and state the lazy-fetch constraint without its history

The telemetry doc's tally is maintained by hand and main had just moved
it; this PR adds a feature, so it reads 48 across eighteen with `home`
in the list — verified against the pinned EE ref rather than counted by
eye.

The empty state's comment narrated a preload that no longer exists and
the defects it caused. What a future reader needs is the constraint:
`disable_hub` loads asynchronously, so a fetch from here goes out before
the setting forbidding it is known.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): stop the prefill overwriting a typed name, and gate submit on the policy

`load()` assigned the suggested name unconditionally, so a name typed
while its two requests were in flight was replaced a moment later. It
now yields to anything already typed.

Nothing may be submitted before the username policy lands either:
`automateUsername` starts at the common case, and posting that guess to
an instance that derives no usernames sends none where one is required.
`policyLoaded` gates both the button and `create()`, and is set in a
`finally` so a failed load leaves the form usable rather than wedged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): settle the username policy on failure instead of guessing it

`policyLoaded` was set in a `finally`, so a failed policy load unblocked
the form with `automateUsername` still at its default — the exact submit
the flag exists to prevent. The failure now hands over to the full form,
which asks for a username outright rather than inferring one, so the
flag is never true while the answer is still a guess.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): answer the username policy instead of rejecting it

Three rounds of this bug moved between call sites because the shared
loader rejects when it cannot read `automate_username_creation`, leaving
each caller to guess — and both guessed "automated", which hides the
username field and posts none to an instance that derives none.

`loadUsernamePolicy` now answers "ask for one" in that case, so
`SimpleCreateWorkspace` and `CreateWorkspaceInner` both render a field
someone can type into rather than submitting a guess. An instance that
does automate ignores a username it was sent, so asking is safe either
way.

The prefill and the policy are settled apart now too: a failed
`globalWhoami` costs the suggested name and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): block creation when the username policy is unknown

There is no safe default. `create_workspace` refuses a username on an
instance that automates them and requires one on an instance that does
not (`workspaces.rs:5820`), so a client that cannot read the setting has
two request shapes available and the server rejects both. Last round's
"ask for one" was as wrong as the "automated" guess it replaced.

So the loader reports the failure instead of inventing an answer, and
the form says so: Create stays disabled, with a line explaining why and
a link to try again. Verified in the browser both ways — unreadable
policy disables Create and shows the message, a healthy load prefills
the name and enables it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): close the advanced-settings bypass while the policy is unknown

Create was gated on knowing whether the instance derives usernames, and
the link beside it went to a form with no such gate — so the way around
the block sat next to it. It is disabled until the policy is known, with
a title saying why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): reload the list on dismissal, and never re-offer a workspace that exists

Closing a landed import with the X left the home list stale: only
`finish()` reloaded it, so a workspace that now holds a project kept
showing its placeholder rows. A run that started wrote items whether it
finished, was abandoned or failed partway, so any dismissal after one
reloads.

Creation reported failure for a failed *list refresh* too, and handed
the form back — where a retry picks the next free id and creates a
second workspace. Once `createWorkspace` returns, nothing may report
failure: the refresh is logged if it fails, and the hand-over proceeds,
since the workspace is real either way.

The disabled-link tooltip also claimed the settings could not be read
during the ordinary load, before anything had failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): reload after the abandoned run stops, not when it is asked to

`abandon()` stops the run at the next phase boundary; the request already
sent still lands. Reloading the list at that moment could read it before
that write committed, leaving the caller stale again — the thing the
reload was added to fix. It now waits for `running` to clear, which is
immediate for the common case of dismissing a finished import, with a
cap so a run that never settles still ends in a reload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): give the list reload one owner, taken by both exits

Finish reloaded immediately while dismissal waited for the run to stop,
so Finish pressed during a retry — `done` survives one, which is what
makes the button clickable then — read the list mid-write, and its
`finishing` flag stopped the deferred reload from correcting it.

Both exits now go through the same wait. One reload per closing, always
after the writing stops, whichever way the dialog was left.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* feat(frontend): give ImportExecution a whenIdle(), and await it instead of polling

The reload waited on a 250ms poll of `running` with a 15s cap, because
the modal receives the execution after `run()` was already called and so
holds no promise to await. The cap was its own hole: a write slower than
15s reloaded early, and nothing followed.

`run()` now keeps the in-flight promise and `whenIdle()` hands it out —
resolved when nothing is being written, immediate when no run is in
flight. The modal awaits that: no poll, no cap, no window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): back the settlement reload with a bound, and keep reporting run failures

`installProject` writes serially and takes no signal, so a request left
pending after earlier items committed leaves those invisible until the
next page load — `whenIdle()` alone never resolves for it. A bound now
reloads once in that case, *without* replacing the settlement reload:
replacing it was the flaw in the timeout this grew out of, so a hung run
reloads on the bound and again if it ever finishes.

`whenIdle()`'s rejection handler also swallowed the only report an
unexpected throw had — `#runInternal` has no catch of its own, and a
throw outside its inner ones leaves a stalled run with nothing on
screen. It logs now instead of discarding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* feat(frontend): say when a workspace holds only archived items

A workspace whose items are all archived read as empty, because the
placeholder is decided by the default listing. Reaching those items then
depended on the toolbar, which is why it had been left interactive while
dimmed — and that let a kind toggle replace the invitation with "no
items found" on a workspace that really was empty.

The state is named instead. When the default listing comes back empty,
one request asks whether anything archived exists, and the placeholder
says which of the two it is: "Everything in this workspace is archived"
with a link to show them, or the ordinary invitation. Held until that
answer lands rather than drawn and swapped, since the wrong one claims
the workspace is empty when it is not.

The toolbar is dimmed and `inert` again, its original design: the
archived case now carries its own way in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): make the archived route independent of write permissions

Three ways the archived-only placeholder failed to deliver what it
promised:

Reading archived items is not a write, but the notice offering them sat
behind the create-permission gate — so an operator, or a workspace whose
direct-deploy protection cleared `showEditButtons`, got "no items found"
over items it could see and a toolbar now inert. The gate governs the
create actions alone; the notice is shown to whoever the probe found
something for.

The probe answered once per workspace and was never invalidated, so
archiving the last item left a cached "nothing archived" claiming the
workspace was empty until a page load. `reloadItemsAndCounts` clears it.

And it omitted `includeWithoutMain`, which the backend reads as
excluding library scripts — a workspace holding only archived ones
answered "empty". Always true here: hiding library scripts puts a filter
in `activeFilters`, which `workspaceEmpty` requires to be empty.

`whenIdle()` gains the two tests its contract deserves, since the reload
correctness three rounds argued over rests on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix: harden the hub proxy, the workspace picker's gating and the rd hand-off

Findings from four local review passes over the branch:

- `list_projects` refuses when `disable_hub` is set, and `is_public_hub` now
  compares the parsed host, so no spelling of the public hub (mixed-case scheme
  or host, port, trailing dot, userinfo) forwards a member's bearer token there.
  Covered by a unit test table.
- The workspace picker waits on `usersWorkspaceStore` as well as `workspaces`,
  which derives to `[]` while the store is unloaded; with the create-form latch,
  one such frame swapped a member's picker for the create form until reload.
- `refreshSuperadmin` takes `force`, and the picker uses it: a `false` left over
  from a logged-out load decides whether the page is a picker or a create form.
  A cancelled call no longer publishes `false` over the live request's answer,
  and only its own request's handle is cleared.
- `rd` is sanitized once where it is derived rather than at each of the four
  hand-offs, so an absolute target keeps the OAuth callback's allowance and
  `https://evil.example/` is dropped.
- The archived-items probe answers "unknown" on failure, which keeps the
  ordinary caption and leaves the toolbar reachable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix: answer the round-33 nits on the hub route and the empty state

- `empty_state_view` is no longer logged for an archived-only workspace, which
  is not the state the counter measures.
- `detail`'s fetcher keeps its last answer in a local instead of reading
  `detail.current`, a self-reference that typed the resource `any`.
- `list_projects`' comment, including its authorization contract, is back on the
  handler rather than on the predicate inserted above it.
- `listHubProjects` documents the 400 an instance with the hub disabled returns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* feat(frontend): keep the operator onboarding tour

Operators cannot create anything, so the home page is the whole product to
them and its three tabs are worth naming. The tour that did that is the one
piece of the removed tutorial system that still has an audience.

Restored trimmed: driver.js, the driver wrapper and its controls, the
`.driver-popover` styling, and a module for the progress bit. The catalogue
machinery it used to sit in — the config, the role gating, the router, the
banner and the tutorials page — stays deleted, so the five steps are reached
directly instead of through a registry of one.

It runs on an operator's first home page visit and is recorded as seen
however it ends, including navigating away; afterwards it is in the sidebar
menu under Take the tour, which is where the last step points. Progress uses
the surviving `tutorial_progress` route, slot 6, read-modify-written so the
slots of the removed tutorials keep their state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JirHCYVR6qg7Xqe4PcZ1KG

* fix(frontend): dim the home toolbar only where the placeholder replaces it

Standing the toolbar down depends on something else offering a way onwards.
An operator in a workspace that is simply empty gets no placeholder — they
cannot create, and there is nothing archived to reach — so the search and the
kind toggles were the only controls on the page, dimmed to 40% and `inert`.

They now follow the placeholder rather than emptiness, which also stops the
operator tour spending three of its five steps highlighting controls this
page had greyed out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JirHCYVR6qg7Xqe4PcZ1KG

* docs(frontend): put the inline-link rationale on the first link, not the third

The caption's three links share one reason for being bare `<button>`s, and it
was written on the last of them. A reader — or a reviewer — meets the archived
one first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JirHCYVR6qg7Xqe4PcZ1KG

* fix(frontend): keep private hub project names out of telemetry

An instance pointed at its own hub imports its own projects, and the slug
naming one is the customer's content — `template_import` was recording it
verbatim, which the disclosure ("the name of any public hub project") does
not cover and `hub_script` already avoids by collapsing a private script to
`private`.

`hubProjectUsageKey` gives projects the same treatment, deciding by the
configured hub's host so a port, a scheme's case or a trailing slash cannot
turn a private hub into a public one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JirHCYVR6qg7Xqe4PcZ1KG

* fix(frontend): treat an unread hub setting as private, not as the public hub

`hubBaseUrlStore` is seeded with the public hub and written in one place, by a
loader with no catch and no retry. A settings read that threw therefore left
the store naming hub.windmill.dev for the rest of the session, and the import
counter read that as permission to report a private instance's project slug —
the leak the previous commit closed, narrowed to "after one failed read".

The fact has three states and the store held two, so `hubBaseUrlKnown` carries
the third: the loader sets it only once the value is the instance's own, and
the telemetry key requires it. Links keep rendering the default meanwhile,
which is what they always did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JirHCYVR6qg7Xqe4PcZ1KG

* fix(frontend): tag each hub detail with the slug that asked for it

`resource()` assigns whatever its fetcher returns, with no guard for a run that
has been superseded, so handing back the previously fetched project on a stale
response published that project. With two slow requests in flight — pick A,
leave B loading, pick C — B's answer put A's name, author and counts on the
card while the plan underneath still said C, and Import wrote C.

Each answer now carries its own slug and is read only while that slug is the
chosen one, which also drops the local the previous shape needed to keep the
resource's type from going circular.

The hub-telemetry tests reset their shared fixture per case; the private-hub
one had been passing on what the case above it left behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JirHCYVR6qg7Xqe4PcZ1KG

* fix(frontend): stop the template picker spinning on a hub with no projects

Opening the picker against a reachable hub that has published nothing pinned
the renderer at full CPU and froze the tab. The effect arming the list called
`setLoader` and `loadData`, which read `InfiniteList`'s reactive state as well
as writing it, so the effect depended on what its own load changed and re-ran
itself; a list that stays empty never settles that cycle. It now arms the
loader once per workspace, untracked, and leaves the load to `setLoader`.

The same empty list also claimed the hub was unreachable, since one `empty`
snippet serves both. The loader records which happened, so a hub with nothing
on it says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JirHCYVR6qg7Xqe4PcZ1KG

* fix(frontend): keep a superseded hub answer from erasing the chosen project

`resource()` publishes whatever its fetcher returns, superseded or not, and
`fetchHubProject` takes no abort signal — so an answer for a project the user
had moved on from replaced the published value, the slug guard rejected it,
and the chosen project's item counts went off the card for good with nothing
left to ask for them again.

The fetch now records its own answer, tagged with its slug and only while that
slug is still the chosen one, and the card reads that. Nothing reads the
resource, so it is a `watch` — the same machinery without the value that was
the problem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JirHCYVR6qg7Xqe4PcZ1KG

* chore: update ee-repo-ref to 81edd1382d951265ab3e9b67fc7ca7967676fd56

This commit updates the EE repository reference after PR #775 was merged in windmill-ee-private.

Previous ee-repo-ref: 21ace847ec1c1406bafc50153004e1874642bf6c

New ee-repo-ref: 81edd1382d951265ab3e9b67fc7ca7967676fd56

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-09-09 10:22:06 +02:00
130a2f7408 feat: instrument sandbox isolation, data tables and in-flow script edits (#10981)
* feat: instrument sandbox isolation, data tables and in-flow script edits

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLnp4v49BozkDd3KeWn5Q3

* fix: address review findings on the new telemetry counters

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLnp4v49BozkDd3KeWn5Q3

* refactor: inline single-site telemetry helpers and trim what is collected

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLnp4v49BozkDd3KeWn5Q3

* docs: tighten the telemetry disclosure copy

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLnp4v49BozkDd3KeWn5Q3

* chore: update ee-repo-ref to 5921c03c8e28642efd1c390f590c0dab9834fa99

This commit updates the EE repository reference after PR #780 was merged in windmill-ee-private.

Previous ee-repo-ref: 548b5e0421a04a2d9a76cce6efc6c91b1d8560ee

New ee-repo-ref: 5921c03c8e28642efd1c390f590c0dab9834fa99

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-09-05 10:38:20 +00:00
79426a1a68 feat: reconcile IdP instance groups from the SSO groups claim (#10957)
* feat: add sso_groups_claim setting for login-time instance group sync

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YESxWqzt959S6TY6vbc4eG

* chore: bump ee-repo-ref for the SSO groups claim reconcile

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YESxWqzt959S6TY6vbc4eG

* chore: update ee-repo-ref to 3b89bfc11314a326a191101cfe3ef65f6f7f82a8

This commit updates the EE repository reference after PR #774 was merged in windmill-ee-private.

Previous ee-repo-ref: e388527f9adbbe466fe050ca8d1d236ce3342bc3

New ee-repo-ref: 3b89bfc11314a326a191101cfe3ef65f6f7f82a8

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-09-03 22:23:43 +02:00
0bbd559ac8 feat: instrument AI fill/fix, evals, agents and the debugger (#10853)
* feat: track AI fill, AI fix, evals, reusable agents and debugger usage

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: pin ee ref to the feature_usage registry commit

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: update ee-repo-ref to c3b6f62ea579a3583d4b474e9885c77104cfc87e

This commit updates the EE repository reference after PR #745 was merged in windmill-ee-private.

Previous ee-repo-ref: 77992910929188a854eadc06ee45971877b6f954

New ee-repo-ref: c3b6f62ea579a3583d4b474e9885c77104cfc87e

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-08-28 16:31:13 +02:00
e866b68cdf feat: surface execution usage in the sidebar and explain what an execution is (#10760)
* feat: surface execution usage in the sidebar and explain what an execution is

Users read "executions" as a job count and are surprised by the real number,
which meters a second of compute. Every place the UI prints an execution count
now says so, and the sidebar carries a usage meter for the quota that will bind
first.

Adds SidebarUsage at the bottom of both sidebar surfaces: a ring in the
collapsed rail, a labelled bar when expanded, and a modal breaking down every
quota. On the free tier it meters the per-user and per-workspace 1000-execution
caps; on a paid plan it meters workspace usage against the executions the
workspace's seats already include.

Item.tooltip was inert on disabled dropdown rows: DropdownSubmenuItem rendered
the info icon inside the disabled button, which swallows hover, and the row's
own title attribute shadowed any wrapper title. Both renderers now fall back to
a wrapper title the way DropdownV2Inner already intended.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep the usage meter tied to the workspace it describes

isPremiumStore held the previous workspace's tier across a switch, which no
consumer noticed while it only gated affordances — the usage meter is the first
surface to render a number from it, and would have shown a paid seat quota for a
free workspace. It is now undefined until the active workspace's tier is known,
and a superseded response no longer writes.

The seat fetch had the same shape: a slow response for the workspace we left
overwrote the current count and stayed wrong until the next switch.

The usage wrapper also carried the padding the brand-mark row used to own, which
shifted the sidebar bottom by 4px on every instance where the meter renders
nothing. The component owns its own padding instead.

Names the collapsed ring for assistive tech, which otherwise saw an unlabelled
button whose only signal was the arc's color.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: scope the usage meter to the billing workspace and a known tier

A fork's usage, tier and bill all resolve to its billing root, but its member
list is deliberately a subset of the root's, so counting fork members metered
root usage against a fork-sized cap and invented billed-seat overages. Seats now
come from the billing root, and the paid meter stays hidden when that root is
not visible from the fork.

The tier was cleared only after the user-store round-trip, so the meter rendered
the previous workspace's tier for the length of it — a free→paid switch showed
the 1000-execution hard cap on a paid workspace, not a race but every time. The
clear now happens before the first await.

Workspace usage had neither guard: a superseded response overwrote the store
permanently, and the meter is the first surface to print that number as its
headline rather than bury it in a dropdown.

The free-tier counters keyed off `!$isPremiumStore`, which reads an unknown tier
as free and flashed the free-tier blocks during a paid-to-paid switch. They wait
for a known tier instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: never render an unresolved execution count as zero

The workspace-usage clear wrote 0, which is a real usage value: an in-flight or
failed fetch rendered as a green "0/1,000" bar, and a rejection left it there for
the session because loadUsage had no failure path. Usage is now undefined until
it resolves, each endpoint is assigned on its own so one failing leaves the
other's number intact, and a quota is listed only once its own usage, tier and
cap are known. The legacy counters show an em dash rather than a fabricated 0.

The fork gates read an unknown tier as not-premium, so clearing the tier on
switch made the fork entry point disappear for the length of the fetch on a
paid-to-paid switch. They hold while the tier is unknown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: read the usage endpoints as numbers, and fall back to the free tier

Both usage endpoints serve text/plain, so the client hands back a string despite
the generated `number` type. Interpolation and arithmetic coerced it, which is
why nothing noticed before, but `toLocaleString` on a string returns it
unchanged — a five-figure count rendered without its thousands separator against
a formatted cap.

A failed tier fetch left the tier unknown for the session, and consumers hold
premium-only affordances through the unknown window so a free workspace kept
offering them. It falls back to the free tier instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep an unknown tier unknown, and refresh the seat cap on demand

Falling back to the free tier on a failed tier fetch fixed the affordance gates
by lying to the meter: a paid workspace's real five-figure usage rendered
against the 1000 hard cap, red, under "jobs stop running for the rest of the
month". The tier stays unknown instead, and the two consumers get what each
needs — the meter hides, while affordances read `maybePremium`, which holds
through the pending window but fails closed once the fetch has failed.

Membership changes elsewhere don't reach this component, so the seat cap could
show an overage against a cap that had since grown. It re-resolves when the
modal opens, which is when the number is read rather than glanced at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: let anything showing executions re-read them

The counters were written in one place, the root layout, on a workspace change
only — so a tab left open all day showed the count from whenever the workspace
was opened, and the modal-open refresh could only reach the seat cap, leaving a
freshly computed denominator over a stale numerator.

Moves the fetch to lib/usage.ts, next to the stores it writes, so the meter can
refresh both numbers when its modal opens. Seats follow a membership signal that
WorkspaceUserSettings bumps where it already refetches after every mutation, so
the cap stops lagging a role change without either side owning the other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: order concurrent usage and seat refreshes

The workspace id doesn't order two requests for the same workspace, and both
refreshes can now have two in flight: usage through A→B→A or a modal-open
refresh landing on one already running, seats through a membership bump
arriving mid-request. An older response could win and restore the count it
replaced. Each refresh takes a generation and only writes if it is still the
newest.

The membership signal also fired on a plain read, so opening the users tab made
every consumer re-fetch a list identical to the one it held. It bumps on an
observed change to the member set instead, never on the first read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: count only billable seats, and order the tier requests

The cap counted every member row, while the backend bills
`NOT disabled AND NOT is_service_account` — a workspace with service accounts
got an inflated included quota, which hides a real overage rather than inventing
one. The seat basis matches `count_paid_seats` now, and the membership signature
carries both fields so enabling or disabling a member re-resolves the cap.

The tier fetch was the one refresh still ordered by workspace id alone, so a
late failure for a workspace could raise the failure flag over a tier a newer
request had already resolved. It takes a generation like the other two.

The membership signature is keyed by workspace: this page survives a workspace
switch, and comparing one workspace's members against another's reported a
membership change where only the workspace had changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: compare the member set only against the same workspace's

Qualifying the signature with the workspace put the workspace inside the value
being compared, so a switch made every comparison unequal and bumped the version
unconditionally — the opposite of the intent, and worse than before the key. The
workspace is the key now, not part of the payload: a different one has nothing
to compare against and re-baselines silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: hold the usage and tier fetches in resources

Every one of these values belongs to a workspace but lived in a bare store, so
each writer and reader re-derived "does this still describe what I'm rendering?"
by hand. Nine sites did, and the ones that forgot were most of this branch's
review findings: three stale-workspace overwrites, three A→B→A races, and two
placeholders (`0` executions, `false` tier) that read as real data because an
in-band value was standing in for "not known".

`resource` from runed — which frontend/AGENTS.md prescribes for async data, and
which ~80 files here already use — supplies all three properties as behaviour
rather than convention: a superseded fetch is discarded, the value resets when
its key changes, and loading and error are states instead of magic values. The
seat count keys on the billing root and the membership version, so both a
workspace switch and an added member re-resolve it.

That removes three generation counters, two workspace trackers, and the manual
clear-and-compare around each fetch. What remains is one publish site that
asserts the value still carries the active workspace before it reaches a store.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: order the resource responses that runed does not

The refactor claimed `resource` discards a superseded fetch. It does not: its
only ordering is an AbortController whose signal the generated client cannot
consume, and `current = result` runs unconditionally once a fetch resolves. So a
late answer for a workspace we had left still landed in `current`, and the
publish site — which trusted `current` — cleared the value on screen for the
workspace we were on. That reinstated the races the generation counters had
covered.

`loading` was standing in for the missing ordering, and it cannot: it is also
true during a `refetch()`, when `current` is still the right value. Gating on it
meant every re-read blanked the meter, and clicking it unmounted the modal that
same click had opened, since both sit behind the quota it had just cleared.

Values now carry the scope they describe and `scopedValue` keeps the newest one
matching the active scope, so a superseded answer neither publishes nor erases,
and a re-read leaves the display alone. The account-wide user counter keys on
the account, so a workspace switch no longer clears it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: order responses within a scope, not just across scopes

The tag carried what a value described but not when it was asked for, so two
fetches for one scope — a refetch landing on an in-flight load, or a second
membership invalidation — were indistinguishable and the older won if it landed
last. That left the seat cap reading the pre-change number until the next bump
or switch, which is the stale cap the generation counters had covered.

Widening the tag to the resource key would have fixed it by blanking the bar on
every membership change, so the issue order travels alongside the scope instead:
`tagged` stamps each request as it is issued, and only a strictly newer answer
for the current scope replaces the held one.

The unit tests now cover the same-key case they missed; both new ones fail
against the key-only guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: ignore a user list a newer read has overtaken

`lastSeen` was written unconditionally after the await, so a response for a
workspace already left overwrote the baseline for the workspace on screen. The
next real membership change there then compared against a baseline that was
never taken for it, re-baselined silently, and never bumped
`workspaceMembershipVersion` — leaving the sidebar on the old seat cap. The
`users` assignment had the same hole: an overtaken list could paint over a
newer one.

Both now go through a single check: a read whose issue order is behind the last
applied one is dropped before it touches either.

Also trims the two `scopedValue` docstrings and the membership rationale to the
four lines AGENTS.md allows, and records there that a failed refresh keeps the
last successful value rather than blanking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: do not claim a plan before the tier resolves

Widening `isPremiumStore` to `boolean | undefined` left `UserMenu`'s `{:else}`
catching the unresolved state: with the tier still in flight, or after the
request failed, a free workspace was told it was on the "Premium plan". Both
branches under that block assert a plan, so the block now renders only once the
tier is known — which also keeps the bordered divider from appearing empty
while it resolves.

Verified against the running instance with the tier stubbed slow: unresolved
shows neither branch, `false` shows the free counters, `true` shows "Premium
plan". Reverting the guard reproduces the wrong label at 300ms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: pin how a late answer orders against the read that replaced it

Returning to a scope whose earlier read is still in flight is the one case the
guard resolves by scope rather than by sequence, and the suite only covered it
with nothing outstanding. It now covers the late answer itself: it stands while
it is the only value describing the scope, the read issued on returning
supersedes it, and it cannot come back afterwards.

Also gives the meter the explicit `type="button"` the sibling sidebar rows use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: size the modal's plan button with unifiedSize

`size` is deprecated on `Button`. `unifiedSize="sm"` renders the plan button at
the same height and weight as the modal's own Close button.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: match the plan button to the modal's own action button

`unifiedSize="sm"` is `h-7`, and the Cancel button `Modal` renders beside it is
`px-3 py-[7px]`, i.e. 32px — so the two sat 4px apart. `md` is the unified size
that lands on 32px, which pairs them without putting a deprecated prop back.

Measured both boxes rather than the new one alone: 32px and 32px, same top.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: instrument the execution meter, and bill-align PremiumInfo's seats

The meter's only interaction is opening the modal, so that is what it counts:
`usage_meter/opened`, keyed by the plan tier and the quota that was tightest —
`free:user`, `free:workspace`, `paid:workspace`. The full set is a type next to
the call site so the vocabulary stays readable in one place.

The pair is registered in `FEATURE_USAGE_KINDS` (windmill-ee-private), without
which the post is dropped with a 204 and records nothing. Verified both halves:
the browser posts
`{"feature":"usage_meter","kind":"opened","key":"free:user","value":1}`, the
running EE image drops it because its registry predates the entry, and
`is_recordable_event` accepts it once the entry is there.

`PremiumInfo` computed its seats from an unfiltered user list, so the billing
page counted disabled members and service accounts that `count_paid_seats` does
not bill. Same filter as the sidebar's cap now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: point ee-repo-ref at the usage_meter registration

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: read the member list before the seat rows that depend on it

`loadPremiumInfo` reads `users` after its own await and nothing recomputes the
seat rows when the list lands, so whenever `premium_info` won the race the page
rendered zero developers, zero operators and zero seats and kept them. The list
is now fetched first, and a failure to read it no longer costs the rest of the
page.

Also refreshes the registered-action inventory in `docs/feature-telemetry.md`,
which the new pair makes 21 across nine features.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: scope the seat comment to the counter it matches

The comment claimed parity with the seats actually charged, which nothing in
this repo computes: `count_paid_seats` documents itself as counting provisioned
members rather than billing's active-user population, and the Stripe quantity
is not derived here. What the filter buys is agreement with that counter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to c6902ec2c51dc0ce30962afbfab3e456c5d9b831

This commit updates the EE repository reference after PR #735 was merged in windmill-ee-private.

Previous ee-repo-ref: bbc48fae6b73b6d72fe2e125e6003794a4ece167

New ee-repo-ref: c6902ec2c51dc0ce30962afbfab3e456c5d9b831

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-08-20 22:17:15 +02:00
53eb94659b feat(telemetry): extend feature-usage tracking beyond AI features (#10681)
* feat(telemetry): extend feature-usage tracking to long-tail features

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: describe telemetry as product feature usage rather than AI usage

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(telemetry): trim disclosure copy and drop unused pick origin

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(telemetry): count trigger fires per run and key hub picks from hub data

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(telemetry): slugify hub keys and order both writers' upserts

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(telemetry): key native trigger adoption by service so it matches fires

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref for native trigger adoption fix

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(telemetry): move feature-usage collection into the ee crate

* docs: point feature-telemetry at the moved registry and rust writer

* docs: correct the trigger-fire gate comment to match measured step counts

* docs: put the private-build caveat on the verification step

* chore: update ee-repo-ref to f079db9e7962a413b349c4ff8036080894f30771

This commit updates the EE repository reference after PR #725 was merged in windmill-ee-private.

Previous ee-repo-ref: 055adb80416f9339c9a28ae7fbaeadad30d74959

New ee-repo-ref: f079db9e7962a413b349c4ff8036080894f30771

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-08-14 18:50:38 +02:00
hugocasaandClaude Opus 5 c3b2275864 docs(agents): rework agent context, fix dev-env docs, vendor skills (#10667)
* docs(agents): scope agent guidance to where it loads

AGENTS.md loads in every session. Three of its sections only ever applied to
one directory, and docs/autonomous-mode.md was unreferenced by anything in the
repo, so none of its content was in effect.

- Move "Verifying Backend Changes" to backend/CLAUDE.md, "Verifying Frontend
  Changes" and "Banned Patterns" to frontend/CLAUDE.md. They now load when
  working under those directories, which is when they apply.
- Update the two cross-references that pointed at the moved sections (pr and
  svelte-frontend skills).
- Delete docs/autonomous-mode.md. Its "don't stop early" half is already in
  .webmux.yaml's oneshot system prompt, which actually loads; its trigger was
  bypassPermissions, which does not imply an absent user; and it restated
  AGENTS.md and the pr skill with copies that had drifted (hardcoded ports,
  relative screenshot paths). Salvaged the UI traps it uniquely documented
  into frontend/CLAUDE.md and dropped the three stale profile references.

AGENTS.md drops ~3.6k characters with no guidance lost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(agents): guidance for building a feature — reuse, telemetry, live verification

Three recurring gaps, all cases where a pointer existed but nothing triggered
on it.

Component reuse. The svelte-frontend skill documented three components with
props, which reads as the whole catalog; the barrel exports 23 and common/ has
34 subdirectories against those 23. So "never use raw HTML elements" was an
instruction agents could not follow. Added a mandatory discovery step: read
the barrel, grep the tree, and treat the documented three as examples.

Brand guidelines. frontend/brand-guidelines.md is 34k characters referenced by
bare path, which nothing opens speculatively. Added a table mapping what you
are building to the section that governs it, entered with grep rather than a
full read.

Product telemetry. feature_usage has 14 registered actions across three
features, and an unregistered (feature, kind) pair is dropped by
valid_feature_usage_event with a bare continue — no error, still a 204 — so
frontend-only instrumentation silently records nothing. New
docs/feature-telemetry.md carries the criteria for when to instrument, the
four-step recipe including the allowlist and the InstanceSettings disclosure,
and the privacy rules. Raised in the plan for user-facing work, not as a
separate question, and not at all for bugfixes or refactors.

Also: validation now ends at exercising the change on the running instance,
with standing permission to spin up whatever that takes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(dev): correct the worktree dev-environment guidance

Several things agents were told to do did not match what the machine does.

- Env discovery pointed at .env / .env.local / backend/.env. In a webmux
  worktree the real values are in $(git rev-parse --git-dir)/webmux/runtime.env
  (BACKEND_PORT, FRONTEND_PORT, DATABASE_URL, CARGO_FEATURES, WM_DB_NAME),
  sourced by every pane and undocumented. Reading it is also not blocked by the
  Read(**/.env) deny rules, which the old instruction walked straight into.
- The database name rule said branch-with-underscores. worktree-common.sh uses
  the worktree directory basename, and Postgres truncates at 63 characters, so
  branch hugo/win-2340-… resolves to windmill_win_2340_…_and_eval with no hugo_
  prefix and the tail chopped. A wrong DATABASE_URL guts the sqlx cache.
- The restart procedure said "tmux pane 1" and sent keys to an undefined
  <pane1>. Pane 1 is the backend under the full profile and the frontend under
  frontendOnly. Replaced with finding the pane by pane_current_command,
  recovering the live feature set from the running process (CARGO_FEATURES in
  runtime.env only records what the pane started with), and restarting in place.
- Added recovery for an orphaned backend holding the port: it reparents to
  systemd when its shell dies, so it survives anything that looks like cleanup.
  Three checks before killing a single pid, because pkill -f windmill takes out
  every sibling worktree.
- Agents spawned their own servers because AGENTS.md opened by telling them to.
  Now it checks for the existing panes first; the spawn commands are scoped to
  a plain checkout.
- New EE worktrees branched from the EE repo's local main, which nothing
  fast-forwards, so they started behind the commit pinned in
  backend/ee-repo-ref.txt — the one CI builds against. They now base on the pin,
  falling back to main only when it is unreadable.
- Enabled webmux autoPull so local main stays current; new worktrees are
  branched from it. Documented what WM_CLONE_DB does, including that it
  terminates every connection to the base windmill database.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(skills): vendor grilling/architecture skills; tighten PR ready and review rounds

Vendors five skills from https://github.com/mattpocock/skills (MIT, pinned at
84fdeffd12f2ee307994d1eb6feb48173b6e0502). They are one dependency closure:
grill-me is a stub that runs grilling, and improve-codebase-architecture draws
its vocabulary from codebase-design and its CONTEXT.md upkeep from
domain-modeling. .agents/skills/UPSTREAM.md records the license, the pin, and
the four local deltas so a refresh stays a diff:

- flattened the upstream engineering/ and productivity/ split
- rewrote bundled-file links to repo-root paths, since relative links break
  when read through the .claude/skills symlink
- dropped the upstream agents/openai.yaml packaging metadata
- removed every ADR path. This repo has not adopted ADRs, and a skill that
  offers to create them is how the practice arrives by side effect rather than
  by decision.

PR workflow changes, all in the pr skill:

- A round that never starts is usually a conflict with main, not a CI outage.
  Resolve by merging, not rebasing — a rebase rewrites the head SHA that round
  verdicts and the clean-round marker are keyed to. If the merge advances
  backend/ee-repo-ref.txt, the EE worktree has to follow or
  cargo check --features private compiles a tree neither the author nor CI
  intends.
- A clean round no longer means an automatic flip to ready. Wide blast radius
  (*_ee.rs, migrations, OpenAPI or the generated client, auth paths, shared
  worker infrastructure, a new public surface) asks first; self-contained
  changes flip. Unattended, the judgement holds and the action degrades: flip
  the small ones, leave the rest at a clean draft with the reason in the PR
  body.
- Rounds that never converge are usually structural. After three without
  convergence, stop, name the module the findings cluster around, and suggest
  improve-codebase-architecture rather than burning more CI.

AGENTS.local.md (gitignored, with CLAUDE.local.md importing it) holds the
ready/ask calibration, recorded as dated observations rather than a rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(dev): state that each worktree gets its own fresh database

The per-worktree section warned which DATABASE_URL to use but never said where
the database comes from: the post-create hook creates and migrates a new one
per worktree, so it starts with none of the main instance's workspaces, scripts
or flows. WM_CLONE_DB was documented only as a comment in .webmux.yaml, which
reads as how things work rather than as a per-project opt-in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(sqlx): script the cache backup/restore instead of documenting it

The update-sqlx skill spelled out a cp/comm/rm dance around `cargo sqlx
prepare`, which empties backend/.sqlx before regenerating — a failed run leaves
the cache gutted (observed: 2350 -> 142 entries), and a --all-targets run in a
CE checkout fails that way every time. Three problems with documenting it:

- The backup path was the literal /tmp/sqlx_backup, shared by every worktree.
  Two concurrent runs overwrite each other's backup, which is the only thing
  standing between a failed prepare and a gutted cache.
- The restore was a copy-pasted `rm -rf .sqlx && cp -r ... && cp ...` chain.
- Skipping the backup is what turns a routine failure into a lost cache, and a
  convention is easier to skip than a command.

sqlx-cache.sh has backup / newq / restore, keeps state in a per-worktree
directory, and leaves the judgement call where it belongs: `newq` prints each
added entry's query field for review, and only `restore` writes them in.

Also adds the general rule that scratch files belong outside the checkout —
anything written into the tree has to be deleted again, and rm prompts each
time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(agents): state why a routine cleanup prompts, and where scratch goes

The guard hook already auto-allows a plain rm whose operands are under /tmp or
inside a git checkout in $HOME, so deleting a temp dir or a stale .sqlx entry
costs nothing. What prompts is the command shape: the hook's tokenizer defers on
&&, ;, redirects, quotes and $VAR, so a chained cleanup falls through to the
Bash(rm:*) ask rule.

That was recorded only inside a paragraph about screenshot file paths in
frontend/CLAUDE.md, where nobody looking for it would find it. Stated in Core
Principles instead, alongside the rule that scratch belongs outside the tree —
for the reason that actually applies, which is not committing junk rather than
avoiding prompts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(security): deny agent edits to the permission hooks and project settings

.claude/hooks/guard-rm-outside-tmp.sh and guard-main-branch.sh are the
enforcement points for everything the permission rules are meant to catch, and
nothing stopped an agent editing them. One sed -i disables the guard for every
later command, silently, and the deny list in .claude/settings.json has the same
exposure.

Defence in depth rather than a boundary: an agent with arbitrary bash can still
delete, and this may only close the Edit-tool path if Bash writes are not
covered by Edit deny rules. It costs nothing and removes the cheapest way to
turn the guards off. Changing them now means editing the files by hand, which is
the intent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: address review round findings on head 3f47dc1

- backend/ and frontend/ guidance was Claude-only. Codex and Pi read AGENTS.md,
  not CLAUDE.md, so moving "Verifying Backend/Frontend Changes" and the
  $bindable ban out of the root AGENTS.md made them invisible to two of the
  three CLIs this repo supports. Renamed both to AGENTS.md with a one-line
  @AGENTS.md CLAUDE.md beside them, matching what the repo already does at the
  root and in ai_evals/, and retargeted the four references.

- sqlx-cache.sh aborted with exit 2 and no output when .sqlx was empty:
  list_entries ran `ls -1 ./*.json`, and an unmatched glob under
  `set -euo pipefail` killed the script. An empty cache is precisely what a
  failed prepare leaves behind, so it broke in the one case it exists for.
  Replaced with a glob loop; reproduced the failure and verified the fix.

- The oneshot prompt ("never leave the PR sitting in draft") contradicted the
  "Flip, or ask first" rule added in the same PR, which tells unattended runs to
  leave wide-blast-radius changes as clean drafts. The prompt now defers to the
  skill for the flip decision and keeps only "never stop at an unreviewed
  draft".

- Bundled-resource references in the vendored skills were markdown links to
  `.agents/skills/...`, which resolve relative to the file, not the repo root.
  Replaced with inline paths stating they are repo-root relative.

- The PR-ready calibration file was write-only: the skill said to record
  answers there but never to read it. It is now consulted before deciding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Revert "chore(security): deny agent edits to the permission hooks and project settings"

This reverts commit 3f47dc1692.

* fix: address round 2 nits

- backend/AGENTS.md told agents to persist CARGO_FEATURES in runtime.env, but
  webmux regenerates that file from metadata and .env.local every time the
  worktree is opened, so the setting is lost on the next reopen. The persistent
  source is .env.local, which scripts/post-create.sh already writes.

- UPSTREAM.md still described the vendoring delta as rewriting bundled-file
  *links* to repo-root paths. 555f063 replaced them with plain paths in prose,
  because a markdown target resolves relative to the file — a repo-root link is
  just as broken as a sibling-relative one through the symlink. Replaying the
  old wording on a refresh would reintroduce the bug UPSTREAM.md exists to
  prevent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(skills): correct the UPSTREAM.md link-rewrite delta

The delta note still described rewriting bundled-file *links* to repo-root
paths. 555f063 replaced them with plain paths in prose, because a markdown
target resolves relative to the file containing it — a repo-root link is as
broken as a sibling-relative one read through the symlink. Replaying the old
wording on a refresh would reintroduce exactly the bug UPSTREAM.md exists to
prevent.

The preceding commit's message claimed this fix; the edit had failed on a
stale anchor and only the backend/AGENTS.md half landed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(dev): describe what a fresh worktree database actually contains

Exercising a real worktree creation showed the previous wording ("none of your
workspaces, scripts or flows") reads as an empty database. It is a bootstrap
instance: the admins workspace, the admin@windmill.dev superadmin, the license
key copied from the base database, and the migration seeds — observed as
u/admin/hub_sync and the default app theme resource.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 11:12:20 +00:00