Commit Graph

6344 Commits

Author SHA1 Message Date
Justin Blumencranz 85902eb7ff feat(tables): improve view and filter controls (#6725)
* feat(tables): improve view and filter controls

* fix(tables): keep menu actions open

* fix(tables): guard view autosave against echo remounts and stale responses

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

* fix(tables): keep and/or filter toggles, autosave only real edits

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

* fix(tables): compute view-row action spacer and cover the default pin

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

* feat(tables): apply filter text on enter or blur instead of a debounce

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

* fix(tables): apply filter edits from user events

* fix(tables): reject stale default promotions

* fix(tables): preserve filters during rule transitions

* fix(tables): isolate flagged view interactions

* fix(tables): preserve OR boundaries when rules drop

* fix(tables): retain deferred filter conditions

* fix(tables): keep hidden view actions click-through

* fix(tables): keep disabled view pins from selecting the row

A disabled Button is pointer-events-none, so clicks on the default or
read-only pin fell through the overlay to the row and selected the view.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 10:47:28 -07:00
Justin Blumencranz 38630ff46d feat(tables): autosave persisted default views (#6724)
* feat(tables): autosave persisted default views

* fix(tables): close persisted view lifecycle gaps

* fix(tables): preserve edits through view hydration

* fix(tables): preserve the persisted default owner

* fix(tables): preserve legacy layout through view adoption

* fix(tables): close final view autosave races

* fix(tables): preserve valid saved sort on stale links

* fix(tables): make the first saved view the default

Creating the first view left isDefault false, so the legacy All
fallback stayed in the menu instead of handing off to the new view.

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

* fix(tables): refuse to delete a table's last saved view

The sibling check and the delete share the views advisory lock, so
racing deletes cannot drop a live table to zero views and regress it to
the legacy "All"-only state. Views of a hard-deleted table are removed
by the FK cascade, which this guard never sees.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 10:46:23 -07:00
Vikhyath Mondreti 785c619b1c improvement(provenance): name the block behind an unprojected input root (#6890)
* fix(provenance): name the block behind an unprojected input root

structural-input-root-unprojected fires when a block's config.params
throws on the projected inputs — the copy where a secret has been
replaced by its placeholder — and no structured projection recovers it.
It was reported with the reason and nothing else, so a line told you
this had happened somewhere without naming the block, and the caught
error was discarded by a bare catch. markIncomplete now takes a
structural detail, and this guard passes the block type, tool, input
path, and failure class.

Names and types only. A coercion that rejects a value tends to quote it,
and an input reaching this guard may still hold a resolved secret.

Which is also why the json-parse warning a few lines above no longer
logs the thrown message: V8 quotes the text it rejected back into it —
Unexpected token 's', "sk-live-EX"... is not valid JSON — and that
prefix is enough to leak. The field name and its declared type are
already in the message, and SyntaxError is the only class JSON.parse
throws.

* fix(provenance): keep a detail from displacing the reason it explains

The detail merged into the incompleteness payload could shadow `reason`.
Spreading it first at the call site protected only the fields added
there; `reason` is added a level up in reportIncompleteness, which
built `{ reason, ...details }`, so a detail carrying that key replaced
the guard literal on the line while the level was still selected from
the real one. `origin` was reachable the same way whenever no importer
origin was set.

Write `reason` last, which protects every caller of that reporter
rather than the one that prompted this, and close the detail to named
fields so neither key is expressible without a cast.
2026-08-20 10:41:29 -07:00
Waleed f5728fa887 fix(icons): restore the Crunchbase mark's counter and framing (#6887) 2026-08-19 23:34:04 -07:00
Theodore Li 4214a891f4 fix(setup): publish unscoped setup package (#6886)
* fix(setup): publish unscoped setup package

* fix(setup): strip renamed status command
2026-08-20 02:11:06 -04:00
Waleed f6a9f0dd87 fix(integrations): white CB Insights tile and a borderless Crunchbase mark (#6884)
CB Insights moves from a dark navy tile to white, matching Jira, Confluence, and
Bitbucket. Its icon carries its own fills, so it stays legible on the lighter tile.

The Crunchbase icon drops the white rounded-square plate and its border, leaving
just the `cb` mark on `currentColor` so the block's bgColor supplies the tile. The
viewBox is retargeted to the glyph's true curve extrema with padding that keeps it
at the same optical weight as the surrounding brand marks.
2026-08-19 22:34:17 -07:00
Waleed 4fce5190e6 fix(bitbucket): bind cursors and task locations case-insensitively (#6883)
* fix(bitbucket): bind cursors and task locations case-insensitively

Bitbucket resolves workspace and repository slugs case-insensitively but echoes
the canonical lowercase form in `next` links, diff/diffstat redirect targets, and
async merge task Locations. Binding those back with exact string equality meant a
mixed-case slug succeeded on the first request and then failed on every follow-up
— worst on a 202 merge, where the merge has already started when polling breaks.

Repository file paths keep verbatim comparison; git treats those as case-sensitive.

* fix(bitbucket): fold only the slug segments Bitbucket canonicalizes

Segment-wise comparison replaces whole-path case folding, so a cursor that recases
a fixed endpoint literal (repositories, commits, pullrequests) fails locally again
instead of being deferred to Bitbucket. Only the workspace and repository segments
of a /2.0/repositories path fold; file paths and literals stay verbatim.
2026-08-19 22:33:56 -07:00
Justin Blumencranz 0b71717241 perf(icons): reduce and guard SVG path precision (#6839)
* perf(react): reduce SVG path precision

* fix(react): preserve Sim wordmark precision

* fix(icons): preserve Quartr scale

* test(icons): ratchet SVG path precision

* fix(icons): make precision exceptions local

* perf(icons): enforce three-decimal paths
2026-08-19 22:33:15 -07:00
Vikhyath Mondreti 2ced737976 refactor(sub-blocks): make a registered selector the single source for a remote option list, and close the fork-sync reconfiguration gap (#6878)
* fix(workspace-forking): stop double-labelling a custom block's inputs, and derive their controls from the canvas

Two problems with how a repointed custom block's inputs render in the sync modal.

The field title printed twice. The row wrapper already draws the label and its
required marker for every dependent field — `DependentFieldSelector` takes a
`title` only to phrase its placeholder and renders a bare combobox. The
custom-block branch used `ChipModalField`, which owns a label of its own, so every
input showed its name twice. It now renders bare controls like its sibling does.

The control was chosen by re-reading the raw field type instead of asking the
function that already answers this. `subBlockTypeForField` decides what a Start
field becomes on the canvas; the modal had a parallel switch that had already
drifted, rendering a `file[]` input — an upload on the canvas — as a plain text
box, which would write a bare string into a field expecting file references.

`subBlockTypeForField` is now exported and the modal derives from it, so the two
cannot disagree about what a field IS; the modal only decides how that kind draws.
A file input is explicitly `unsupported` rather than falling through: it renders
disabled, saying it is set in the workflow, instead of inviting a value that
cannot work. A test walks every type a Start field can declare and asserts the
modal's choice follows the canvas's, so a type added later surfaces here rather
than silently becoming a text box.

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

* fix(workspace-forking): resolve a custom block's inputs against the target environment, and stop a re-sync wiping its uploads

A repointed custom block's inputs are configured at sync time, but the modal
drew them as bare text fields against no environment at all:

- `{{SECRET}}` had no completion, and no way to know which secrets exist in the
  workspace the value is written INTO.
- `<block.output>` had no completion. The canvas dropdown reads the workflow
  open in the editor; on the fork settings page there is none, and the workflow
  that matters is the target's.
- A `file[]` input has no control here (it is an upload on the canvas), so it
  had no stored override — and the block was rebuilt from overrides alone, so
  every sync silently dropped the target's uploaded files.

`WorkflowReferenceScope` lets a surface supply the workflow a reference resolves
against. Absent a provider, the hooks read the live editor stores exactly as
before, so the canvas is unchanged. The scope splits graph from values on
purpose: reachability cannot change with the text being typed, and the
validation hook runs in every reference-aware sub-block editor at once, so
subscribing it to live sub-block values would re-render all of them on every
keystroke. A test pins that split.

`replaceCustomBlockInputs` now seeds from the target block when it is ALREADY
the mapped type, layering the configured values on top. That keeps an input the
modal cannot offer a control for, and leaves a field the user simply did not
touch alone; a field they explicitly emptied stores `''`, which is an override
and still wins. Under a DIFFERENT current type nothing is carried over — those
values are keyed by another block's field ids, which is the orphaning this
function exists to prevent.

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

* fix(workspace-forking): stop a required file input deadlocking Sync

Both PR bots flagged this and they were right. A repointed custom block's
`file[]` input renders as a disabled control — it is an upload on the canvas,
and there is nothing to type here — but the Sync gate still demanded a
non-empty value for every REQUIRED dependent. So a custom block with a required
file input turned Sync off permanently, while the field's own hint told the
user to go set it in a workflow they could only reach BY syncing.

`isForkSyncConfigurableField` is the one predicate for "can the modal put a
value in this field", used by the gate and by the per-kind status badge so the
two cannot disagree. Skipping the gate is only safe because the sync no longer
clears the field: the target keeps what it has, and a genuinely missing value is
still caught by the block's own required-field validation at run/deploy time —
the same fallback every other unconfigured required field already relies on.

Also gives the disabled control an `aria-label` (the row's visible label is a
sibling, not associated), closing the second review note.

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

* refactor(sub-blocks): make a registered selector the single source for a remote option list

`dropdown` and `combobox` could only load a remote list through a per-block
`fetchOptions(blockId)`, which resolves its credential by reading the live
workflow store. That works on the canvas and nowhere else — which is why the
fork sync modal cannot offer those fields, and why every one of those fetchers
turned out to be a hand-rolled duplicate of a selector that already exists
(`triggers/gmail/poller.ts` calls the very contract `gmail.labels` wraps).

Both controls now accept `selectorKey`, resolved through the registry inside
`useFetchedOptions`. Deliberately NOT a second code path: the registry is
presented through the same two function shapes the props already describe, so
the existing lifecycle — request-id guards, dependency-scope reset, label
hydration — is reused verbatim, and paginated selectors drain through the same
`loadAllSelectorOptions` that search/replace and value resolution already use.

`isDynamic` replaces the `fetchOptions &&` test the controls used to decide
whether the fetched list or the static `options` array is authoritative; that
question outlives the prop it was asking about.

No block or trigger changes yet, so nothing moves off `fetchOptions` in this
commit: subblock `type`, `multiSelect`, and the stored value shape are all
untouched and no existing workflow is affected.

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

* refactor(triggers): move every credential-scoped option list onto a registered selector

Each of these `fetchOptions` resolved its credential with
`readSubBlockValue(blockId, 'triggerCredentials')` — a live-workflow-store read
— and then called the very selector contract a registered selector already
wraps. They were duplicates that only worked on the canvas.

Migrated: webflow sites/collections (x4 triggers), clickup workspaces, gmail
labels, outlook folders, and all six hubspot pickers. 425 lines of duplicated
fetch logic deleted.

The missing piece each one needed was `canonicalParamId: 'oauthCredential'` on
its credential subblock: `buildSelectorContextFromBlock` keys the context on a
subblock's CANONICAL id, so without it `context.oauthCredential` was never
populated and the block had no way to reach its credential except the store —
which is what forced the hand-rolled fetcher in the first place.

Five new hubspot selectors. `hubspot.pipelineStages` reads the pipelines
contract and narrows, because HubSpot returns stages inside the pipeline
payload rather than behind an endpoint of their own; sharing the one response
is also what keeps a stage list from ever describing a pipeline its sibling
picker is not showing. `objectType`/`customObjectTypeId`/`pipelineId` join
SelectorContext, and `resolveObjectType` keeps HubSpot's own `contact` default
so an untouched dropdown still lists properties for what it visibly shows.

Subblock `type`, `multiSelect`, and stored value shapes are unchanged, so
existing workflows are unaffected.

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

* refactor(triggers): move the table trigger's column picker onto table.columns

`fetchTableColumns` resolved the workspace from the active-workflow store and
the table id by reading two subblocks by name, then refetched the table list to
find one table's schema. The registered `table.columns` selector takes both from
the context — `tableSelector`/`manualTableId` already carry
`canonicalParamId: 'tableId'`, so the canonical pair resolves on its own — and
reads the table detail query directly.

Deletes the helper and the four imports it was the only user of.

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

* refactor(managed-agent): move its four pickers onto registered selectors

All four read one route distinguished only by `resource`, with the credential
pulled from the store by name. They are now `managedAgent.agents` / `.vaults` /
`.memoryStores` / `.environments`, and `lib/managed-agents/subblock-options.ts`
is deleted entirely.

The environment filter (cloud vs self_hosted expose different fields, so mixing
them offers choices the rest of the form cannot honour) moves into the selector
with `environmentType` on the context.

Also decouples two things `canonicalParamId` was conflating. It is both a
block's serialized PARAM NAME and the key `buildSelectorContextFromBlock` reads,
so making this block's pickers resolvable appeared to require renaming its
shipped `credential` param to `oauthCredential` — a rename that would change the
serialized shape of every existing managed_agent block, and one that
`blocks.test.ts` correctly refused. A picker should not be able to force a param
rename, so the context now reads a credential off the subblock TYPE when no
canonical id supplied one. It only fills a gap: a block that declares
`canonicalParamId: 'oauthCredential'` has already resolved it, including the
basic/advanced active-member logic the type check cannot express.

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

* refactor(sub-blocks): delete fetchOptions — a sub-block's options are a selector or derived, never both

Completes the migration. `fetchOptions`/`fetchOptionById` are off `SubBlockConfig`,
off both controls, and out of `useFetchedOptions`, leaving exactly two ways a
sub-block gets its options:

  selectorKey  — a registered selector. The ONLY way to load a remote list.
                 Parameterized by an explicit SelectorContext, so it works on the
                 canvas, in the fork sync modal, and anywhere else.
  options      — a static array, or a pure function of the block's own values.
                 No I/O.

Reading the remaining callsites showed most of the "derived" ones were nothing of
the kind — they were workspace-scoped remote fetches wearing a local-looking
signature. Those became seven `workspace.*` selectors (credential providers,
credential groups + their per-group providers, secret names, raw secret names,
sandboxes, trigger types) plus `providers.openrouterEmbeddingModels`. Only the
agent block's three capability dropdowns were genuinely derived; `options` now
takes the block's values so they can say so directly. The parameter is optional,
so every existing zero-argument options function is untouched.

`imap.mailboxes` is the one selector whose account is typed rather than stored.
Its password is deliberately absent from the query key: a query key identifies a
resource, a credential authorizes access to it. `oauthCredential` is safe there
because it is only an id — a typed password is a secret, and keys are cached and
surfaced by devtools. Host, port, TLS and username already identify the mailbox
list uniquely; the password rides the body exactly as before.

`selectorExcludeSelf` replaces the one thing a shared `sim.workflows` selector
could not express. It is a declared flag rather than a blanket rule because the
answer differs per field: the Sim trigger never receives events about its own
workflow, while the Logs block legitimately reads the logs of the workflow it
runs in.

Deletes `lib/workflows/subblocks/options.ts` and `triggers/editor-state.ts`
entirely — every caller was a `fetchOptions` resolver. The live-registry test for
the trigger vocabulary moves to the selector that now owns it, keeping its
lazy-import cycle guarantee under test.

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

* feat(workspace-forking): make every fork-clearable sub-block reconfigurable at sync time, and lint that it stays so

`clearDependentsOnRemap` wipes every transitive dependent of a remapped parent,
and a credential mapped between environments changes value on EVERY sync — so a
dependent the sync modal could not offer was re-emptied on every push, with
nowhere to set it that stuck. Setting it in the target did not survive. 36 fields
were in that state.

The selector migration closed most of it; this closes the rest. The collector now
also emits plain text dependents (`short-input` / `long-input`), which need no
selector — just somewhere to type — and the modal's no-selector branch renders
them through the same control it already drew for custom-block inputs. It
deliberately does NOT emit the manual half of a selector-backed canonical pair:
that pair already represents the field once, and its manual member is verbatim by
policy, so offering both would show one concept twice and invite writing into the
inactive half.

`forkDependentControl` replaces the direct `customBlockInputControl` call in the
view, because `fieldType` now means two different things: a custom-block input
declares a Start FIELD type (`string`, `file[]`), while every other no-selector
dependent is a canvas SUB-BLOCK whose own type says it. They agreed by accident
before; now they are classified separately.

`check:fork-dependent-coverage` fails when a sub-block under a
credential/knowledge-base/table anchor is none of: selector-backed, a canonical
pair member, a preserved name-based type, or text. 656 dependents, zero
uncovered, no baseline — verified to fail by seeding a regression. Picked up
automatically by `check:audits` (all 30 green).

Documented in `/add-block`, `/add-trigger`, and `.claude/rules/sim-integrations.md`,
including the two rules the checks enforce: a secret never enters a selector's
query key, and a fork-clearable dependent must be reconfigurable.

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

* fix(sub-blocks): stop selector-backed fields rendering undefined options, and pass derived values to ComboBox

Both found by Bugbot on the migration commit; both real, both mine.

A selector-backed field carries no static `options` — that is the point — but
`Dropdown` and `ComboBox` still read it on first paint, before any fetch resolves,
and `allOptions.map(...)` is unconditional. Every field moved to `selectorKey`
(Function sandboxes, Managed Agent pickers, OpenRouter embeddings, Logs
workflows, the migrated triggers) would throw on mount. The type said the prop was
required, so nothing caught it: the callsites pass `config.options`, which is
optional on `SubBlockConfig` and now genuinely absent.

Fixed on the controls rather than by restoring `options: []` to every migrated
sub-block: the absence is correct, so the component owns the default. `options`
is optional on both prop types and falls back to a shared empty array, which also
keeps a stable identity for the memo.

`ComboBox` never got the `options({ values })` wiring `Dropdown` received, so
agent's reasoning-effort, verbosity and thinking-level lists — all comboboxes —
silently stayed on their generic fallback instead of narrowing to the selected
model. Wired the same way, reading the block's own values from the store.

`selector-backed-subblocks.test.ts` pins the invariants against the real registry:
a named selector exists and can list, a selector-backed field never also declares
static options, and a field whose selector is gated on context declares the
`dependsOn` that rebuilds it. That last one immediately caught a third bug —
`clickup.triggerWorkspaceId` had no `dependsOn`, so its list would have loaded
once, empty, and never refetched once a credential was picked.

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

* fix(selectors): restore the credential-group provider label resolver, and probe getQueryKey for missing dependsOn

Two findings from a final adversarial pass over the migration, both the same
class as the three the review bots caught: something a `fetchOptions` sub-block
declared that its replacement selector quietly does not.

`credential-group.providerFilter` had a `fetchOptionById`;
`workspace.credentialGroupProviders` had no `fetchById`, so the canvas card
summarising several stored provider ids lost every label. The field is
multi-select, which is exactly when a label has to resolve without the full list.

The `dependsOn` assertion in `selector-backed-subblocks.test.ts` only probed
`enabled` against three hand-listed context fields, which is why it caught
`clickup.triggerWorkspaceId` and would have missed the rest. It now probes
`getQueryKey` as well — a selector's key names every context field its RESULT
depends on — and derives the sub-block-sourced set from
`SELECTOR_CONTEXT_FIELDS` rather than a literal. Verified by deleting a real
`dependsOn`: it fails naming the field and the fields it depends on.

Also checked and NOT changed: `display.ts` and the copilot dropdown validator
both guard `options` before use, so stripping `options: []` does not reach them.
The validator's behaviour does shift from "reject every value" (an empty
`validIds` array matched nothing) to "skip validation", which is a relaxation
rather than a regression. `function.sandboxId` kept its `dependsOn: ['language']`.

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

* chore: re-record the page-graph baseline after staging's growth consumed its allowance

CI's "Repo audits" step failed on `check:tool-registry-boundary`. Measured before
touching anything, because the reported growth (+32 and +42 modules on two
routes) looked like this branch had dragged the selector registry somewhere new.

It had not. Recording a baseline on clean `origin/staging` and diffing against
this branch attributes the growth precisely:

  this branch:  +1 to +4 modules per route, +35 total across 25 routes
  staging:      the rest

Staging's six merged commits landed both failing routes at exactly their
tolerance — knowledge/[id] at +31 of an allowed +31, layout at +41 of +41 — so
`check:tool-registry-boundary` passed there with nothing left over. This branch's
+1 tipped both past the line. The next PR to touch anything would have tripped it
just the same, whatever it contained.

The +1..+4 is the selector consolidation's real cost: `selectorRegistry` is one
static object, so a page reaching any selector reaches every provider, and this
branch adds four (hubspot, managed-agent, imap, workspace). That is the same cost
the 27 existing providers already impose, and it is what buys one option-list
mechanism that works off the canvas.

Also tried deferring the workspace provider's data-layer imports to fetch time.
Reverted: this checker follows dynamic imports, so the numbers did not move,
leaving only a Promise.all-of-imports shape that reads worse than the 27 sibling
providers it sits next to.

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

* fix(workspace-forking): actually apply text dependents, and resolve labels against the full selector context

Both from Bugbot; both real, both mine.

**Text dependents never persisted.** `applyDependentOverrides` allowlisted
`dependsOn && selectorKey`, so the plain text fields the collector started
emitting were offered in the modal, stored, and gated on by the Sync button —
then dropped on apply. The field stayed wiped on every push and the typed value
went nowhere, which is the exact treadmill the feature existed to end.

The cause was the rule being written twice. `reconfigurableDependentIds` is now
the single definition of "a dependent the modal can offer AND the sync can write
back", used by the collector and by the apply side. A test asserts the two agree
by round-tripping through `applyDependentOverrides`, and fails against the old
allowlist.

**Provider labels stayed raw ids.** `useDynamicSubBlockOptionDisplayName` called
`fetchById` with a `workspaceId`-only context, which silently fails any selector
scoped by a sibling — `workspace.credentialGroupProviders` needs the group before
it can name a provider, so the `fetchById` restored last round returned null
every time. It now builds the block's real context with
`buildSelectorContextFromBlock`, the same one the canvas uses.

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

* fix(queries): scope the sub-block label cache by the selector's own context

Follow-on to 1f423ab5, and a real gap in it. That commit taught `fetchById` to
read sibling context but left the React Query key at
`(workspaceId, blockId, subBlockId, optionId)`. A label resolved before its
sibling was set — `workspace.credentialGroupProviders` with no group picked,
which returns `null` — stayed cached under the same key and was reused once the
group WAS picked, so the card kept showing the raw id. Changing between two
groups collided the same way.

This is the repo's own React Query rule ("every identifier the queryFn forwards
into the fetch must appear in the queryKey"); `check:react-query` did not catch
it because the context is built in the hook rather than passed as a named arg.

The key now carries the selector's OWN `getQueryKey` for that context, rather
than a second hand-maintained list of context fields. The cache is scoped by
exactly what the selector reads, and stays correct if a selector's dependencies
change later. The context also became reactive (subscribed rather than read via
`getState()`), which is what lets the key move when the sibling does.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 21:34:09 -07:00
Justin Blumencranz 9a621bcde7 fix(workflow): prevent canvas slowdown cascades (#6881)
* fix(workflow): prevent canvas slowdown cascades

* fix(workflow): make connection picker scrolling seamless

* fix(workflow): correct two regressions in the canvas perf pass

Gating `toolBlocks` on the picker's open state also emptied it for the
always-visible selected-tool chips, which silently fell through to their
`getBlock` fallback — the branch documented as the exception for types
hidden from the picker. Only `toolGroups`, where the expensive group build
lives, is gated now.

Re-invoking the find shortcut while the panel was already open stopped
re-selecting the query: `open()` is a no-op when the panel is mounted, so
the mount-time focus effect never re-ran. The panel publishes its focus
callback so the shortcut can drive it either way.

A saturated `slice` also allocated a fresh array once the limit covered a
whole group, re-rendering the memoized "All blocks" group on every tools
page-in — the frame cost the change set out to remove.

Alongside those: fold the two reconcilers into one generic and decide reuse
by identity rather than a three-write `changed` flag; record why the node
comparison is deliberately asymmetric (React Flow augments node objects in
place, so a symmetric `isEqual` would never reuse anything); give the
browse pagination its own constant instead of borrowing the search-result
cap; drop a redundant clamp and the deps it needed; inline the
single-consumer `sliceGroupsToLimit`; and split the bundled ref so the
hottest component stops allocating an object per render.

---------

Co-authored-by: Waleed Latif <walif6@gmail.com>
2026-08-19 20:35:51 -07:00
Bill Leoutsakos e3a4874ece feat(integrations): add Bitbucket Cloud (#6860)
* feat(integrations): add Bitbucket Cloud

* fix(bitbucket): enforce selector workspace slugs

* fix(bitbucket): overfetch small pipeline log tails

* fix(bitbucket): harden provider edge cases

* fix(bitbucket): accept provider diff redirect specs

* fix(bitbucket): stop advanced-field leakage and harden log, status, and selector paths

Splits the `closeSourceBranch` advanced subBlock into per-operation ids. Advanced
fields serialize without evaluating their condition, so a value set on Create Pull
Request reached Merge Pull Request and closed the source branch unprompted.

Also:
- read step logs through the byte-capped server transport and map an empty-log 416
  to an empty result, keeping a genuine 416 an error
- trim a step log's partial leading line after the character cap rather than before,
  and never return an empty log when the retained window held content
- surface Bitbucket's `error.detail` alongside `error.message`
- treat commit-status `key`/`state` as nullable so one malformed row cannot drop a page
- match repository `full_name` case-insensitively and reject dot segments in a
  workspace slug before the outbound request
- type `reviewerAccountIds` as the comma-separated string it is
- trim optional Bitbucket query strings; correct the token lifetime to two hours

---------

Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
Co-authored-by: Waleed Latif <walif6@gmail.com>
2026-08-19 20:32:44 -07:00
Waleed 9f346765fe feat(granola): complete API coverage, note triggers, and connector validation (#6880)
* feat(granola): complete API coverage, note triggers, and validation fixes

Granola's public API exposes nine endpoints; Sim implemented three. Adds the
remaining six and wires the new programmatic webhook-endpoint lifecycle into a
managed trigger.

Tools (6 new, 9 total):
- get_transcript, list_audit_events
- create/list/update/delete_webhook_endpoint

Triggers: note.generated, note.edited, note.access_granted, plus an all-events
trigger. The provider handler registers the Granola endpoint on deploy and
deletes it on undeploy, scoped to the trigger's own event names, and verifies
every delivery with the Standard Webhooks HMAC-SHA256 signature Granola returns
on creation. event_id is the idempotency key, which Granola reuses across
retries.

Validation fixes to the shipped tools:
- get_note dropped speaker.attribution ("me"/"them"); now surfaced
- a 413 on get_note now explains that the transcript is too large inline and
  points at get_transcript, instead of surfacing a bare status code
- note IDs are URL-encoded rather than interpolated raw
- base URL, auth headers, and status-aware error handling are shared runtime
  helpers; params/outputs stay literal per file so the docs generator still
  reads them

Tests cover signature verification (including replay and body-tamper
rejection), event matching, subscription create/delete, and the block/tool
contract — plus a guard that ids shared between the tool and trigger surfaces
seed the same default, since block state is keyed by id and last-wins.

The knowledge-base connector was validated against the spec and needed no
changes.

* fix(granola): correct array output schemas, listing-truncation signal, and docs

Findings from validation passes over the tools, trigger, and connector.

Tools — array outputs were declared as `type: 'json'` with `properties`, which
describes an object, not an array. Agents and the output picker therefore saw
`notes.title` instead of `notes[i].title`. All 15 array outputs (including the
pre-existing three tools) now use `type: 'array'` with `items`, matching the
2000+ other tool files. The audit event `data` field stays `json`; it is
genuinely free-form per the spec.

Connector — `hasMore` was ANDed with the cursor, so a `hasMore: true` response
with no cursor was reported as a complete listing. The sync engine treats
exactly that shape as truncated and sets `listingTruncated` to block deletion
reconciliation; masking it meant a partial first page could be taken for the
whole corpus and reconciliation would hard-delete every note past it. Granola
would have to violate its own contract to emit that shape, but the engine
already handles it and the connector was hiding the signal. Also aligns
mimeType with the `.txt`/text-plain bytes the engine actually writes (it was
the only connector of 101 claiming text/markdown).

Trigger — the setup instructions named a Granola settings path that does not
exist; the help center says Settings > Connectors > API keys in the desktop app.

Both list parsers now split commas inside array entries, so an array-wrapped
free-text value cannot be sent as one malformed identifier.

Block — `id`, `events`, and `hasMore` are produced by several operations but
their descriptions named only one, unlike `folders` which already documented
both meanings.

Adds connector tests pinning all four listingCapped quadrants and the
truncation signal, and tool tests for the list parser and the PATCH body's
per-field "omit means unchanged" semantics.

* fix(granola): clean up webhook endpoints created by a failed registration

Raised independently by both reviewers. The registration service only rolls
external state back when createSubscription *returns* — its rollback is guarded
on `preparedProviderConfig`, so a handler that throws is assumed to have left
nothing behind. Granola's handler broke that contract: when Granola accepted the
POST but the success body was missing `id` or `signing_secret` (including a body
that failed to parse and became `{}`), it threw with the endpoint already live.

Nothing then recorded an external id, so undeploy could not remove it, and
Granola kept delivering to a callback whose signature could never be verified —
duplicating on every deploy retry.

The handler now removes what it created before rethrowing, matching the pattern
grain's multi-hook create already uses. It deletes by id when Granola returned
one, and otherwise recovers the endpoint by matching the callback URL, which
also covers a connection that fails after the request reached Granola.
Endpoints whose URL was redacted to its origin are never matched — that
comparison could delete another workflow's endpoint on the same host. Cleanup is
best effort and never masks the original failure. A non-2xx is left alone, since
no endpoint was created.

Also folds the delete call shared with deleteSubscription into one helper.

* fix(granola): never recover an orphaned endpoint by callback URL

The previous commit's URL-based recovery was unsafe. A redeploy reuses the live
registration's `path`, so the candidate and the currently serving endpoint share
a callback URL — listing by that URL and deleting every match would remove the
live deployment's endpoint and silently stop a working trigger, which is worse
than the leak it was trying to prevent.

Cleanup is now keyed solely on the id Granola returned. When the success body
carries no id there is no way to tell the candidate's endpoint from the live
one, so it is left in place: a leaked endpoint produces unverifiable deliveries
that Granola disables on its own, whereas deleting the wrong one takes down live
traffic with no signal.

The 2xx-missing-signing-secret case this originally fixed still cleans up, since
that response does carry an id.

Adds a test asserting no lookup or delete is attempted when the response has no
id, so URL matching cannot be reintroduced unnoticed.
2026-08-19 19:02:27 -07:00
Waleed 02ae2b4c33 feat(sidebar): add Tables and Files flyouts to the collapsed rail (#6882)
* feat(sidebar): add Tables and Files flyouts to the collapsed rail

Chats and Workflows already open a hover flyout on the collapsed rail;
Tables and Files were plain links. Both now list their contents, with
folders as submenus and the open resource marked.

The chip stays a real link, so clicking still opens the list page and
right-click still reaches the nav context menu. Each flyout owns its
queries and mounts only when the menu opens: a hook on the sidebar keeps
its cache subscription on every workspace route even when disabled, so
an unrelated writer would re-render the whole sidebar for a closed
flyout.

Rows are ordered by the shared sortResources, so pinned rows float and
the flyout reads in the same order as the page it links into.

Also removes two dead components (CollapsedFileFolderItems, FileList)
that were exported but never rendered, and extracts SidebarNavChip so
the rail chip has one definition.

* fix(emcn): stop ordinary menus scrolling at the shared height cap

Every DropdownMenuContent was capped at a flat 240px. A menu is 28px per
row, 13px per separator, plus 12px padding, so a 7-row action menu with
3 separators measures 247px and scrolled for 7px while the 7-row menu
beside it with 1 separator did not.

Raises the cap to 420px, which clears every hand-authored action menu,
and clamps it with min() against the space Radix measures so a menu near
a viewport edge stays on screen — which the flat value never did. The
cap still exists so a long data-driven list scrolls instead of running
the height of the screen.

* fix(sidebar): hold the rail flyout until its lists resolve for this workspace

Both the resource and folder queries keep the previous workspace's rows as
placeholder data across a switch. Gating only on isPending let the flyout
build a tree from one workspace's resources against another's folders, where
no folder id resolves — which the builder reads as "archived out from under
it" and files the whole list at the root.

Gate on isPlaceholderData too, matching foldersResolved in
use-folder-ancestors. An error settles a query without resolving it and is
deliberately not held: the flyout then renders flat, which still reaches
every row.

* improvement(sidebar): mark pinned rows in the rail flyout

The flyout sorts pinned rows to the top via the shared sortResources, but
rendered no indicator, so that ordering read as arbitrary — the exact
pairing Resource's own label cell documents. Carry `pinned` on each row
and render the same non-interactive glyph, on folders as well as
resources.

Adds folder-structure coverage alongside it: per-level ordering, the full
depth of a nested chain, and an empty folder staying in the tree.
2026-08-19 18:52:59 -07:00
Waleed f17938c09e feat(cbinsights): add CB Insights API v2 integration (#6879)
* feat(cbinsights): add CB Insights API v2 integration

Covers every non-streaming v2 endpoint across 25 tools: free organization
lookup, firmographics search, funding rounds and cap tables, investments,
portfolio exits, business relationships, management and board, the Mosaic /
Commercial Maturity / Exit Probability outlooks and their histories, funding
windows, revenue, strategy maps, Scouting Reports, ChatCBI, and RAG context.

CB Insights authorizes by client-credential exchange rather than a static
key, so the tools run through directExecution: the shared executor trades the
credentials for a bearer token, caches it briefly, and re-authorizes once on a
401 — the token lifetime is undocumented, so expiry is discovered rather than
predicted.

ChatCBI and RAG declare request.modelInput so an activated Sim secret in the
message is projected to its canonical label before reaching a third party's
model. directExecution still runs projectToolModelInputParams, so the two are
compatible.

The two streaming endpoints are deliberately excluded; they deliver
incremental JSON chunks and their non-streaming counterparts return the same
content in one piece.

* fix(cbinsights): reject malformed ID lists and bound the token cache

- Reject an organization ID list containing an invalid entry instead of
  dropping it. Silently filtering meant a typo ran the request against a
  narrower set — spending credits on the wrong organizations, or quietly
  widening a filtered search — and still reported success.
- Apply the same rule to the optional firmographics ID filters, where a
  dropped filter broadens the search rather than narrowing it.
- Bound the process-wide token cache so a long-lived worker serving many
  CB Insights accounts does not grow with the cumulative number of accounts
  seen. Expired entries are swept on write, then the oldest evicted.

* fix(cbinsights): stop paging and blank input bypassing the search guards

- Measure the firmographics empty-search guard against the filters alone.
  limit, nextPageToken, and sort were in the same object, so a request
  carrying only paging slipped past it and issued an unfiltered search over
  the whole database — which still spends credits.
- Reject a mistyped numeric bound instead of dropping it. A bad headcount,
  funding, or valuation filter silently widened the search, the same failure
  mode already fixed for ID lists.
- Treat an empty comma segment identically on the required and optional
  paths. A trailing or doubled comma is a separator artifact that cannot
  change which records are requested, so both paths now discard it; every
  other malformed entry is still rejected.

* fix(cbinsights): accept only plain decimal organization IDs

Number reads "0x10" as 16 and "1e2" as 100, so either notation resolved to a
real but unintended organization and the request spent credits on it. Both the
path-scoped and the bulk validators now require a plain run of digits, and use
Number.isSafeInteger so an ID past the precision limit cannot round to a
neighbouring one.

* fix(cbinsights): bound a numeric organization ID to the safe-integer range

The string path already required a safe integer; the numeric path still used
Number.isInteger, which accepts a value past the precision limit. JSON parsing
has already rounded such a value, so the request would target a different
organization than the caller supplied.
2026-08-19 18:31:14 -07:00
Justin Blumencranz 8aab6d573e chore: remove confirmed unused files (#6863) 2026-08-19 18:16:44 -07:00
Waleed a9cf760c0f feat(pitchbook): add PitchBook integration (#6876) 2026-08-19 18:03:25 -07:00
Vikhyath Mondreti 5bc29554d0 fix(workspace-forking): keep a repointed custom block's inputs configurable after the mapping is saved (#6877)
Mapping a custom block to a different block and syncing showed "no changes
required" with no fields to fill, so the inputs #6871 added were unreachable on
every sync after the one where the mapping was picked.

`parentChanged` comes from `shouldReconfigureEntry`, which asks whether the target
was edited IN THIS SESSION. Saving the mapping makes it false, and the reconfigure
listing then keeps only fields that are both required and empty — so an optional
input disappeared entirely and a filled required one never came back.

That test is right for every other kind: an unchanged credential or table mapping
leaves its stored dependent picks valid, and a Gmail label picked under the same
credential still resolves. A custom block has no such continuity. Its sub-blocks
are keyed by the SOURCE block's Start field ids, so under a different target they
describe fields that do not exist and nothing carries over — the mapping standing
IS the reason to configure, whenever it was made.

A custom block mapped to a different block is now always actionable; mapped to
itself ("keep the same block across environments") it is not, since its own field
ids still describe it. An in-session re-pick still wins over the saved target.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 17:40:05 -07:00
Waleed 40aa8ad5eb feat(crunchbase): add Crunchbase Data API integration (#6875)
* feat(crunchbase): add Crunchbase Data API integration

Covers the v4 Data API end to end: dedicated search and lookup operations
for organizations, people, funding rounds, and acquisitions, plus generic
collection-parameterized search and lookup reaching the remaining 39
collections, single-card paging, autocomplete, the deleted-entity feed, and
fields metadata.

Adds a crunchbase-errors extractor: the API answers failures with a bare
JSON array, which no existing extractor reads, so an auth or predicate
failure would have reported only its HTTP status.

* fix(crunchbase): honor card paging limits and cursor exclusivity

- Cap a card page at the documented 100-item maximum instead of Search's
  1000, which the shared Limit field made easy to carry over
- Always request the card's identifier so a narrowed cardFieldIds cannot
  return a full page with a null cursor and stall a paging loop
- Reject the mutually-exclusive afterId/beforeId pair on the card and
  deleted-entity endpoints, not just on search
- Report an unexpected card shape as empty rather than wrapping the
  envelope as a one-row page
2026-08-19 17:23:20 -07:00
Vikhyath Mondreti eede9a94cc fix(workspace-forking): stop a remapped custom block losing every input, and let its target inputs be configured at sync time (#6871)
* fix(workspace-forking): stop a remapped custom block losing every input

Repointing a placed custom block at another environment's block left its inputs
behind. They are keyed by the SOURCE block's Start field ids, so against the new
config they are fields that do not exist, and the serializer drops a stored value
with no matching config as a deleted input. The block synced with its name intact
and every field blank — and because both environments' blocks share a name, that
read as "the sync did nothing and corrupted the block".

The type rewrite itself was landing; a test now pins that rather than leaving it
to the eye, since a successful rewrite is visually identical.

On a type change the inputs are now replaced outright with the ones configured for
the TARGET block, and reserved wiring is preserved. There is deliberately no
attempt to migrate values across the swap: two custom blocks are independent
workflows, so a field id that happened to collide would carry a value meaning
something else. When the type does not change — no mapping, or an explicit
identity mapping — nothing is touched and values carry as they always did.

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

* feat(workspace-forking): configure a repointed custom block's inputs at sync time

Repointing a custom block leaves it with no usable inputs — its sub-blocks are
keyed by the SOURCE block's Start field ids, which describe nothing on the new
block. Until now the user had to open the synced workflow and re-enter them by
hand, with no indication anything was missing.

A credential or table swap already makes its `dependsOn` fields reconfigurable in
the sync modal. Repointing a custom block is the same idea at its limit: not a
subset of fields is invalidated but ALL of them, so all of them are offered. They
travel the existing dependent-value channel end to end — collected into the diff,
stored per (target workflow, block, sub-block), pre-filled from the store, gating
Sync when required and empty, and applied to the written state — so nothing about
storage, pre-fill, or the Sync gate is new.

`parentKind`/`parentSourceId` are the block itself, which is already the key a
reconfig is joined to its mapping row on, so the fields render under their own
row with no extra wiring. `selectorKey`/`parentContextKey` become optional: a
custom block's inputs are typed values, not selectors, and the modal renders a
plain field (a textarea for the JSON-valued types) instead of an option list.

Deliberately no seeding from the source value: it belongs to a different block's
field of the same position, so pre-filling it would carry a value meaning
something else. A block whose type does not change is skipped entirely — its ids
still describe it, so its values carry as they always did.

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

* fix(workspace-forking): namespace, type, and single-source a custom block's configured inputs

Four review findings, all real, three of them the same root cause: the dependent
store holds a plain string keyed only by (target workflow, block, sub-block), and
that key carried none of what applying the value correctly needs.

The key now carries the TARGET TYPE and the field's declared TYPE.

Target type, because remapping a block to A, configuring it, then remapping to B
would otherwise pre-fill and submit A's value into any field id the two happened
to share — a different workflow's field of the same name. Namespacing makes that
structurally impossible instead of a rule to remember.

Field type, because the canvas stores a boolean input as a real boolean (its
sub-block is a `switch`), so a stored `'false'` written as text is truthy to the
child workflow. The apply side reads the type off the key and restores it, and
the modal offers a switch rather than a text field. `object`/`array` stay strings:
they are authored as JSON and parsed by the executor.

Separately, the carve-out that keeps a custom block's stored value alive through
its always-true `parentChanged` was applied at the render site, so the modal
showed the stored value while the Sync gate and the submitted payload still saw
blank — required fields looked filled but kept Sync disabled, and optional ones
submitted empty and wiped the stored mapping. It now lives in
`effectiveDependentValue`, the one place all three read through.

Field-type-to-control selection moves out of the component into its own module,
where it sits beside the boolean round-trip constants it has to agree with.

Reported by Greptile and Cursor Bugbot on #6871.

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

* fix(workspace-forking): keep an unset custom-block boolean unset

Boolean handling collapsed a tri-state. `''` is a flag the user never touched, and
it is not `false`.

On apply, any non-`'true'` string became `false` — so an untouched optional flag
was written as one. `assembleCustomBlockInputMapping` skips `''` but keeps
`false`, so that value reached the child's `inputMapping` and overrode whatever
default the Start field declares. Only an explicit `'true'`/`'false'` is applied
now; anything else leaves the field unset, and the child's own default stands.

In the modal the switch mapped `''` to the False segment, so a required flag
rendered as configured while the Sync gate still read it as empty — the same
display-versus-gate split the previous commit moved into `effectiveDependentValue`
to close, reintroduced one layer up. The value is passed through unmapped instead:
`''` matches neither segment, so the switch renders with nothing selected, which
is what it is.

Reported by Greptile and Cursor Bugbot on #6871.

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

* fix(workspace-forking): let an optional custom-block boolean return to its default

A two-segment switch has no transition back to "nothing selected", so once a user
picked True or False there was no way to stop overriding the target workflow's
declared default — a single click pinned the flag for every later sync.

An optional boolean now carries a third `Default` segment, trailing the two real
values because choosing one is the common action and reverting is the escape
hatch. A required boolean keeps two: the Sync gate demands a value, so unset is
not a state it can end in and offering it would present an unsubmittable choice.

Reported by Greptile on #6871.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 17:16:34 -07:00
Waleed 7b67a2c40c fix(integrations): render one brand icon state everywhere (#6872)
* fix(integrations): render one brand icon state everywhere

A service glyph was drawn three different ways depending on the surface:
brand-colored via getBareIconStyle, muted through --text-icon, or left to
inherit the surrounding text color. The same Dropbox icon therefore read
blue in suggested actions and grey in the connect modal.

Replace the loose helper with a single BrandIcon component (plus
withBrandIcon for component-shaped icon slots) that owns the color, and
migrate every bare call site to it. The tiled treatment (BlockTile /
IntegrationTile) is unchanged.

* fix(integrations): update the mention chip test to the new color owner

The chip test asserted the wrapper still carried the descendant
`[&>svg]:text-*` rule that BrandIcon now owns. Assert the absence of a
competing descendant rule and check the glyph itself instead.

Also give BrandIconSlot and the test's PlainIcon dedicated props
interfaces.
2026-08-19 17:06:22 -07:00
Theodore Li 1372977d07 feat(setup): publish standalone self-hosting package (#6849)
* feat(setup): publish standalone self-hosting package

* fix(setup): refresh discovered compose installs

* improvement(setup): unify repository command

* fix(setup): harden standalone package launch

* Update README.md

* fix(setup): isolate standalone compose installs

* fix(setup): restore default stopped installs
2026-08-19 20:04:05 -04:00
Waleed 04e0fe00c0 fix(trigger): let workers see that Trigger.dev is available (#6869)
* fix(trigger): let workers see that Trigger.dev is available

Workers run Trigger.dev by definition, but the flag saying so is read from the
environment and had only ever been set on the app container. isTriggerAvailable()
was therefore false inside every task run, so work a task dispatched silently
took the in-process fallback instead of the queue it was written for.

Document processing is where this showed: a connector sync chunked and embedded
its documents itself, five at a time, rather than handing them to the
document-processing queue. The queue's concurrency limit, the per-document task's
machine, retry policy and duration budget all sat unused, and a sync with
thousands of documents ran until it hit its own max duration. It also explains
why that task has no runs for connector-synced knowledge bases at all.

Asserting the flag here is safe because the same check still requires
TRIGGER_SECRET_KEY, which only the Trigger.dev runtime provides: anywhere
dispatching is not actually possible the flag stays ineffective and behaviour is
unchanged.

Dispatch failure is now recoverable rather than silent. Only a total failure
raised before, so one failed batch left its documents at pending with nothing
recording why. Those are processed in-process instead, which costs the caller
the time it hoped to hand to the queue but does not drop the work. That path was
unreachable from a worker until this change made dispatching happen there.

* refactor: tighten the comments on this change and the quota classification

Both sets explained the incident that motivated the code rather than the code
itself. That kind of narrative stops being true as the surrounding system moves
and starts misleading instead, so each is cut back to the reason a reader needs.
2026-08-19 16:27:28 -07:00
Vikhyath Mondreti fcea50deb0 fix(provenance): stop size limits silently dropping secret provenance (#6867)
* fix(provenance): stop size limits silently dropping secret provenance

A bundle-selection cap counted cells rather than rows, so a 25-column
table insert lost secret provenance for every row past 400 — the whole
batch was stamped unknown with nothing logged. The same number lived in
the sender, the runtime type guard, and the route contract.

Consolidate every provenance limit into one definition: an 8MB
serialized envelope and 10,000 distinct secrets. The pair had been
copied into seven modules under fourteen names, and several copies had
drifted into bounding inputs — rows, cells, files, chunks — rather than
the envelope.

Remove every limit that could refuse a legal payload, add write-side
cause logging and a workspace-visible audit entry when a read proceeds
on unrecorded provenance, and repair the existing unknown rows.

* fix(provenance): close a repair race and stop memory double-reporting

The repair matched sidecars by the id its page captured, so a
provenance-aware write committing between the snapshot and the delete
had its fresh exact sidecar removed and its marker cleared behind it —
a secret-bearing row left reading as legacy. The delete now re-checks
status, which under READ COMMITTED re-evaluates against the writer's
committed row so it no longer matches.

Walk the candidate set by keyset over row_id. A page whose rows were
all repaired concurrently clears nothing, and terminating on "cleared
nothing" ended the walk with the rest of the backlog untouched.

Memory reported unrecorded provenance twice, and counted records even
when the surface was enforced — auditing a fail-open read that had
actually failed closed.

* fix(provenance): take the repair's locks in the writer's order

The repair deleted the sidecar and only then updated its parent row,
while mutateTableRowsWithSecretProvenance locks user_table_rows up front
and upserts the sidecar inside the same transaction. Opposite orders, so
an overlapping write deadlocked and Postgres resolved it by aborting
either the deployment or somebody's table write.

Lock the parent first, in id order, matching lockTableRows. Holding that
lock is also what makes the status re-check decisive rather than racy:
the writer commits its sidecar and its marker under the same lock, so
once it is held the write is either wholly done or has not begun.
2026-08-19 15:42:33 -07:00
Vikhyath Mondreti 483ff12b6f feat(billing): align enterprise reporting periods (#6851)
* feat(billing): align enterprise reporting periods

* fix(billing): harden enterprise reporting flow

* fix(outbox): preserve handler compatibility

* fix(billing): bound enterprise provisioning reads
2026-08-19 15:39:18 -07:00
Waleed bbe11830f8 fix(knowledge): stop retrying an embedding key with no credit left (#6868)
OpenAI answers an exhausted balance with 429, the same status as a rate limit,
but the two are not alike: a rate limit reopens and a spent account does not.
Both were classified transient, so every document burned its full retry budget
against a key that could never accept it, and because the sweep re-queues failed
documents on every sync the account turned into permanent load rather than a
one-off failure. A connector sync ran the full hour and timed out doing this.

The rejection body is what separates them — insufficient_quota, or a
credit_balance_exhausted code — so it is read when the error is built and the
retries stop immediately.

Retrying and failing over are decided separately here. An exhausted balance
rules out the key just used but says nothing about the next provider in the
chain, so the error stays eligible for failover and only the retries against the
spent key are dropped.
2026-08-19 15:36:20 -07:00
Vikhyath Mondreti efea9de326 fix(redis): reclaim a distributed lock that a timed-out acquire may have taken (#6864)
* fix(redis): reclaim a lock a timed-out acquire may have taken

`acquireLock` awaited `SET NX` and let a rejection propagate. But a rejected
SET does not mean the server declined it: the client is configured with
`commandTimeout: 5000`, and ioredis gives up locally while the command can
still reach Redis and take the lock. The caller never learns it won, so it
never releases — and since every caller treats a throw as "did not acquire",
nothing else releases it either. Every contender then skips until the TTL
expires.

Staging hit this on the Outlook polling cron: `acquireLock` threw
`Command timed out`, the route returned 500, and the next scheduled poll and
the Lambda retry both got `Polling already in progress - skipped` against a
lock whose holder had never started polling. The 180s TTL cleared it.

On failure, best-effort compare-and-delete through the existing `releaseLock`.
That deletes only while this token still owns the key, so a lock another holder
won in the meantime is untouched, and if Redis is still unreachable the TTL
stays the backstop — the behavior without this cleanup. Control flow is
unchanged for all nine call sites: the original error still propagates.

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

* fix(redis): make the timed-out-acquire reclaim opt-in per caller

Review caught that reclaiming unconditionally is unsafe for two caller classes,
both of which exist today:

Callers that fall open. `withLeaderLock` and the MCP OAuth refresh mutex catch a
throw from `acquireLock` and run their work uncoordinated. If the SET landed,
today the lock they hold keeps everyone else out while they run. Freeing it
under them admits a second concurrent runner — for OAuth refresh that means two
rotations of the same token and an `invalid_grant`.

Callers whose lock value is not unique. The copilot chat lock keys on
`streamId`, which is the client-supplied `userMessageId`. Two sends can carry
the same value, so a compare-and-delete from a contender that timed out can
match — and delete — the lock the active stream is holding.

Reclaiming is therefore opt-in, and the option documents both preconditions it
needs: a value unique to the holder, and a caller that does no work when
acquisition throws. Default behavior is byte-for-byte what it was before.

Opted in are the four cron/poll callers that satisfy both — webhook polling,
resume polling, workspace-events polling, and Teams subscription renewal. Each
mints its value with `generateShortId()` and returns 5xx rather than proceeding
when acquisition throws.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 15:35:14 -07:00
Vikhyath Mondreti 9ee1b81b41 feat(custom-blocks): join cross-workspace runs into the caller's trace, and map blocks per environment (#6857)
* feat(custom-blocks): join cross-workspace runs into the caller's trace, and map blocks per environment

Teams that orchestrate work across workspaces have two gaps that keep them on HTTP
blocks instead of custom blocks: they cannot see what a custom block actually did,
and a forked environment silently keeps calling the environment it was forked from.

Debugging. A custom block is an invocation boundary — a published block is org-wide,
so its internals must not reach every consumer by default. The child already writes
its own log row in the source workspace, correlated to the invoking run; the trace
existed, it just was not joined. The parent's span now carries only the child's
opaque execution id, and `hydrateChildTraces` joins the child's spans at READ time,
after authorizing the person reading against the child's workspace. Authorization
follows the viewer rather than a flag set at publish time, re-evaluates on every
read, and needs no second copy of the spans. Each hop of a nested chain is
authorized against its own workspace. Boundaries left unexpanded — no access, no
data, past a cap — say so, because a childless boundary span otherwise renders
exactly like a leaf and a partial trace reads as a complete one.

Live runs stream too, gated on `liveTraceViewerUserId`, which only surfaces with a
single known authenticated viewer set. Chat deployments stream through the same
callbacks and their consumer may be anonymous, so anything that does not opt in
keeps the boundary shut. Child spans handed to such a viewer are projected through
the CHILD's session: the invoking run's registry knows nothing about the publisher's
secrets, so projecting there would leave a source-owner credential unmasked. They
reach the live stream and stop — `createSpanFromLog` still refuses to persist them,
which is what keeps read-time hydration the single authorization point.

Environments. A fork inherits its parent's organization and `custom_block` is keyed
`(organization_id, type)`, so a uat fork resolved to the same row and ran the prod
workflow. Custom blocks become a fork-mappable resource, keyed by BLOCK TYPE — the
rule every kind follows: key by whatever the workflow references, as `file` does
with storage keys and `env-var` with names. A custom block is the only resource
referenced by the canvas block's own type rather than a sub-block value, so the
rewrite gets its own channel. Unmapped blocks keep the source type, because a type
cannot be emptied without deleting the node; they surface as unmapped and block the
promote, which is what stops uat from quietly invoking prod.

Same-named environment copies now carry their source workspace, so an Access Control
allowlist decision between three identical "Invoice Parser" rows is no longer a guess.

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

* fix(workspace-forking): let an explicit identity custom-block mapping resolve

`remapForkBlockType` reported a mapping whose target equalled the source as
unresolved, conflating "a mapping exists" with "the type string changed". Those
are opposite states that produce an identical output `type`, and every caller
uses the flag for the former — to decide whether the reference blocks a promote.

The org-wide candidate list includes the source block, so binding an environment
to the shared block is a normal pick. Under the old flag it raised
`unmapped-custom-block` and refused the sync over a choice the user had
explicitly made. The flag is now named `resolved` and reports mapping existence;
whether the type moved is already visible from `type`.

Reported by Cursor Bugbot on #6857.

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

* fix(custom-blocks): keep a streamed child's spans and markers off the parent's log

Two leaks in the live-stream path, both the same mistake: treating a channel as
viewer-scoped when it is actually persisted, so gating the stream on an
authorized viewer bought nothing.

`childTraceSpans` rode the block output to reach the stream. `filterOutputForLog`
only dropped a hidden key when the block's own config declared it
`hiddenFromDisplay` — true of the workflow block, never of a custom block, whose
outputs are publisher-curated. The source run's spans therefore persisted into
the parent's `span.output`, readable by anyone with parent-workspace access and
never re-checked by `hydrateChildTraces`. A globally hidden key is now dropped at
the top level, not only when nested, and `extractDisplayOutput` strips it again so
no other producer can reintroduce it.

The fan-out also called the invoking run's `onBlockStart`/`onBlockComplete`, which
are persist-then-emit composites: they write block names and I/O into the parent's
LoggingSession before reaching the stream. Those markers are keyed by the parent
execution and outlive the per-viewer check entirely. Custom-block children now go
through `liveStreamCallbacks`, the raw emit-only pair, and fail closed when a
surface supplies none. Same-workspace workflow children keep the composites — they
belong to the same run and their markers are legitimately the parent's.

Reported by Cursor Bugbot on #6857.

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

* fix(custom-blocks): carry the emit-only stream sink into nested executions

Routing custom-block events through `liveStreamCallbacks` forwarded the viewer id
to the child but not the sink itself, so a nested hop cleared
`canStreamCustomBlockToViewer` off the inherited id and then had nothing to stream
through — `parentStreamSink` fell back to `{}` and live traces stopped at the
first sub-executor. That hit a custom block nested inside a workflow block as
readily as one inside another custom block.

The sink now travels with the viewer id, and both are withheld together when
streaming is not permitted. It is always the INHERITED chain, never
`parentStreamSink`: for a same-workspace workflow block that is the persisting
composite, so forwarding it would put a custom block nested inside one straight
back onto the parent's progress markers — the leak the previous commit closed.

Reported by Cursor Bugbot on #6857.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 15:25:28 -07:00
Waleed 7c8d290281 fix(auth): drop the redundant "Continue with" from the Google sign-in button (#6862)
The login and signup social buttons sit under an "Or continue with" divider,
so "Continue with Google" read as "Or continue with Continue with Google" —
and its siblings on the same stack are bare "GitHub" and "Microsoft". Restore
"Google" and hand the icon back to the chip's own leftIcon slot so it picks up
the canonical 16px chipContentIconClass instead of a className size override.

The landing auth modal is a different surface: every button there reads
"Continue with X" (Microsoft, GitHub, email), so its Google label is correct
and stays. Only its icon changes, 20px back to the 18px both siblings use.
2026-08-19 14:02:52 -07:00
Bill Leoutsakos 5d6268db91 fix(branding): refresh Google branding (#6786)
* fix(branding): refresh Google logo

* refactor(branding): trim Google icon tests and correct the SVG wrapper

Drop the GoogleIcon and SocialLoginButtons snapshot tests: they pinned exact
attribute strings, the asset byte length, and the absence of markup the
component never contained, so they broke on any legitimate tweak without
catching real regressions.

Correct the wrapper's viewBox to 0 0 200 204 so it matches the artwork, which
bleeds to all four edges. The previous 204-wide box pinned four units of dead
space to the right via xMinYMin, offsetting the mark within its box.

Rewrite the TSDoc: it described avoiding a WebKit foreignObject gradient bug,
but this file never used foreignObject and already ships 106 linearGradient
definitions. Document the real reason instead - Google publishes the current G
only as a raster.

Align the auth button icon on shrink-0 with its sibling callsite.

---------

Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
Co-authored-by: Waleed Latif <walif6@gmail.com>
2026-08-19 13:59:40 -07:00
Waleed 4f9d5f33b0 improvement(search): search every folder, and document real API error bodies (#6861)
* improvement(search): search every folder, and document real API error bodies

Search on Files, Tables, and Knowledge was ANDed with the open folder, so a
query only ever matched that folder's direct children — and the query was not
cleared when you entered a folder, filtering the folder you just opened down to
the same matches. A non-empty query now searches the whole workspace, a
Location column names each result's folder, and opening a folder ends the
search.

Also gives GET /api/v2/files a `recursive` flag, and replaces the single shared
OpenAPI error example — which showed `BAD_REQUEST` under every status tab — with
one real body per status.

* fix(search): discard the search term on clear instead of masking it

`useSearchFilterValue` returned the debounced term whenever the input was
non-empty, so clearing only hid the settled needle. The mask lifted on the next
keystroke while the debounce still held the pre-clear term — opening a folder
and typing within the window searched the whole workspace for the query the
user had just abandoned.

A clear now resets the settled term rather than hiding it, adjusted during
render so the reset is visible to the render that follows the clear. The
initial state is seeded from the first value so a deep-linked `?search=` still
filters on the first render.
2026-08-19 13:55:53 -07:00
Waleed 936e1acfd8 fix(knowledge): say what went wrong when a document's chunks fail to load (#6858)
* fix(knowledge): say what went wrong when a document's chunks fail to load

`combinedError = documentError || searchError || initialError` collapsed three
different failures into one, blanked the rows and stripped search, sort and
filter — and said nothing. A failed read rendered as an empty table, which is
the same thing the page shows when a document genuinely has no chunks.

Stripping the search box was the worse half: when it was the *search* that
failed, the control the user needed to clear it was the one that disappeared.

The three are now told apart:

- The document itself failing has no page left to draw, so it gets a full
  screen, matching the base page's 'Knowledge base not found' one level up.
  It has to run before the editor branches — `selectedChunkId` renders the
  chunk editor without checking for a document, so a deep link to a chunk of a
  deleted document sat on 'Loading chunk…' forever.
- A failed chunk read keeps the document, so it keeps the chrome and the
  controls, and the message goes in the table body through the `emptyState`
  slot. Tinted with the error token, because at the weight the empty states use
  a failure is indistinguishable from 'nothing here yet'.
- A failed search leaves the loaded chunks intact, so it says the search
  failed rather than claiming the chunks could not be loaded.

`searchError` went through `instanceof Error ? .message : null`, so a rejection
that was not an `Error` produced no message and fell back to the silent blank
this commit exists to remove. It uses `getErrorMessage` now, like the chunk
read beside it always did.

Pagination is dropped on a failed read — it was counting pages nothing fetched
— and the action bar reads the same value, so it no longer lifts itself clear
of a bar that is not there.

The not-found screen was about to be copied a second time, so it moves to
`ResourceNotFound` and the base page adopts it.

* fix(knowledge): let a processing document say so, not that it failed

A document that is not `completed` rejects the chunk read by design —
`requireChunkReadable` throws `KnowledgeDocumentNotReadyError` before it queries
anything — so `initialError` is set for every pending, processing or failed
document. Treating that as a load failure put "Couldn't load chunks" over a
document that is simply still working.

`chunkRows` already builds the right row for those states, and it turns out
nothing could ever see it: the old `combinedError` blanked the rows on exactly
the same condition, so "Document processing pending..." has been unreachable
for as long as it has existed. Excluding not-ready documents from `chunkError`
brings the row back and leaves the error state for reads that genuinely failed.
2026-08-19 13:20:54 -07:00
Vikhyath Mondreti 9864f5cf1d improvement(condition): batch condition evaluation into one sandbox call, and stop the transport from undercutting a route's own deadline (#6854)
* fix(tools): give internal routes transport headroom past their execution budget

A `timeout` param bounds the work an internal route was asked to do — the code
a sandbox runs, the upstream call a proxy route makes. The fetch around it also
pays authentication, body parsing, workspace authorization, worker acquisition,
and response serialization, none of which that budget was sized for. Arming the
client with the bare number made the caller give up at the same instant the
route's own deadline fired, so the route could never win the race and report
which part actually ran long — the caller saw an unattributable
`Request timed out` instead of `Function execution timed out after 5000ms`.

Add 30s of headroom, sized above the isolated-vm worker's own 10s startup
budget so a cold worker spawn stays inside the transport deadline rather than
aborting it. An execution abort signal, when present, still bounds the call.

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

* improvement(executor): evaluate a condition list in one sandbox call

A condition block spent one `function_execute` round trip per branch, so a
four-branch block that fell through to `else` paid four sandbox executions
before routing. Build one script that tests each expression in order and
returns the index of the first truthy one.

Ordering and short-circuiting are unchanged: an expression is only reached once
every earlier one returned falsy, so a later expression that throws is still
never reached and the run takes the same branch it took before. The script's
`catch` reports the index it was on as data rather than rethrowing, which is
what lets the handler still name the failing branch in its error.

A batch that produces no verdict falls back to one call per branch — the path
this handler used before. That is load-bearing rather than redundant: a syntax
error anywhere in the list fails the whole script at parse time, while
evaluating one at a time only reaches, and so only fails on, the branches the
run actually takes. A timed-out or cancelled batch skips the fallback, which
would otherwise re-run every branch against the same stall.

An unrecognized reply is treated as no verdict rather than as "nothing
matched", so a garbled response cannot silently route the run down the else
path.

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

* fix(executor): wrap condition expressions the same way in both paths

The batched script put each expression on its own line inside `Boolean(...)`
so a trailing line comment ended before the closing parenthesis; the per-branch
fallback still inlined `Boolean(${expression})` on one line. That made the
recovery path stricter than the path it recovers — a batch that failed to parse
because of a later branch would fall back and then reject an earlier
comment-bearing branch it should have matched.

Both paths now wrap through `buildBooleanTest`, so they cannot drift again.
Also narrows the evaluation-context boundary from `Record<string, any>` to
`Record<string, unknown>`; the context is only ever serialized, never indexed.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 12:45:47 -07:00
Waleed ede762f550 feat(knowledge): empty state for a base that holds no documents (#6855)
#6828 gave the four workspace resource lists a zero-data graphic and left the
one list a level below them still painting column headers over a blank body —
the list every user meets immediately, because creating a base does not require
a file and does not navigate anywhere on success.

The mark is a stack of sheets with the front one dog-eared and ruled. The
dog-ear is the one signifier the set does not already use: the folder is a
container and the knowledge mark is a shelf of volumes, and this has to read as
the pages inside one of them rather than as either.

It reuses the rest of the recipe — hairline contours, the surface ramp for
depth, ink mixed off `--text-secondary` because the ramp is near-white in light
mode, and a fade running the direction the stack recedes. `HAIRLINE` was
byte-identical between the folder and this mark, so it moves to a shared module
beside `mask.ts`.

Visibility goes through the same `isResourceListEmpty` the four pages use, with
one difference the call site documents: it counts the server's `total` rather
than the visible rows, because this list is paginated and an empty page 2 is a
paging position, not an empty base. The folder arguments are omitted — a base's
documents are flat.

The frame is derived from the artwork's bounds rather than a round-numbered
viewBox. The sheets step up and to the right, which left the drawn mass far
enough off-centre that the mark sat visibly right of the copy beneath it.
2026-08-19 12:34:05 -07:00
Waleed 7167ed67f6 fix(knowledge): stop one env knob from setting the embedding request fan-out (#6852)
* fix(knowledge): stop one env knob from setting the embedding request fan-out

KB_CONFIG_CONCURRENCY_LIMIT was read in three places with three meanings: the
document-processing queue depth, the number of embedding requests issued
concurrently inside a single embed call, and (divided by five) the in-process
document concurrency. The first two multiply — every admitted task run reaches
the embed path and opens its own fan-out — so the default put roughly a thousand
requests in flight against one provider key. A rate limit is per key, so the
pipeline held itself at the limit, and no retry policy can absorb a load its own
concurrency is generating.

Each variable is now read by exactly one consumer, which also removes the drift
that hid this: the same variable was read with a different inline fallback in
each place, and since createEnv runs with skipValidation the declared defaults
never execute, so the fallbacks were the real ones and disagreed. The divisors
are gone and the previous effective values are the declared defaults, so only
the embedding fan-out changes: 50 to 8.

KB_CONFIG_BATCH_SIZE had the same conflation between chunks-per-embedding-request
and documents-per-batch, and is split the same way.

Rate-limit rejections also discarded what the provider said about when to come
back. The response headers were dropped when building EmbeddingAPIError, so the
retry loop's support for a server-stated wait was dead code on this path and
every attempt fired blind, exhausting the budget inside a window that had not
reopened. The headers now travel with the error the way fetchWithRetry already
does for connectors, and the wait is read from Retry-After or, failing that, the
reset header for whichever limit dimension is actually exhausted. Those carry a
Go duration rather than the epoch seconds the shared connector helper expects,
so the reading lives with the provider instead of changing retry behaviour for
every connector. The retry budget is sized against a rate-limit window rather
than a blip, since a 10s ceiling clamped every stated wait below the reopen time.

* fix(knowledge): stop retrying an embedding wait we will not honor

Honoring the provider's stated wait introduced a case the retry budget could
not serve. When a provider states a reset longer than the ceiling, the loop
clamps every attempt to that ceiling, so the whole budget is spent inside a
window that has not reopened — and with five attempts at thirty seconds that
delayed the fallback provider by around two and a half minutes, where the
previous blind backoff reached it in about seven seconds.

A stated wait past the ceiling now refuses the retry outright. The error still
classifies as transient, and the fallback chain classifies separately through
shouldFallback, so the next provider is reached immediately instead of after the
budget burns down. Retrying was never going to succeed in that window, so
nothing is given up.

* fix(knowledge): measure a stated wait against the whole retry budget

Refusing to retry once the stated wait passed the per-attempt ceiling was too
blunt. Each wait is clamped individually but the attempts accumulate, so a
window a little longer than one clamped delay still reopens partway through the
budget: a 35s wait is reachable on the second attempt. Rejecting those stranded
a caller with no fallback provider, which would have recovered by waiting.

The comparison is now against the budget the attempts span in total. A window
inside it is retried and can recover; only one that outlasts every attempt is
unreachable, and that still fails fast so the fallback chain is reached at once
rather than after the budget burns down.
2026-08-19 12:09:05 -07:00
Waleed fc2087b4c1 polish(resources): stop the tables grid reappearing past its fade (#6853)
The grid is authored larger than the box it fades inside — 358x160 drawn into
320x148, deliberately, so it runs off two edges. But a mask tile is sized to
the element box and `mask-repeat` starts at `repeat`, so the overflow landed in
the *next* tile at the opaque head of the gradient: a solid strip of cells
reappeared just past where the fade had finished dissolving. The frame now
clips, and every fade in the set pins `no-repeat` rather than relying on its
subject happening to fit.

Also:

- `--border-1` is a legacy alias; the new files use the canonical `--border`,
  which `resource.tsx` was already using two lines above them.
- `createIsoLineProps` returns `SVGAttributes<SVGElement>`. It never returns a
  `ref`, and `ref` was the only member forcing an element type — which had made
  the knowledge mark reach for an `SVGPathElement & SVGCircleElement`
  intersection to spread onto both. `className` moves last so no caller passes
  `undefined` positionally to skip it.
- `isResourceListEmpty` is exported from the components barrel its four callers
  already import `Resource` from, instead of being reached past it.
- `emptyState` sits after `rows` on all four tables; three had it leading.
- Two TSDoc blocks claimed things the code stopped doing: the folder graphic
  does not have three fill tiers, and the empty-state wrapper grows the slot
  but does not centre it.

Adds the predicate's unit test — it is a pure eight-clause function that
decides whether a page tells someone they have nothing, and it had none.
Verified it fails when the placeholder and folder guards are removed.
2026-08-19 12:05:15 -07:00
andres 681a8ec427 feat(resources): empty-state graphics for knowledge, tables, logs, and files (#6828)
* feat(resources): empty-state graphics for knowledge, tables, logs, files, skills

Four of the resource pages (knowledge, tables, logs, files) had no empty
state at all — `Resource.Table` painted column headers over a blank scroll
area and stopped there. Skills had a `: null` branch for zero data.

Adds a graphic per resource, drawn in the editor vignette's recipe: take the
product's own primitives, shrink them, strip the content to skeletons, and
let the composition bleed off the frame edges.

- Knowledge — a document fanning into the chunks it is embedded as, using the
  editor's 6px smooth-step connector language in --workflow-edge
- Tables — a sheet of cells running off two edges with one cell in an edit ring
- Logs — runs stacked newest-first, their trace spans staggered into a waterfall
- Files — a folder held open with one file still above its dashed landing slot
- Skills — a skill card opened far enough to show the tools bundled inside

`Resource.Table` gains a sanctioned `emptyState` slot rendered below the
column headers when `rows` is empty, so the chrome guarantee still holds.
Each page shows the graphic only for true zero-data — never for a search or
filter that matched nothing, never inside an empty subfolder, and (logs) never
before the first page of runs lands.

Also ports the shared `EmptyState` frame from the editor branch so this branch
stands alone, and adds a review-only /empty-states-preview gallery route.

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

* improvement(tables): redraw the empty-state graphic in the house grayscale

Matches the workflow editor's vignette and the landing feature graphics, which
between them use no brand colour at all — every one of them is built from
neutral tokens.

Two corrections:

- The blue edit ring is gone. Nothing in the reference graphics carries a hue,
  and it was the loudest element on the page.
- `--surface-4`/`--surface-5` are near-white in light mode (#f5f5f5/#f3f3f3), so
  skeleton geometry built on them dissolved on a white card. Bars now mix
  `--text-secondary` into transparent at graded strengths — a real mid-grey that
  inverts with the theme, which is the idiom the editor vignette already uses for
  the one bar it needs you to see.

Also drops the full-composition mask. The editor vignette keeps its block fully
opaque and fades only the connector strokes leaving the frame; masking
everything is what made the miniature read washed rather than deliberate. The
card is crisp now and the continuation is drawn the way a real table draws it —
an overflow fade at the edge the columns run off.

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

* improvement(tables): strip the empty-state graphic to ruled lines and a corner fade

Minimal pass. The card is gone — no border, no fill, no header shading, no type
squares. What is left is the grid itself: hairline rules in `--border-1`, ink
bars at two strengths, and the one cell held in an edit ring.

With no card fill the grid sits directly on the page, so it can dissolve into
the background instead of ending at a border. The fade is the landing page's own
idiom — two gradients intersected (`mask-composite: intersect`), crisp at the
top-left and gone through the bottom-right, the same construction
`workflow-graph-preview` uses.

Two placement notes:

- The grid is offset right of frame centre. A diagonal dissolve puts the visual
  mass toward its opaque corner, so centring the geometry would leave the
  graphic reading left of the copy beneath it.
- The selected cell sits in the quadrant the fade leaves fully opaque. A
  selection ring dissolving mid-stroke reads as a rendering fault, not a detail.

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

* improvement(tables): make the selected cell opaque and a shade darker

The ring mixed `--text-secondary` into `transparent`, so the grid rules running
underneath showed through its own stroke. Mixing into `--bg` instead holds the
same apparent value while staying opaque, and still inverts with the theme.

Raised 32% -> 46% so it reads as chrome rather than more content, and added a
stacking context: neighbouring cells are later siblings, so their rules were
painting over the ring's right and bottom edges.

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

* improvement(tables): round the empty-state grid's crisp corner

6px on the top-left only — the one corner the fade leaves intact, and the same
radius the workflow editor's vignette uses. The other three dissolve, so there
is nothing there to round.

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

* feat(knowledge,tables): redraw knowledge's empty state and add docs/create chips

Knowledge gets the same treatment tables just went through: no brand colour (the
`--brand-knowledge` accent is gone), no card chrome, ink mixed from
`--text-secondary`, and the landing page's intersected corner fade.

The graphic is a document and the chunks it is embedded as. Its fade is held
back further than the tables grid on both axes — the document has to stay whole
for the graphic to mean anything, so only the chunk grid may trail off. The
three chunks the edges actually land on are the only filled ones; filling the
whole first column left a chunk with no edge feeding it.

Both empty states now carry two chips in the frame's action slot — a docs link
and the create action, each running the same handler as the header's primary
chip and inheriting its disabled state.

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

* review(knowledge): three candidate depictions, and lead with the create chip

Chip order swapped on both empty states — the primary action reads first, the
docs link second.

Adds a review-only `knowledge-alternates.tsx` rendered in the preview gallery,
because the document-to-chunks graphic is not landing. Three directions:

- A. the embedding mesh — the landing hero's own knowledge-base panel already
  draws a base this way (`stage-kb.tsx`), so this is the house depiction rather
  than a new invention
- B. a stack of documents — the most literal reading, at the cost of colliding
  with what the files empty state wants to draw
- C. a query and the passages that answered it — depicts what a base is for,
  which is what the description copy actually promises

Delete this file and the gallery entries once one is chosen.

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

* feat(knowledge): draw the empty state as an isometric set of volumes

Replaces the document-to-chunks diagram, which read as a workflow graph rather
than as a knowledge base.

Built on the landing page's iso-illustration recipe rather than a new one:
`ISO_STROKE` contours (`--text-subtle` mixed toward `--text-muted`) at the shared
3.2 stroke width, faces filled from the three-tier surface ramp brightest-on-top,
round caps and joins. Geometry is authored in a large unit space so that 3.2
lands as a hairline once scaled to empty-state size — the same reason the landing
marks draw 3.2 into a ~526-unit viewBox.

The projection and faces are computed rather than hand-authored as path data, so
the volumes stay coherent when the geometry is retuned.

No corner fade here. The fade belongs to repeating structures that mean the same
thing cropped — the tables grid keeps its meaning with two columns or four. A
discrete object does not, which is also why the workflow editor's vignette keeps
its block fully opaque.

Drops the three candidate depictions now that the direction is settled.

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

* improvement(knowledge): brand the front volume instead of laying a page beside it

Drops the loose page on the ground and puts the knowledge-base mark on the front
volume's cover — the same `Database` glyph the sidebar and the page header use,
so the empty state names its own resource.

The mark is laid into the cover's plane rather than drawn over it. The cover is
the face at max x, spanned by the volume's depth across and its height up;
walking those two edges gives the face's basis vectors in projected space, and an
affine matrix built from them maps flat artwork into the face. So the glyph
skews with the isometric, and because both vectors derive from the box, retuning
the volumes carries the mark with them instead of stranding hand-fitted path data.

Its stroke is pre-divided by the same factor the matrix scales by, so the glyph's
contours land at the volumes' weight rather than four times it.

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

* improvement(knowledge): bore through the front volume, and fade the set back

Replaces the mark on the cover with a hole through it.

The bore is authored as a plain circle in the cover's own plane and skewed into
an ellipse by the face matrix. Its far mouth is the same circle stepped back
through the volume: boring straight back is a world step of `-w` along x, and
solving the cover-plane matrix for the local offset that produces it gives
`(+w, -w)`. The sliver of near mouth the far mouth fails to cover is exactly the
wall you see down the hole, so the depth falls out of the geometry rather than
being drawn by hand.

Down the hole the near mouth is floored in a tone darker than any outer face —
the wall turns away from the light — and the far mouth is painted in the cover
tone of the volume standing behind it, because looking through a hole in the
front volume lands on that volume's face, not on the page.

Corners stay square. Rounding was tried and reverted: rounding each face
separately notches every corner where three faces meet, and rounding the
silhouette instead cost a clip per volume for a softness the set did not want.

The tables grid's corner fade is applied along the other diagonal. There it
dissolves toward the bottom-right because a grid keeps its meaning cropped; here
the set recedes up and to the left and the front volume carries the bore, so
anchoring at the bottom-right eats into the back of the stack and reads as more
volumes behind.

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

* feat(resources): logs and files graphics, and settle the set as one collection

Logs is an activity feed — newest run lifted onto its own card, older ones
settling behind it. The relative stamps are the only literal text in any of these
graphics; everything else stays skeleton, so nothing here has to be translated or
kept true.

Files is a folder with sheets standing proud of its front panel. Depth comes from
the surface ramp rather than shadow, which would need separate light and dark
recipes where the ramp inverts on its own. The tab's diagonal is filleted at both
ends and every outer corner shares one radius — mixing radii, or running the
diagonal into square junctions, made the corners fight at this size.

Consistency pass across the set:

- Titles are the resource name alone. "No tables yet" earned nothing the
  description does not already say.
- The knowledge mark is mirrored so its bore faces left. Rebuilt on the geometry
  rather than flipped, since a flip would have put the shading on the wrong side.
  Its contours are thinned and mixed toward `--border-1`: the landing marks are
  the focal art of their section, but this one sits beside a ruled grid whose
  lines are 1px, and full-weight contours read as ink next to it.
- The logs feed is sized to the same ~148px footprint as the rest. The frame
  centres graphic and copy together, so a taller graphic pushes its title out of
  line with the others' and the set stops reading as one thing.
- Every empty state carries its create action and a docs link, each running the
  same handler as the header's primary chip.
- Fades run whichever way the subject recedes: the tables grid to the
  bottom-right, the knowledge set up and right, the logs feed down, the folder
  up.

Fixes a duplicate React key in the knowledge mark — the volumes stack along y
now, so keying on `box.x` gave every one of them `0`.

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

* revert(skills): drop the skills empty state

Removed at request. The skills list goes back to rendering nothing for zero data,
which is what it did before this branch.

Takes `vignette.tsx` with it — the shared stage and skeleton bar were left over
from the first pass, and skills was the last thing still importing them once the
other four graphics were redrawn.

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

* cleanup(resources): fix empty-state flashes and share the iso ramp

Drops the review-only preview route and gallery, which the branch always
meant to delete before merging.

Three ways the zero-data graphic painted over a workspace that has content:

- The gate read the instant URL search term while `rows` is filtered by the
  debounced one, so clearing a search that matched nothing showed the full
  "you have nothing yet" state for one debounce window.
- Nothing gated on the list still loading. Knowledge and tables hydrate from
  a server prefetch that is allowed to seed nothing, and the files list
  deliberately seeds nothing above 300 rows — so the emptiest-looking screen
  was shown to the fullest workspaces.
- The filters are part of the query key and every list keeps the previous
  key's data, so `isLoading` is false across a filter change. Only the
  placeholder gate suppresses the graphic during that refetch.

Also folds the re-declared isometric fills and stroke back onto the shared
`iso-illustration-style` source they were copied from, so a change to the iso
ramp reaches this mark too; only the stroke width still diverges. The static
face paths move to module scope, the bore interior becomes a named component
so its note is TSDoc rather than a JSX comment, and the four identical docs
chips become one.

* simplify(resources): reuse the iso recipe and let the frame own its layout

Hides the empty-state graphic behind `error` as well. A failed load also
leaves `rows` empty, and inviting someone to create their first item is the
wrong answer to a request that did not complete — all four pages only logged
the error, so the zero-data copy was what a failed load actually rendered.

`iso-illustration-style` moves out of the landing route group to
`components/iso/`. Importing it from a workspace route was the only
workspace-to-landing edge in the app, one directory away from an `iso-marks`
barrel that pulls ~10KB gzipped of illustration components — a hazard for
whoever needs the second constant. The contour recipe is now shared too:
`createIsoLineProps` takes an optional stroke width, so the knowledge mark
stops re-declaring it and only its weight diverges.

`EmptyState` owns the action row's layout, so the three pages with two chips
drop their wrapper div and every empty state's chips sit identically. Its
unused `className` prop goes with them. Also drops the `height` prop that had
one caller passing its default, and the `CORNER` constant that promised
single-sourcing the path's four bare literals did not honour.

* simplify(resources): decide list emptiness in one place

"This list holds nothing" was derived in four pages, each with the same seven
clauses under the same nine-line comment. Adding the `error` gate one commit
ago took four identical edits, and the skills empty state that was reverted
off this branch would have made it five copies.

`isResourceListEmpty` now owns the rule and the reasoning behind each gate.
Logs omits the folder argument because it has no folder navigation; the other
three pass theirs.

`Resource.Table` also wraps the slot in its own growth box, so the empty state
centres because the table says so rather than because the node handed to it
happened to carry `flex-1`.

* chore(audits): re-record the page module-graph baseline

The four empty-state graphics and the shared frame add **+10 modules** to each
of the five routes that render them — measured against `origin/staging`, not
against the recorded baseline:

    files/[fileId]  1958 -> 1968
    files           1958 -> 1968
    knowledge       2167 -> 2177
    logs            1727 -> 1737
    tables          1817 -> 1827

The baseline itself was last recorded in #6697, and staging has drifted up to
+29 on tables since — inside the max(25, 2%) tolerance on its own, but close
enough that this +10 tipped it over. So the failure was the stale baseline
meeting a small real addition, not a heavy import. The other 29 entries move
only by that accumulated drift.

The graphics stay eagerly imported on purpose: an empty state is the first
thing a new workspace paints, and deferring ~4KB gzipped behind a chunk
request would trade a shared, already-fetched module for a visible pop on the
one screen where the product has to look like it works.

* fix(resources): hold the empty state until folders resolve

Folder rows share the list with resource rows, so a workspace whose only
contents are folders has an empty `rows` until the folder tree lands — and got
the "create your first item" graphic in the gap. The resource list's own
loading gates never covered it because the folder tree is a separate query.

`useFolderNavigation` already exposes `foldersResolved` (`isSuccess &&
!isPlaceholderData`) for exactly this hazard — it guards the ancestry index
against evicting a folder id it has not loaded yet. Knowledge and tables pass
it straight through; files reads the same two flags off
`useWorkspaceFileFolders`, which it calls directly. Logs omits it, as it has
no folders.

---------

Co-authored-by: andresdjasso <andresdjasso@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Waleed Latif <walif6@gmail.com>
2026-08-19 11:36:25 -07:00
Waleed cb6c842e27 feat(knowledge): read a PDF's text layer before paying for OCR (#6850)
* feat(knowledge): read a PDF's text layer before paying for OCR

Every PDF went to OCR, an external per-document call, even though most carry an
embedded text layer that costs nothing to read. Across a real corpus of 2,693
documents, local extraction produced text for every PDF that OCR could also read,
so the great majority of those calls bought nothing.

A PDF's text layer is now read first and used when it is good enough, leaving OCR
for the documents that actually need it. Three ways a layer fails, none of which
catches the others: there is no text at all (a scan), the text is too sparse to
be the document, or there is plenty of text that is not language — a broken
encoding, or the raw character ids a CID-keyed font emits with no ToUnicode map,
which is common in exactly the contract and procurement material that reaches a
knowledge base and which a length check alone reads as healthy.

Beyond the cost, this narrows an availability dependency: an OCR outage no longer
touches every PDF, only the minority that cannot be read locally. The threshold is
env-tunable so the balance can be moved toward cost or fidelity without a deploy.

Known limitation: the judgement is per document, so a file mixing typeset pages
with scanned inserts can average above the threshold and keep its partial text.
Per-page routing would catch it and needs per-page extraction this does not have.

The opaque-input refusal now asserts against the outbound request rather than the
storage read: local parsing is not model input, so bytes are read before the
projection is checked and still never leave the worker when it refuses.

* fix(knowledge): route a truncated PDF extraction to OCR, and drop the threshold env var

Two corrections to the text-layer triage.

A parser limit stops extraction partway and reports `truncated`. Such a result has
plenty of text by volume, so every volume-based check read it as healthy and the
document was indexed as a fragment with the remainder silently missing from
search. Truncation is now judged before anything that measures volume, and sends
the document to OCR, which reads it whole.

The characters-per-page threshold is a plain constant again. It read
`process.env` directly rather than going through the env module, and the tunable
was not worth having: a typeset page carries roughly 1,500-3,000 characters and a
scan carries none, so the value sits in a wide gap where no realistic tuning
changes an outcome. A constant is one less piece of configuration that can be set
wrong, and if the threshold is ever wrong the fix is to change it.

* fix(knowledge): take the page count from the parse that produced the text

The density check counted pages with a second, independent read of the file. The
two could disagree: a count that failed reported no pages, the check fell back to
treating the document as a single page, and a long scan carrying only a header
looked dense enough to skip OCR and be indexed as that header.

`parseBuffer` already reports the page count from the parse that produced the
text, so the two can no longer diverge, and the redundant second open of the file
goes away with it.

* fix(knowledge): chunk a long PDF for Azure OCR instead of refusing it

Both OCR providers cap how many pages a single request may carry, and both were
handling that cap differently: one split the document to fit, the other rejected
any document over it. A long PDF could therefore be ingested on one provider and
not at all on the other, for a limit that belongs to a request rather than to a
document.

The splitting, concurrency, ordering and partial-failure rule now live in one
place that both providers call, so they cannot drift apart again. A chunk that
fails is dropped rather than failing the document — losing one section of a long
document beats losing all of it — and every chunk failing still throws.

Also drops the unpdf mock from the triage tests. It was masking real behaviour:
the page count now comes from the parse metadata, so the mock was no longer
needed, and while it was in place a test asserting the old page-cap refusal
passed against both the old and new code.

* fix(knowledge): keep an unsplittable PDF and an empty OCR response honest

Two regressions from chunking the Azure path.

Splitting loads the document, which an encrypted or malformed PDF refuses, and
that failure was deciding whether the file reached OCR at all. Those are exactly
the documents the triage routes here — no readable text layer — and the provider
may well accept bytes a local parser will not, so a failed split now sends the
document whole and leaves the page cap to the provider, as it did before it was
chunked.

An Azure response carrying no pages fell back to the raw API payload as content.
Chunked, that payload counted as recovered text and was stitched into the
document; unchunked, it satisfied the empty-content check written to catch this.
No pages is now no content, so the chunk counts as failed and the document
reports it.

* fix(knowledge): fail a PDF whose OCR only partly came back

A chunked OCR run dropped any chunk that failed and returned the rest as a
normal success, so the document was marked complete with whole page ranges
absent from search and nothing downstream could tell the difference.

That contradicted the rule this change set already applies to a truncated text
layer, which is sent to OCR precisely because indexing a fragment while
reporting success is the failure being removed. A document is now indexed whole
or not at all: any missing chunk fails it, leaving it visible with a reason and
eligible for the stuck-document sweep, which can retry and produce a complete
result. Each chunk has already exhausted its own retries, so a missing one is a
real failure rather than a blip.

The page-cap test mocked fetch with a single Response object, whose body can
only be read once — the second chunk was failing on "Body already read" and the
lenient path hid it. It now returns a fresh response per call.
2026-08-19 11:30:55 -07:00
Waleed 10ff62259f fix(connectors): treat a zero-byte source file as nothing to index (#6848)
Observed in production after connectors began delivering source files: a
zero-byte PDF was stored and shipped to OCR, which answered `400 Bad Request`.
That bills an external call to discover the file was empty and reports it as an
API fault rather than as what it is. Before source files existed, an empty file
produced empty extracted text and was dropped at the empty-content check, so
this was a regression.

The emptiness rule now lives in one place, `hasIndexablePayload`, used by the
sync engine's classify and hydrate gates and by both connectors' `getDocument`.
It previously existed twice — the connectors asked whether a source file was
present while the sync engine asked the same question a second way — and a
source file with no bytes satisfied both.
2026-08-19 10:23:51 -07:00
Waleed dafa4dadda fix(settings): drop the settings return url when the workspace changed (#6847)
The settings Back button restores a return url captured on entry, but a
workspace switch made from inside settings keeps the user in the new
workspace without touching that stored path — so Back pushed them back
into the workspace they had left, while the sidebar still read as the new
one. Discard a stored return url that names a different workspace and
fall back to the current workspace root.
2026-08-19 10:15:21 -07:00
Vikhyath Mondreti 7dac31b06c feat(library): AI Agent Orchestration Frameworks Explained (#6846) 2026-08-19 06:27:13 -07:00
Waleed 1b30611363 improvement(settings): drop the Delete account row's description (#6845)
The confirmation modal already states the consequence, and states it better:
it names the account, enumerates the workspaces that will be deleted, notes
which billing transfers instead, marks 'This cannot be undone' in the error
color, and requires typing the email to proceed. Nothing is lost by removing
the line from the row.

The wrapper it shared with the row goes too, now that the row is the section's
only child.
2026-08-18 22:15:36 -07:00
Waleed ae8b169fbc improvement(settings): drop the Privacy row's description (#6844) 2026-08-18 21:59:30 -07:00
Vikhyath Mondreti d9098895fa fix(workspaces): explain why org admins can't be removed from a workspace (#6838)
* fix(workspaces): explain why org admins can't be removed from a workspace

Organization admins hold workspace admin through their org role, not a
permissions row, so removal had nothing to revoke. It failed with "User
not found in workspace" for someone listed as an Admin on the same
screen, and when they also held an explicit row it deleted a grant the
derived one immediately replaced — which could drop their org membership
and seat, since the seat reconciliation counts rows only.

* fix(workspaces): stop offering leave to org admins and surface refusals

Sidebar Leave was still offered to non-owner organization admins, whose
access is derived and cannot be given up, and the confirm modal swallowed
the refusal — so it sat open with no reason shown. The workspaces list now
reports whether the viewer's admin access came from their org role, which
`permissions: 'admin'` alone could not distinguish from an explicit grant.

Also folds a disabled row action's tooltip into its accessible name, since
Radix skips disabled items in a menu's roving focus.
2026-08-18 20:21:42 -07:00
Waleed f82085accc refactor(consent): fold cookie preferences into General > Privacy (#6837)
* refactor(consent): fold cookie preferences into General > Privacy

The consent settings were a top-level tab of their own, which is the wrong
weight for something a user opens once. They are now a sub-view of General,
reached from the Privacy section that already held the telemetry toggle, and
that toggle moves with them so one page owns everything Sim collects.

Cookies render only on the hosted service, the only deployment that sets them;
telemetry renders everywhere, so the sub-view is useful on a self-hosted
deployment too. Each cookie switch commits on change rather than staging behind
a Save, matching the telemetry switch directly above it -- one interaction
model per page, and no unsaved-consent state. saveConsents('custom') reads
selectedConsents from the store at call time and the switch's write is
synchronous, so the value a toggle stages is the value it commits.

The open sub-view lives in the URL, so it is linkable and Back closes it.

* fix(consent): keep the old /settings/privacy link working

The section moved into General, so the path no longer resolves. Redirect it to
the replacement view through TOP_LEVEL_REDIRECTS, which the route already uses
for the integrations and skills moves.

* fix(consent): stop two cookie toggles from racing, and revert a failed one

Each save sends the whole selectedConsents snapshot, so two quick toggles could
finish out of order and land the older choice. The switches now lock while a
commit is in flight, exactly as the telemetry switch does on its own mutation,
and a failed commit puts the switch back instead of showing a preference that
was never recorded.

Also stop the General blurb promising cookie controls on a self-hosted
deployment, where the sub-view only carries telemetry.
2026-08-18 20:16:33 -07:00
Justin Blumencranz 7bfd78c0a3 DONT MERGE fix(workflow): attach cmdk-added blocks to the selected block (#6836)
* fix(workflow): attach cmdk-added blocks to the selected block

* improvement(workflow): fall back to last-touched block for cmdk adds, park unattached blocks in the rightmost column

* fix(workflow): validate positionless-add sources for eligibility before choosing
2026-08-18 19:25:29 -07:00
Vikhyath Mondreti 521348b529 feat(secrets): record which secrets each run resolves, and surface it per secret (#6823)
* feat(secrets): record which secrets each run resolves and surface it per secret

Redaction stops a value at a boundary but cannot stop code that never emits it —
a Function block can print a key one character at a time and nothing ever matches
the secret. That is undecidable in general, so this adds the other half of the
posture: attribution.

Every run now records which configured secrets it actually resolved, under whose
identity, through which surface (workflow, Sim agent, MCP). The data already
existed in ResolvedSecretTraceRegistry.addActiveEntry and was persisted only for
paused runs; this persists it for every terminal path.

Execution logs cannot answer this. They store the whole available encrypted
environment rather than what a run referenced, they evidence a secret only where
value-matching redaction happened to fire, and they expire under
logRetentionHours — while "who has touched this key" outlives any single run.

- secret_usage: per-UTC-day rollup keyed by workspace, secret, scope, owner,
  source, workflow, actor. A one-minute schedule touching three secrets would
  otherwise write thousands of rows a day, which is also why this is not
  audit_log. workflow_id/actor_user_id use '' sentinels rather than null so the
  unique key works on Postgres 14 without NULLS NOT DISTINCT, and are not FKs:
  they are historical facts, and an onDelete would rewrite a key column.
- secret_owner_user_id is part of the key. Two people can hold a personal secret
  under one name and a shared personal secret resolves for a caller who does not
  own it, so name and scope alone do not identify a secret. It is NOT the actor:
  a scheduled run resolves the workflow owner's personal slice under the
  workspace's execution actor.
- Direct environment reads are now detected in JS (TypeScript AST), Python
  (tokenizer-checked) and shell (quote/heredoc-scanned), so a secret read as
  environmentVariables['K'] or $K enters the run's provenance instead of going
  unredacted. Each detector prescans for names that are actually configured
  secrets before paying for a lex or quote-frame pass.
- Copilot integration tool calls are covered: resolveCopilotEnvReferences
  substitutes {{SECRET}} into user-only params, which is a real use.
- See usage lives behind a credential-admin gate, using the same predicate that
  reveals the value; members get a disabled chip explaining why.

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

* chore(audit): register the secret-usage route in the validation baseline

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

* fix(secrets): keep rollup metadata with its run, and stop shadowed bindings faking usage

Review round 1.

- record.ts: last_execution_id/last_trigger were assigned unconditionally while
  last_used_at was chosen by greatest(), so two runs completing out of order split
  one row between them — the newer run's timestamp beside the older run's execution
  id, making "View log" open a run the row does not describe. Both are now guarded
  on the timestamp actually advancing, so the row's metadata always belongs to the
  run that owns its timestamp.
- javascript.ts: a local binding named environmentVariables (declaration, parameter,
  destructured binding, or bare reassignment) made reads off the user's own object
  look like mounted-secret reads. Any such binding now disables detection for the
  file; the AST already had parent pointers, so this is a kind check during the
  existing walk.
- python.ts: same class of bug with no parser available, so the rule is an allowlist
  — every mention of the binding must be a literal subscript or .get(), otherwise
  detection is off for the file. This also subsumes the cross-line attribute case
  (other.\n environmentVariables['K']), which the previous space-and-tab look-behind
  missed.

Under-reporting is the safe direction here: a trail that claims a use that never
happened is worse than one that misses a use.

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

* chore(db): format the generated migration snapshot

CI runs lint:check across every workspace; the drizzle-kit output in packages/db
had never been through biome, so the branch was green locally (where lint had
only been run inside apps/sim) and red on CI. Whitespace only — both files are
byte-for-byte identical once parsed, and drizzle-kit still reports no pending
schema diff against the reformatted snapshot.

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

* fix(secrets): detect every rebinding of the environment identifier, not just declarations

Review round 2. A bare `for (environmentVariables of rows)` has no declaration to
key off, so the previous check missed it and reads of the loop value were still
recorded as secret usage.

Rather than extend the hand-rolled node-kind list, this reuses the pair the same
file already applies to reject a placeholder in a write position:
isDeclarationIdentifier covers declarations, parameters, destructured bindings and
imports, and isWriteIdentifier covers every assignment operator, ++/--,
destructuring targets, and for-in / for-of initializers.

That also closes four forms neither the review nor the original check named:
logical (||=) and nullish (??=) assignment, and object and array destructuring
assignment. Six of the eight added cases fail against the previous check.

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

* fix(secrets): apply the rebinding rule to shell, and say when a run's log is gone

Review round 3, plus the docs that were left claiming the old behavior.

- shell.ts: a script that writes a configured name (API_KEY=local, export/local/
  readonly, read, for, unset) expands its own value from that point on, not the
  mounted secret, so recording it claimed a use that never happened. Every mention
  of the name must now be a `$NAME` / `${NAME}` expansion, matching the allowlist
  shape the Python detector already uses. Applied per name rather than per file:
  JavaScript and Python shadow one object holding every secret, whereas rebinding
  one shell variable says nothing about the rest.

- The usage trail deliberately outlives execution logs, so a row routinely names a
  run whose log has been pruned. The read now left-joins workflow_execution_logs on
  its unique execution_id and reports availability, and the panel renders the chip
  disabled with the platform tooltip instead of linking into an empty Logs view.
  Three states: no run to link, a run whose log is gone, and a live link.

- Docs said a direct environmentVariables/$KEY read does not activate masking,
  which this branch changes. Corrected in credentials.mdx, function.mdx and the
  logging FAQ, and the recognition limits are now written down: runtime-built
  names, reassigned bindings, and reads that cannot be told apart from text.
  Added a "See usage" section covering who can see it and why an empty trail
  means "nothing recognized" rather than "never used".

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

* fix(secrets): writing a name is not reading it, and a bare mention is not a rebinding

Review round 4.

- javascript.ts / python.ts: `environmentVariables.API_KEY = 'x'` and
  `delete environmentVariables.API_KEY` touch the name without ever reading the
  mounted value, but the detectors matched the member access and recorded a use
  that never happened. JavaScript now asks the same isWriteIdentifier the
  placeholder rewriter uses (its parameter is widened to ts.Node — the body
  already walked generic nodes, so this is a type change, not a behaviour one)
  plus a delete check; Python excludes a subscript followed by `=` and a `del`
  target.

- shell.ts: requiring every mention of a name to be an expansion also fired on
  text that binds nothing — a comment naming the key, or `echo "API_KEY=$API_KEY"`
  where the literal is an argument rather than an assignment — and dropping those
  cost masking on a genuine read. It now looks for actual writes: an assignment at
  command-word position, a binding builtin, `printf -v`, or a `for` target.

  The two directions are not symmetric, which is why this errs toward detecting
  the read: missing a write records a use of a secret the script only had in its
  environment, a misleading audit row and nothing more, since masking still
  searches for the real value and will not find it. Over-detecting a write
  suppresses masking on a value that does reach the log.

  This also makes the code match what the docs already described — skipping after
  a rebinding, not after any mention.

13 tests added; 11 fail against the previous code.

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

* fix(secrets): an update reads before it stores, and a del target may be parenthesized

Review round 5. The first of these is a regression from round 4.

- javascript.ts: reusing isWriteIdentifier to answer "is this a read" was wrong.
  That predicate answers the rewriter's question — is this a target the
  substitution must refuse — so it treats every assignment operator alike, which
  is correct there and wrong here: `+=`, `||=`, `??=`, `++` and `--` all load the
  current value before storing, so they are genuine reads and were silently
  losing their masking. Only a plain `=` stores without reading. Replaced with a
  purpose-named predicate, and isWriteIdentifier's parameter is narrowed back to
  ts.Identifier now that nothing else needs it widened.

  A test committed last round asserted the wrong behaviour for `+=`; it has been
  corrected rather than left to pin the bug.

- python.ts: `del (environmentVariables['K'])` slipped past a check that looked
  only at the characters immediately before the match. It now isolates the
  enclosing logical line and tests whether that is a del statement, which also
  covers `del((x))`, `del(x)`, `del a, x`, and a del after a semicolon.

12 tests added or corrected; 10 fail against the previous code.

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

* fix(secrets): stop excluding Python writes, which kept leaking in the unsafe direction

Review round 6. Greptile found that `del environmentVariables[environmentVariables['K']]`
had its inner access — which computes a key, so it is a genuine read — skipped
along with the delete, leaving that value unmasked.

The narrow fix was another textual rule. Instead this removes the write and delete
exclusions from the Python detector entirely, because they were optimizing the
wrong direction.

`resolvedSecretNames` feeds `outputSecretMatcher`, an exact-value matcher over the
output. Naming a secret the code never read costs nothing there: the matcher scans
for a value that does not appear. Failing to name one that was read leaves it
unmasked. The two error directions are therefore not comparable, and the
exclusions bought only audit-trail tidiness while every heuristic they needed has
so far leaked into the dangerous side — first a parenthesized target, now a nested
read. A `del` or an assignment is reported like any other access.

JavaScript keeps its exclusion: a real AST answers the question per node, with no
text to misread, and it has produced no such hole.

Net 30 lines removed from python.ts.

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

* fix(secrets): report recognized reads instead of proving they are not reads

Review round 7. Greptile flagged both directions at once — false usage from
reporting a write target, and unmasked secrets from the file-wide shadow flag —
so I traced what the signal actually drives before choosing.

The chain: the compiler's names feed outputSecretPlaintextsByName and the
exact-value matcher, NOT context.resolvedSecretNames, which starts empty. After
execution activateOutputSecretProvenance scans the output and adds only names
whose plaintext actually appeared; those become __resolvedSecretNames, which
tools/index.ts turns into recordResolved calls, which is what the usage trail
reads.

So a compile-time false positive produces no usage row on the ordinary path — it
only hands the matcher a value the code never emits. It does produce one on the
!projection.safe fallback, where the system already over-approximates by design.
A false negative, by contrast, keeps the value out of the matcher entirely, so a
genuinely read secret is never masked on any path.

That asymmetry decides it, so every "prove this is not a read" mechanism is gone:

- javascript.ts: the file-wide shadow flag. A helper declaring its own
  environmentVariables discarded genuine reads of the mounted binding everywhere
  else in the file — Greptile's security finding, and real.
- python.ts: the allowlist requiring every mention to be a subscript or .get().
  Same hole: passing the dict to a function suppressed unrelated reads.
- shell.ts: the rebinding check. It had the same hole in a form nobody flagged —
  `echo "$API_KEY"; API_KEY=local` dropped the first read, which is of the real
  secret.

What stays is the question of whether the text is code at all — strings, comments,
single quotes, quoted heredocs — plus the receiver check that `other.environment
Variables['K']` is a different object, and JavaScript's node-precise write/delete
exclusion, which cannot suppress a read elsewhere.

Net 215 lines removed across the three detectors and their tests. Docs updated:
the rule is now stated as reporting rather than proving, and that See usage may
occasionally list a secret the code had available but did not read.

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

* refactor(secrets): drop the last write-vs-read special case

`environmentVariables` is a plain object deserialized from the run payload
(route.ts:206), not a handle on the stored secret. Assigning to it changes
nothing outside the sandbox and is discarded when the run ends, so separating a
write from a read bought almost nothing while leaving JavaScript as the one
language still trying to prove a read is not a read.

Every language now follows the same rule: report a recognized read of a
configured secret name. The only exclusions left are facts rather than
inferences — the text is not executable (string, comment, single quote, quoted
heredoc), the receiver is a different object, or the name is not statically
knowable.

Docs note that assigning to the binding does not edit the secret.

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

* refactor(secrets): ship only the fields the trail actually shows

Five fields crossed the API and reached no reader: usageDate, firstUsedAt,
actorEmail, workflowId and actorUserId. The panel renders the timestamp, the
trigger, what used the secret, the actor's name, the run count and the run link;
everything else was projected, serialized and discarded.

first_used_at is dropped from the table as well. Nothing read it, and inside a
per-day bucket "first used that day" says nothing next to "last used that day" —
so it was a column written on every run for no question anyone asks. The upsert
loses its least() with it. Migration regenerated; the identifier columns behind
the joins stay, they simply are not returned.

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

* fix(secrets): report referenced code secrets, not only ones that surface in output

The Function route activated a secret's provenance — and therefore its usage row
and downstream masking — only when the exact value appeared in the result,
stdout, or error. That gate made the trail miss silent use entirely: a key that
authenticates an API call and is never echoed reported nothing, and so did the
founding scenario of this feature, a key exfiltrated character by character. The
innocent run that echoed a key got a row; the run worth catching did not.

Activation now follows the referenced set the compiler already computes: resolved
{{KEY}} bindings plus recognized direct reads, filtered to configured values —
the same set the unsafe-projection fallback already activated. An extra name only
hands the output matcher a value that never appears; configured-but-unreferenced
values are still never included. The output-scan activation path and its surface
helper are deleted rather than kept alongside.

One old test pinned the gate ("does not activate a referenced secret that does
not cross the Function result"); it now asserts the reverse, with the reasoning
attached. Two new tests pin the char-split exfiltration and the silent API-call
case.

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

* fix(secrets): shell escaping is backslash parity, not adjacency

Review round 8. `\\$API_KEY` is an escaped backslash followed by a LIVE expansion
— bash prints `\` plus the value — while `\$API_KEY` is an escaped dollar and
stays literal. Checking only the character adjacent to `$` read every even run as
escaped, dropping a real read from usage and masking alike; verified against
bash before fixing.

The scanner now counts the run of backslashes before the `$` and skips only odd
runs, the same parity rule logicalLineEndAfterContinuations in this file already
applies to line continuations. Six-case parity table added; the three even-run
cases fail against the previous check.

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

* fix(secrets): recognize destructured environment reads

Review round 9. `const { API_KEY } = environmentVariables` delivers the value by
name with no property- or element-access node in the AST, so the member-access
walk missed it entirely — and a missed read leaves an emitted value unmasked,
the dangerous direction.

The AST walk now also recognizes the declaration form (shorthand, renames,
defaults, string-literal keys), the assignment form ({ KEY } = env), and a
...rest element — which names no key but takes every value, so it reports every
configured name; the alternative left `const { ...all } = env; return all`
entirely unmasked. A computed key stays unrecognized, the same runtime-name
boundary as a computed subscript, and a receiver that is not the bare identifier
is not attributed.

Nine cases added; the six positive ones fail against the previous walk.

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

* fix(secrets): one receiver rule for destructured reads, parentheses included

Review round 10. Two accurate findings, folded into a generalization instead of
two more special cases:

- A parameter default (function f({ API_KEY } = environmentVariables)) and a
  binding-element default are the same by-name delivery as a variable
  declaration. The detector now keys on the ObjectBindingPattern itself and
  checks its parent's initializer, so every declaration position follows one
  rule instead of per-kind arms.
- Parentheses group without changing the receiver, so (environmentVariables) is
  unwrapped before the identifier check — in the destructuring arm AND the
  member-access arm, which had the same hole unreported.

Declined the for-of-over-array-literal finding: the receiver there is a
container, not the environment object, and following data flow through
containers has no fixed point — the same documented boundary as aliasing and
computed keys. A test pins the boundary so it reads as chosen, not missed.

Eight cases added; the seven receiver-rule cases fail against the previous code.

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

* fix(secrets): a dot in prose is not a qualifier, and a literal computed key is a subscript

Review round 11. Both findings were implementation-narrower-than-rule, fixed by
consulting authorities the detectors already had rather than adding new ones:

- python.ts: the receiver walk crosses whitespace so a parenthesized `other.` on
  a previous line is seen — but it landed on a comment's final period
  (`# Load the value.`) and discarded the genuine read on the next line. The
  landing position is now checked against the same lexer ranges that filter the
  candidates, which is also why the receiver check moves after lexing.
- javascript.ts: `const { ['API_KEY']: key } = environmentVariables` is the
  element-access rule in pattern position, so a computed key holding a string
  literal resolves like a literal subscript; any other computed key keeps the
  runtime-name boundary a computed subscript already has.

Eight cases added; the comment-period case and all three literal-computed-key
cases fail against the previous code.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 18:32:20 -07:00
Waleed ff232799a7 feat(consent): manage cookies from Settings, not from a card in the workspace (#6835)
* feat(consent): manage cookies from Settings, never from a card in the workspace

The banner no longer mounts inside the workspace at all. The gate sits above
the dynamic() boundary rather than inside the lazily-loaded module, so the
product pays neither the consent chunk nor its init request on the surface with
the most hard loads. A signed-in user manages the same choice from
Settings -> Privacy, which shares one store with the banner: the options live
in ConsentStoreProvider and are not exported, so two call sites cannot drift
into two stores.

The banner also stops pinning the light token layer and simply inherits. The
cause it was working around is that LandingShell pins light on a wrapper inside
the page while <html> keeps the visitor's theme, so landing routes missing from
ThemeProvider's hand-written list rendered light pages under dark root chrome.
LANDING_ROUTES becomes one source of truth in lib/landing/routes, read by both
next.config (COEP) and ThemeProvider (forced light) -- the same drift that let
/cookie-policy ship without its COEP exemption. Diffed old against new across
every real route: 16 landing routes gain the correct theme and nothing
regresses. /cli/auth and /credential-groups/complete are added too; both render
AuthShell and were never covered.

Verified the shared-store assumption directly rather than trusting the docs:
getOrCreateConsentRuntime returns the same store and manager for equal options.

* fix(consent): keep Privacy on one settings surface

Projecting the section into the account plane put it in a catalog that
buildPlaneSettingsItems does not gate on requiresHosted, so a self-hosted
deployment would list a Privacy entry, and AccountSettingsRenderer's catch-all
rendered Mothership for it. The unified settings already gate the section and
redirect self-hosted deployments to General, so the section lives there only.
2026-08-18 18:12:56 -07:00
Waleed 9328e66d69 feat(account): let users delete their own account (#6831)
* feat(account): let users delete their own account

Adds a GDPR self-serve account deletion path: a preflight that reports
what deletion would remove and every reason it would be refused, and a
confirmed delete that erases the account and everything only it can reach.

Deletion refuses while the account is still entangled rather than
reassigning its content. Most tables reference user.id with ON DELETE
CASCADE, and those cascades do not distinguish content in the account's
own workspace from content it created inside somebody else's, so each
blocker names the existing action that untangles it (leave the workspace,
leave the organization, cancel the plan) — all of which already hand work
over on their own tested paths.

* fix(account): make deletion atomic and re-check privacy at delete time

Reorders the teardown so nothing irreversible happens before the deletion
is certain: anchors are handed over first (the fallible step, while
everything is still recoverable), the workspace and user deletes now share
one transaction, and the object-storage purge runs only after that commits.

The workspace delete also re-checks inside the transaction that each
workspace is still private, so a membership granted between the preview and
the delete aborts the whole thing instead of destroying the new member's
access.

* fix(account): close deletion gaps found in review

- Run the whole teardown in one transaction. The billing and ownership
  handovers now take the caller's transaction, so a refused deletion can no
  longer leave a workspace reassigned for a deletion that never happened.
- Fail closed on a subscription read error. getHighestPriorityPersonalSubscription
  defaulted to returning null, which read as "no plan" and would have erased an
  account Stripe was still billing.
- Erase the account's profile picture. It is personal data under our own
  storage prefix; an external provider avatar is left alone.
- Enforce the storage purge cap while collecting keys rather than after, so an
  oversized account cannot exhaust memory before the cap applies.
2026-08-18 17:35:57 -07:00
Justin Blumencranz b6ca0d787f fix(emcn): prevent leading glyph clipping in chip inputs (#6833) 2026-08-18 17:12:15 -07:00
Waleed b446b42018 feat(consent): cookie consent banner and cookie policy (#6832)
* feat(consent): add a hosted-only cookie consent banner

Adds a c15t-backed consent runtime and a Sim-styled banner, mounted from
the root layout only when `isHosted` is true. A self-hosted deployment
never mounts the runtime, so it makes no request to Sim's consent backend
and never sees the banner.

The banner is a non-modal card docked bottom-left, opposite the toast
stack, built from the same chrome (border, --bg, --shadow-overlay) and
from Chip/Switch/Label rather than c15t's own components — the runtime is
imported from `@c15t/nextjs/headless`, which ships no UI or stylesheet.
"Customize" expands the same card into per-category switches instead of
opening a dialog over the app. Visibility and the available actions come
from the jurisdiction policy the runtime resolves, and accept and reject
are rendered with identical weight.

* feat(consent): cookie policy page, CSP allowance, and design-system alignment

The consent backend was blocked by our own CSP, so the runtime silently fell
back to an offline policy that showed the banner to every visitor worldwide and
recorded nothing. The backend origin now lives in lib/consent/constants and the
CSP builder allows it from that single source.

Banner: mount the runtime beside the app rather than wrapping it, behind a
dynamic() boundary, so consent state cannot re-render the page tree and a
self-hosted build never fetches the chunk. Align chrome with the toast card
(z token, font scale, --text-body/--text-muted pairing) and mirror the light
token layer the public shells pin, which a dark-theme visitor on a landing
route outside ThemeProvider's forced list would otherwise miss. Read the
category list from the store's own getDisplayedConsents() — the shipped
defaults mark every category except necessary as display:false, so the
hand-rolled filter rendered a one-row list.

Docs: add /cookie-policy as a third ProsePage consumer with the cookie
inventory in tables (a new table block kind on the shared primitive),
cross-reference it from the Privacy Policy, and wire it into the sitemap and
llms.txt. The policy promises consent can be changed at any time, so the
banner can be reopened from it.

* refactor(consent): apply the cleanup pass

- Drop the .light DOM probe: it matched the banner's own element, so once set
  it could never flip back, and it went stale on a theme toggle with no
  navigation. The card now pins the light layer unconditionally, as every other
  public surface does.
- Hoist the motion/style objects to module scope.
- Move a chip's mr-auto into the row layout; chips carry no outer margin.
- Use the shadow-overlay utility and --border rather than the legacy alias.
- Render <caption> before <colgroup>, which the HTML spec requires.
- Make the code formatting of a table column a renderer concern (codeColumns)
  instead of JSX smuggled into the row content.
- Raise the table caption above body weight, and tighten comments.

* refactor(consent): apply the simplify pass

The consent runtime installs a childList+subtree MutationObserver on
document.body for its iframe blocker, for the life of every hosted page —
including the workflow canvas — and re-scans each added subtree. Sim gates no
iframes by consent, so disableAutomaticBlocking turns it off.

Also: collapse the ConsentProvider passthrough into the dynamic() export; move
ConsentPreferencesLink under (landing)/cookie-policy so a shell module no
longer imports landing chrome; build the three cookie tables from one shape;
move the table column widths into the prose chrome layer; only compute the
category list when the card is expanded; drop the ConsentCategory cast; express
the card width in Tailwind rather than an inline style.

Comment corrections: the sibling mount is forced by ssr:false, not by
re-render concerns; lib/consent/constants must stay dependency-free because
next.config loads it and the browser bundles it; codeColumns exists for
biome's useJsxKeyInIterable, not for React; the headless entry omits the
components but the provider still injects an inert --c15t-* style block.

* fix(consent): address the first review round

- Add /cookie-policy to LANDING_ROUTES. It is an app/(landing) route, and
  every one of those must be exempt from COEP: the header is inherited across
  soft navigations, so an isolated landing page navigating into /demo leaves
  the Cal.com booker loading uncredentialed.
- Render the withdrawal control as plain text on a self-hosted deployment,
  where the consent runtime is never mounted and the button had no listener.
- Give ConsentPreferencesLink a named props interface.
2026-08-18 17:08:27 -07:00