Commit Graph
4777 Commits
Author SHA1 Message Date
ea70f8dcf1 feat(harmonic): add contact workflow integration (#6902)
* feat(harmonic): add contact workflow integration

* fix(harmonic): sync docs manifest

* fix(harmonic): address integration review findings

* feat(harmonic): add the missing people endpoints and fix two error paths

Extends the integration from 4 to 13 tools, covering every non-deprecated
people-scoped Harmonic endpoint, and repairs two defects found by validating
the existing tools against Harmonic's OpenAPI and API reference.

New tools:
- Enrich Person (POST /persons) — the only path from a LinkedIn URL or email
  a workflow already holds to a Harmonic contact.
- Get Person, Get Company Employees — account-based sourcing; employees returns
  URNs that chain into Batch Get People.
- Saved-search net-new results and their acknowledgement, so a monitor stops
  reprocessing the entire result set on every poll.
- Bulk email enrichment: submit, poll, and quota, plus Get Enrichment Status.

Fixes:
- The error extractor dropped Harmonic's string and object `detail` envelopes.
  A tool that names an extractor gets no fallback chain, so every FastAPI abort
  surfaced as "Request failed with status 403". The enrichment 404 also carries
  the scheduled `enrichment_urn`, which was being discarded — that URN is the
  only handle on the job, so it is now kept in the message.
- The saved-search selector failed the whole dropdown instead of degrading:
  the response cap was half the sibling value on an endpoint that is
  unpaginated and returns every saved search with its full query object, and
  the option ceiling threw rather than truncating. Raised to 1MB and switched
  to truncate-and-warn, matching the other data-driven selectors.

Clearing net-new results now requires an explicit scope. Harmonic treats an
absent `entity_urns` as "clear everything", so an empty field would have
silently discarded the backlog.

Scope deliberately excludes company-side, deal, typeahead, network, and Scout
streaming endpoints, and every endpoint retiring on 2026-11-05.

---------

Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
Co-authored-by: Waleed Latif <walif6@gmail.com>
2026-08-20 17:12:57 -07:00
Waleed 893e729b3f fix(og): put the shared-file card on the brandbook cover template (#6907)
* fix(og): put the shared-file card on the brandbook cover template

* fix(og): bound the cover title and caption to the fixed canvas

* fix(og): measure cover text with the font's real advance widths

An average glyph width under-measures caps-heavy names and over-measures
narrow ones, so a viewer-supplied file name could still clip off the fixed
canvas. Measure against the same font Satori is handed instead, matching
the library cover generator; the tests parse the font independently so the
assertion is not made with the estimator it is checking.

* fix(og): apply the leading compensation to the title, not the whole footer

On the footer the 14px nudge dragged the caption into the bottom padding as
well, and the caption has no phantom leading to correct for. Matches how the
sibling cover renderers scope the same offset.
2026-08-20 16:28:09 -07:00
Waleed 865f8173ab feat(affinity): add Affinity CRM integration (#6908)
* feat(affinity): add Affinity CRM integration

Adds the Affinity v2 API as a block with 70 tools, covering 86 of the 87
documented endpoints. Only Send Feedback is omitted — it reports product
feedback to Affinity rather than doing workflow work.

Endpoint families that differ only by an entity segment are one tool with an
entityType param, so companies/persons field, list, row, and relationship
reads, the company/person merge endpoints, and entity notes each collapse
into a single operation.

* chore(affinity): regenerate the docs manifest for the new integration page
2026-08-20 16:27:29 -07:00
Theodore Li a99f61bee9 feat(api): add v2 resource management endpoints (#6900)
* feat(api): add v2 resource management endpoints

* fix(cli): gate destructive v2 commands

* fix(test): update v2 request-slice count

* feat(cli): add shared workspace profiles
2026-08-20 18:55:57 -04:00
Vikhyath MondretiandClaude Opus 5 85290693c4 refactor(search): one definition of what a search occurrence is (#6905)
Follow-up to #6901, closing two places where the workflow search index and the
Note card that mirrors it could drift apart. Neither is a live bug; both are the
shape that produced one — the card silently disagreeing with the panel about
which hit is which, counted in one place and painted in another.

THE SCAN. #6901 shared `foldSearchWhitespace` but left the scan around it
duplicated: normalize, then non-overlapping `indexOf` stepping by
`max(len, 1)`, written out once in the indexer and once in the renderer package.
They agree today. They would stop agreeing the moment either grew whole-word
matching, diacritic folding, or a regex mode, and the failure is silent. Both
now call one `forEachSearchOccurrence` in `@sim/utils/string` — the only place
either package can share, since the card renders from a package that cannot
import from `apps/*`.

THE DECLARATION. The indexer projects markdown escapes only for a field
declaring `searchTextFormat: 'markdown'`; the card projects unconditionally,
because it cannot read the block registry. Dropping that one line from the Note
config would leave them disagreeing with nothing to catch it, so a test now
pins it and explains why.

Net negative in lines: this deletes a duplicated loop rather than adding a
layer.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 15:44:22 -07:00
Vikhyath MondretiandClaude Opus 5 2e111f615c fix(search): answer a Note match on the canvas card (#6901)
* fix(search): answer a Note match on the canvas card

A workflow search match inside a Note counted towards the result total and
then highlighted nowhere: the editor panel renders nothing for a Note, and
`clearCurrentBlock` — which the panel calls to refuse one — also cleared the
shared `activeSearchTarget`, destroying the very target the card was about to
paint. Searching a 15k-character note reported "1 of 6" and moved nothing.

The card's read view is now the surface that answers:

- A rehype plugin marks every rendered occurrence; the current one is picked by
  an ordinal counted with the same scan the indexer uses, and travels by context
  so cycling matches does not re-parse the document.
- `<Streamdown>` is keyed on the query. Its memo comparator ignores
  `rehypePlugins`/`components`, so a plugin change alone cannot re-render it —
  marks appeared only when something else remounted the card, and then outlived
  the query that produced them.
- The canvas selects, centres and expands the Note, because a compact card
  resets its scroll region to the top and cannot hold a position deep in its
  own body. Scrolling to the mark is `scrollTop` arithmetic, never
  `scrollIntoView`, which would drag ReactFlow's transformed viewport off-frame.
- Title matches mark the name too.

`activeSearchTarget` is re-published with a fresh identity on most of the search
panel's renders, so subscribers take primitives. Holding the object in
`WorkflowContent` — the panel's own ancestor — closed an unbounded update loop.

Separately, the serializer escaped every underscore, writing
`SB\_ACTION\_ROUTER\_SECRET` into the document. CommonMark's intraword rule
means that backslash carries no meaning, and search matches the stored markdown,
so it made anything with an underscore unfindable in a note that plainly showed
it. Dropped outside code regions, where the serializer emits verbatim and a
backslash is the author's own character.

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

* fix(search): honour fences and folded whitespace in Note highlighting

Two review findings, both real.

The intraword-underscore cleanup guarded code with a pattern that recognised
only the shortest delimiter forms — a bare ``` pair and a single-backtick span.
A ````-fenced block, a tilde fence, or a ``multi-backtick`` span ended the
region early and handed the rest of the author's code to the rewrite, turning
`a\_b` into `a_b` inside their code sample. Fenced blocks are now walked a line
at a time, tracking the opening delimiter exactly the way stripEmptyListItemLines
already does (three or more, closed only by a run at least as long), and the
inline branch matches a backtick RUN closed by one of equal length.

The note scanner claimed to be the same scan as the indexer's `findTextRanges`
but did not fold whitespace, which staging added since this branch was written.
The indexer folds every `\s` to a space, so a phrase matches across a soft line
break — which `remark-breaks` renders as a `<br>`, splitting the phrase over two
text nodes that a per-node scan could never see. The hit counted in the panel
and highlighted nowhere, the exact bug this branch exists to fix.

The plugin now scans runs of continuously-readable text rather than single
nodes, so a match spanning an inline boundary (a soft break, a bold word) is
wrapped as several marks sharing one ordinal. Runs end at any non-inline
element, so two paragraphs are never joined into a phrase the reader cannot see.

`foldSearchWhitespace` moved to `@sim/utils/string`: the canvas card renders
from a package, which cannot import from `apps/*`, and two copies of that rule
silently disagreeing is precisely what produced the second finding.

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

* fix(markdown): close a fence only on a bare delimiter run

A closing fence carries nothing but its delimiter run; a line that merely
starts with one is content. The guard matched the prefix alone, so an interior
line like ` ````example ` inside a same-length fence ended the block, and every
cleanup below then processed the author's remaining code as prose — dropping
the backslashes from their `a\_b`.

Both fence walks in this file shared that flaw, so both now go through one
`closesFence`, which requires the run to be followed by nothing but whitespace.
Strictly more conservative: a fence stays open longer, so more content is left
verbatim.

Scope, stated plainly: the serializer always opens a block with one more
delimiter than the longest run inside it, so its own output cannot reach this
shape today, and `postProcessSerializedMarkdown` only ever sees serializer
output. This is a correctness fix that removes an unstated coupling to that
choice, not a live corruption path. The tests therefore exercise
`postProcessSerializedMarkdown` directly — a round-trip test of the same input
would pass either way, which is exactly the vacuous check worth avoiding.

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

* fix(markdown): skip quoted fences, and stop joining runs across inline tags

Two review findings, both real, both the same shape: a rule that looked at the
rendered form and forgot what the source actually says.

QUOTED FENCES. The fence walk only recognised a bare delimiter run, but the
serializer writes a fence inside a blockquote or a `[!NOTE]` callout with a `>`
on every line. Code state was therefore never entered there and the block's
interior was cleaned up as prose: `> x = a\_b` round-tripped to `> x = a_b`,
losing the author's backslash. Unlike the fence-length cases this one is
reachable today — verified against the real serializer before and after. Both
fence walks now unquote the line first.

INLINE JOINS. Runs concatenated the visible text of every inline tag, so
`a<strong>b</strong>c` read as `abc` — a hit that cannot exist in the markdown
the indexer scans, where `**` sits between the words. That is worse than a
spurious mark: `occurrenceIndex` counts SOURCE occurrences, so a fabricated hit
earlier in the document steals the current ordinal and paints the mark on text
the search never matched. Only `<br>` continues a run now, because it alone
stands for a character the source really has (a `\n`, folded to a space).
Everything else stands for syntax the render drops.

Nothing real is lost: a match spanning `a**b**c` would have to contain the
asterisks to exist at all, and a match wholly inside an element is still found —
the element simply starts its own run.

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

* fix(search): match a Note body as it renders, instead of rewriting the file

Replaces the serializer change with one that writes nothing.

The editor backslash-escapes every markdown-significant character in prose, so a
Note the reader sees as `{{TE_SERET}}` is stored as `{{TE\_SERET}}` and search —
which matches the stored value — could not find it. The previous approach undid
that escape in `postProcessSerializedMarkdown`, which meant re-deriving markdown
structure from the serialized string with regexes so it knew what was code. That
is a losing game: three review rounds, each finding another construct it did not
model (longer fences, then delimiter-prefixed lines, then quoted fences), and
each miss REWROTE somebody's code. `markdown-fidelity.ts` is back to staging,
byte for byte.

The escape is now undone on the matching side only. A field declares
`searchTextFormat: 'markdown'` (the Note body is the only one), and the indexer
matches it against `projectEscapedMarkdownForSearch(value)` — a total,
structure-free function that returns the rendered text plus an index back into
the source. Ranges stay in source coordinates, so replace still rewrites the
whole `\_` and never strands a backslash.

The asymmetry is the whole point: a matcher that de-escapes something a fence
would have kept literal changes only which text highlights, and no caller writes
it back. A rewriter making the identical mistake corrupts the file. So there is
nothing here that needs to know about fences at all.

Two consequences worth having: existing notes are searchable immediately rather
than after their next edit, and no stored byte changes, so no document the
editor has ever written can be affected.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 15:29:25 -07:00
Waleed a27f376164 feat(connectors): add Bitbucket, Databricks, Google Chat, and Workday Help KB connectors (#6895)
* feat(connectors): add Bitbucket, Databricks, Google Chat, and Workday Help KB connectors

Adds four knowledge base connectors, closing the gap where Sim shipped tool
blocks for these services but could not index their content.

- Bitbucket: repository source files and pull request descriptions over the
  existing Bitbucket OAuth credential
- Databricks: notebooks (Workspace API) and saved SQL queries, PAT auth
- Google Chat: spaces indexed as message transcripts, new google-chat OAuth
  service under the shared Google client
- Workday Help: knowledge article versions via the public helpArticle/v1 API

* fix(connectors): second-pass validation fixes and test coverage

Adversarial re-validation of all four connectors plus a combined-change
regression audit.

- bitbucket: stop declaring incremental sync (deletion reconciliation is
  disabled for incremental runs, so deleted files were never removed on the
  default code configuration); drop a wasted listing round-trip after the
  frontier drains; add 33 tests
- databricks: reject an explicit maxDocuments of 0, which meant unlimited;
  add 34 tests
- google-chat: correct the sender displayName documentation (user auth
  populates only name and type) and emit second-precision RFC-3339 in the
  message filter; 15 -> 24 tests
- workday: fix a crash when maxVersions is persisted as a number; refuse a
  configuration whose status filter Workday did not honor; cap the
  unresolved-name error; 18 -> 27 tests
- document the Google Chat service-account omission
- docs: list all four connectors and correct the connector count

* fix(connectors): index Google Chat spaces with no messages in the window

Review round 1.

- orderBy takes a full ordering expression, not a bare direction. The reference
  documents the default as `createTime ASC`, so send `createTime DESC`; a bare
  `DESC` either 400s every hydration or is ignored, which would make the cap keep
  the oldest traffic and the later reverse render the transcript backwards.
- getDocument no longer returns null when the message window is empty. A space
  with no messages is still a live space, and null is the "document is gone"
  signal the engine treats as last-known-good: returning it dropped spaces whose
  only prose is their description or guidelines, and left a stale transcript
  indexed after a space was cleared or lookbackDays was tightened past every
  message.
- The transcript header is omitted when no message contributed text.

* fix(connectors): only flag a Bitbucket listing capped when the cap withheld something

Review round 2.

takeIndexableWithinCap reports capReached as soon as the running total equals
maxItems, which is also true of a listing that ended at exactly that count.
Setting listingCapped there suppressed deletion reconciliation for a complete
listing, so upstream-deleted files and pull requests could stay in the knowledge
base indefinitely. applyMaxItemsCap now takes whether Bitbucket had more content
beyond the page -- a next link, or directories still queued on the frontier --
and flags the listing only when the cap actually withheld something, matching the
Databricks, Google Chat, and Workday connectors.

* fix(connectors): keep the Bitbucket cap flag set when it skips the pull request phase

Review round 3. Fixes a regression from 622a21bd9a.

maxItems is shared across the code and pull request phases, so a code walk that
ends with exactly maxItems documents and no next link or frontier stops
pagination before the pull request phase runs. Scoping listingCapped to "this
phase had more" left the flag unset in that case, and the engine then treated the
run as a complete enumeration and could hard-delete previously indexed pr:*
documents that were never listed.

The cap flag now asks whether anything the connector was configured to list
remains unlisted -- including a later phase the cap is about to stop us reaching.
2026-08-20 14:46:29 -07:00
Waleedandmini.jeong d374ebce6f fix(webhooks): accept the methods and expose the request metadata the generic webhook advertises (#6893)
* feat(webhooks): support query parameters and GET deliveries on generic webhooks

The generic webhook Setup Instructions promised that query parameters would be
available in the workflow and that any HTTP method would be accepted, but
neither was true: query parameters were never carried past the route, and every
GET that was not a provider challenge got a 405.

Carry the request query string into the execution payload and expose it to
providers through FormatInputContext. The generic provider merges it into the
workflow input under a reserved `query` key, leaving the body's own fields
untouched so existing payloads resolve exactly as before.

Add an opt-in `acceptsGetDelivery` provider capability and enable it for the
generic provider, so a workflow can be triggered by a plain URL fetch such as a
link in an email. Providers that have not opted in still answer 405, and unknown
paths keep answering 405 on GET so probes cannot distinguish them.

Update the Setup Instructions to describe what the endpoint actually accepts.

Signed-off-by: mini.jeong <mini.jeong@navercorp.com>

* feat(webhooks): expose generic webhook request headers

The generic webhook's Setup Instructions promised that request headers would be
available in the workflow, but formatInput returned only the body: headers were
used solely for the idempotency key and provider signature checks.

Expose them under a reserved `headers` key, withholding the ones that carry
credentials. Exposing a credential would copy it into execution logs and trace
spans, where it outlives the request, so a fixed denylist (authorization,
cookie, x-api-key, ...) is combined with the webhook's own configured
secretHeaderName. A denylist rather than an allowlist keeps arbitrary custom
headers usable, which is the point of the feature.

Generalize the query-parameter merge so query and headers share the same
key-wise body-precedence rule.

Also correct the authentication instruction: only the configured method is
accepted, not either one.

Refs #6888

Signed-off-by: mini.jeong <mini.jeong@navercorp.com>

* feat(webhooks): accept PUT, PATCH and DELETE deliveries and expose the request method

The generic webhook's Setup Instructions promised any HTTP method, and the /api
CORS policy already advertises PUT, PATCH and DELETE, yet the route answered 405
for everything except POST and GET. Open the remaining methods for providers that
opt in, which today is only the generic webhook.

Expose the method on the trigger input as well. Without it a workflow behind one
URL cannot tell a create from a delete, which makes multi-method delivery half a
feature. The payload field is optional so jobs already queued at deploy time keep
executing.

Turn the GET-only opt-in into a per-provider method set, and let the request
metadata merge carry scalar values so `method` follows the same key-wise
body-precedence rule as query and headers.

Refs #6888

Signed-off-by: mini.jeong <mini.jeong@navercorp.com>

* feat(webhooks): declare the generic webhook trigger outputs

The trigger declared no outputs, so the reference dropdown in the editor offered
no completions for it and users had to type paths like `query.id` by hand after
reading the setup instructions. Declare the request metadata that is known ahead
of time. Body fields stay undeclared because a generic webhook receives whatever
JSON the caller sends.

Refs #6888

Signed-off-by: mini.jeong <mini.jeong@navercorp.com>

* fix(webhooks): stop provider challenges from intercepting other providers' deliveries

The challenge handlers run before webhook lookup and are provider-blind, so two
query parameter names are effectively reserved across every path. Now that a
generic webhook can be triggered by a URL fetch, a link carrying either name
answers the challenge instead of running the workflow:

- `?validationToken=x` is echoed back as a Microsoft Graph subscription
  validation. Graph sends that validation as a POST, so ignore the parameter on
  every other method.
- `hub.mode`, `hub.verify_token` and `hub.challenge` answer 403 when no WhatsApp
  webhook on the path expects a token. A path with no such webhook is not a
  failed verification - the parameters belong to whoever owns that path - so fall
  through and let the delivery route normally. A token mismatch against a
  WhatsApp webhook still fails with 403.

Refs #6888

Signed-off-by: mini.jeong <mini.jeong@navercorp.com>

* fix(webhooks): make the request metadata opt-in per webhook

The four commits below make the generic webhook do what its Setup Instructions
promise. They do it through a provider-level capability, which applies to every
generic webhook row the moment it deploys: each one begins accepting GET, PUT,
PATCH and DELETE, and each one's workflow input gains `method` and `headers`,
on POST deliveries too. No webhook owner chose either.

Gate both behind `providerConfig` flags written by two switches, off by default.
A webhook deployed before these existed has neither flag, so it answers POST
only and its input is exactly the body, as before. `query` stays ungated: it is
dropped today, only appears when the caller's own URL carries it, and yields to
a body field of the same name.

Generalize the Microsoft Teams challenge fix. Every challenge handler runs
before the webhook lookup and matches on payload shape alone, so any of them
will answer a delivery addressed to another provider on the same path. Gate
them centrally to POST via `challengeMethods`, which WhatsApp widens to GET for
Meta's handshake, rather than guarding one handler inline.

Also:

- Widen the credential header denylist to 24 names and withhold the webhook's
  own token by value as well as by name, since a denylist is leaky by
  construction.
- Condition the `method` and `headers` trigger outputs on their switches, so
  the reference dropdown cannot offer a field the webhook will not send.
- Give PUT, PATCH and DELETE their own contracts instead of reusing the POST
  one, whose `method: 'POST'` had become untrue.
- Parse, challenge and generate a request ID once per delivery rather than
  twice on GET, which was logging one request under two IDs.
- Offer the challenge handlers the request before admission, so Meta's GET
  handshake cannot be answered with a 429 by a busy instance.
- Answer every non-POST rejection with the same 405 plus `Allow`, whether the
  path is unknown, holds only non-path triggers, or holds a trigger that has
  not opted in.
- Read flags through a helper treating only `true`/`'true'` as on: the editor
  writes booleans, but a YAML- or Copilot-authored workflow can write the
  string `'false'`, which is truthy.
- Name the methods switch "Accept Other HTTP Methods": HEAD and OPTIONS still
  answer 405, so claiming "all" would reintroduce the overstatement this whole
  change set exists to remove.
- Drop the per-delivery metadata warn logs to debug.

---------

Signed-off-by: mini.jeong <mini.jeong@navercorp.com>
Co-authored-by: mini.jeong <mini.jeong@navercorp.com>
2026-08-20 14:38:08 -07:00
d9cfd7c68e improvement(mothership): v0.9 (#6815)
* checkpoint

* Checkpoint

* dot fixes

* Make async tool resume delivery recoverable

* Support split table tools and option recovery

* Harden VFS mutation handling

* feat(platform): platform subagent support — docs corpus VFS, search_docs, account context

Squash of the feat/platform-agent branch (sim side): mounts the Sim docs
corpus in the copilot VFS, wires search_docs and retires the legacy docs
search tools, and syncs the generated tool catalog and trace contracts for
the platform subagent.

* Align Copilot tools and resource handling

* Expand workflow log query support

* checkpoint

* Port desktop-improvements-0 desktop and browser-agent work

* fix(desktop): keep browser-agent input alive through live-SPA re-renders

* Harden workflow sanitization and Slack setup

* feat(desktop): coordinate clicks, caret insertion, and drag for the browser agent

* Revert subagent group eager auto-collapse

* fix(chat): keep sends FIFO across the streaming-to-idle drain gap

* Add the steering backend surface for mid-turn sends

* Sync generated contracts for async subagent orchestration

Pulls the mothership tool catalog (wait_agents / tail_agent / steer_agent /
interrupt_agent), trace spans (chat.async_subagent.*, chat.orchestrate.*), and
trace attributes (copilot.async_subagent.*) into the generated TS contracts.

* Add display titles for the async subagent orchestration tools

wait_agents / tail_agent / steer_agent / interrupt_agent get natural-language
running titles (naming the agent id being waited on, tailed, steered, or
stopped) and a Steering→Steered completed-verb rewrite.

* Show orchestrator-chosen subagent names on agent groups

A subagent_start whose payload data carries a name (the orchestrator's new
name trigger parameter) now labels the agent group with that mission name —
the agent-type icon stays. The name flows through the live stream path, the
turn model (AgentNode.displayName) and its serialize/rebuild round-trip, and
persisted transcripts (PersistedContentBlock.name), so reloads keep the label.

* Improve Copilot error handling and logging

* Backfill the subagent display name from the second start event

The dispatch-time subagent_start fires before the trigger args (and therefore
the name parameter) have streamed; the phase-3 start re-announces the lane with
the name. The block builder was dropping that duplicate wholesale, losing the
name on streaming providers — now it backfills subagentName onto the existing
block instead. (The home turn-model path already reconciled this case.)

* Support Slack bot connection flow

* Harden Copilot error and VFS handling

* Harden VFS resource operations

* Show 'Waiting for the first of N agents' for mode-any waits

The wait_agents title ignored the mode argument, so an any-mode wait over
three agents read 'Waiting for 3 agents' while the model narrated waiting for
the first — contradicting the transcript.

* Collapsed-by-default agent cards with live intent status lines

Subagents now narrate their work through <intent>3-5 words</intent> tags (a
fleet-wide prompt protocol on the mothership side). The turn model streams
each subagent's text through a split-safe tag parser: complete tags update the
agent's currentIntent and disappear from the prose, tags split across deltas
are carried until their close arrives, and a tag that never closes flushes
back as plain text.

The agent card renders as one line — display name (or agent label) plus the
latest intent, replaced inline as the agent shifts gears — and never
auto-expands; expanding to the full tool log is a deliberate click. Only an
outstanding permission prompt or a browser hand-back forces a group open.
Intents persist on the subagent block (and through the legacy persisted-
message paths) so reloads keep the last status, and a renamed reinvocation
now takes the latest name instead of pinning the first.

* Add the internal in-band tool execution route for live mothership turns

POST /api/copilot/tools/execute (INTERNAL_API_SECRET, Go→Sim) runs one
sim-server tool through the same server tool router the resume driver uses
and returns the result synchronously — no checkpoint. This is what lets
background (async) subagents write files/tables/knowledge, and lets the main
lane keep streaming (instead of checkpoint-pausing and killing every
background run) while async agents are live.

* Persist resource side effects for in-band tool execution

Files/tables created through the internal execute route now register on the
chat's resources exactly like the resume driver's executions — the route runs
the same handleResourceSideEffects pass (persistence only; an out-of-band
route has no live event sink, so mid-turn chip pushes are a follow-up).

* Extract intents from group text on every path, sync and async

The turn-model intent filter only fires for span-scoped subagent lanes, but
this surface also delivers subagent text through the legacy block path — so
<intent> tags flowed through unparsed and rendered as prose rows. Groups now
extract intents from their accumulated text at append time: the last complete
tag becomes the card's status line and every complete tag is stripped from
the rendered prose. Covers span-scoped, legacy, and persisted-reload paths
for both synchronous and background delegations.

* Fall back to the live tool title for the agent card status line

Persisted data proved tool-first subagents (grok search agents) emit zero
prose, so intent tags never stream no matter what the prompt says. The
collapsed card now always narrates: the agent's own <intent> tag when present,
else the latest tool's display title while the lane is live.

* Catch subagent <intent> tags in the server relay

The relay's subagent text handler now runs the split-safe intent extraction
as chunks stream: the latest complete tag is stamped onto the lane's persisted
subagent block (subagentIntent) and stripped from the stored prose, so live,
persisted, and replayed views all agree. Per-lane carry handles tags split
across chunks; a never-closing tag flushes back as plain text.

* Drop the tool-title fallback: the status line is the agent's intent

With the intent protocol now injected into every spawn's task message, agents
open with an <intent> tag; the card shows that narration or nothing.

* Replace intents with live tool-title status lines on agent cards

Intent parsing is fully removed (turn model, relay handler, persistence
fields, group extraction). The collapsed card's status is the latest tool
call in its RUNNING phrasing — never the completed rewrite, which stays in
the expanded log. Parallel tools show the most recently started still-running
title with a +N for concurrent siblings; between rounds the last title stays
frozen; a closed lane shows the bare name. Nested agent cards compute their
own status recursively from their own items.

* Keep the main Sim lane live-expanded; collapse only real subagent cards

The mothership group is the turn's own narration, not a delegation card —
collapsing it hid main-lane text and tools until manual expand, which read as
mis-ordered streaming while async subagents interleaved. It keeps the
original live-expand behavior and no status suffix.

* Persist subagent lane lifecycle blocks from the span handler

Lane-scoped span events route to the span handler, which only recorded trace
side effects — no subagent start block was ever persisted (verified: a
seven-agent run stored 104 blocks with zero starts). Grouping then fell back
to keying lane content by agent NAME, so a respawned agent of the same type
merged invisibly into the first one's card until it resolved. The handler now
persists the start block (spanId-keyed and deduped, carrying the display
name) and stamps endedAt on close, giving every invocation its own card.

* Name agents in orchestration titles; '+ n more' overflow format

wait/tail/steer/interrupt titles humanize the slugified agent ids back to
their display names ('Waiting for the first of Digest Workflow Build + 4
more'), and the agent card's parallel-tool suffix uses the same '+ n more'
format.

* Harden in-band tool execution and resources

* Route in-band execution through the comprehensive tool dispatcher

The internal execute route used the bare server-tool router, which rejects
VFS tools with 'Unknown server tool: read/glob/grep' — so nearly every
background agent's first discovery call failed (102 in-band calls in one run,
dozens rejected). It now uses the relay's executeTool dispatcher: registered
handlers (VFS, function execute) with permission checks and param
normalization, falling back to the app tool router — the same surface
foreground execution gets.

* Harden chat stream transition handling

* Harden VFS provenance and resource writes

* Standardize tool environment references

* Harden browser panel and chat cleanup

* Descriptive, user-language tool titles across the board

House rules applied everywhere: use every argument the call carries, never
name internal machinery, and never lead with Getting (the Got rewrite is
deleted so it cannot return).

- Deployments name the workflow: Deploying {workflow} as API/chat app/MCP tool
- Workflow reads name the part: Reading {workflow} meta/state/deployment/notes;
  generic reads always name the file (Reading {leaf}), never bare Reading file
- Block runs name block and workflow: Running {block} in {workflow}, Running
  from {block} in {workflow}, Running {workflow} until {block}, and
  Enabling/Disabling {block} in {workflow}
- The six split-table tools get per-operation verbs (Adding column {name},
  Updating rows, Wiring automation, Creating view {name}) instead of a wall
  of Querying table
- The manage quartet drops X-action system-speak for gerunds
- get_* internal names become user language (Checking run settings, Tracing
  block inputs, Reading the deployed version); web_fetch says Fetching
- Scheduled-task titles removed entirely (feature deleted from the Go catalog)
- New verb rewrites: Fetched, Traced, Wired, Configured, Looked, Rotated

* Deploying {workflow} as chat, not as chat app

* Loader gerunds; mv names both ends; mkdir names the folder

search_integration_tools -> Finding the right integration;
load_integration_tool -> Loading {integration} tools; load_skill ->
Loading skill {name}; run_enrichment -> Looking up {subject}. mv prefers the
model's phrasing, else reads 'Moving {files} to {destination}'; mkdir reads
'Creating folder {name}' from the path.

* Overflow counts read '+ n', dropping 'more'

* Unify workspace find and search

* Scale desktop title bar with page zoom

* Serialize account and organization truth into the copilot VFS

Workspace standing, membership, billing, org role, access-control
restrictions, published-block provenance, and fork topology were reachable
only through three parameterless tools (or not at all). They are ambient
read-only facts, so they belong in the VFS where they are greppable, cost no
tool round-trip, and every agent that can read gets them — the same move that
retired get_blocks_and_tools and list_user_workflows.

Adds account/{workspace,workspaces,members,billing}.json (always mounted) and
organization/{organization,access-control,custom-blocks,forks}.json (only when
the workspace is org-hosted). Every file projects an existing use case or util
after getOrMaterializeVFS's access assert — no new queries, no new
authorization. One relation per file, cross-referenced by id-and-name stub, so
overlapping facts cannot disagree. Volatile content (billing, access control,
forks) is lazy, so numbers are read-time fresh and unasked-for reads cost
nothing.

Projection follows the viewer: member emails are admin-only, fork detail
requires workspace admin on a forking-enabled org, and the whole organization/
namespace is absent for a personal workspace — which is itself the answer.

Retires get_account_billing, get_enterprise_context, and list_user_workspaces
along with their handlers; display titles stay for transcript replay.

* Fix insert_text refusing an editable field focused inside a frame

describeFocusedEditable descended shadow roots but not frames, while
activeElementReadback descends both. Focus inside a same-origin frame therefore
surfaced to the first as the FRAME element — not an input, not contentEditable,
not a canvas, no textbox role — so it fell through to 'not-editable' and
insert_text refused a field that press_key had just typed a character into.

Two functions answering 'what is focused' with different answers is the bug;
the descent loops now match exactly.

The refusal also names what actually held focus (tag, role, contenteditable).
A bare 'not-editable' gave the agent nothing to act on, so it guessed at the
cause — a real run spent twenty rounds on the wrong theory and had to be
stopped by the user.

* Keep retired browser takeover renderable in history

The tool is gone from the catalog, so its generated constant went with it and
every path that referenced it stopped compiling. Deleting those paths instead
would have silently downgraded every past transcript containing a takeover card
to a generic tool row, and dropped the no-timeout budget that an in-flight
takeover still needs while a rolling deploy finishes.

retired-tools.ts gives the literal a documented home that says what it is and
why it survives its tool.

* Follow the agent into a tab it opened to work in

browser_open_tab created the page with activate: false, so the agent worked in
a tab the user could not see while the panel sat on a page where nothing was
happening. The panel now follows a tab the agent deliberately opened.

Scoped to that tool only. A page spawning its own tab (popup, target=_blank) is
the site grabbing the view rather than the agent choosing a workspace, and
stays in the background as before — two existing tests pin that and caught the
first version of this change, which moved both.

A tab the user claimed still wins over both: the work starts in the background
instead of pulling the page out from under them mid-read.

* Make the browser tools agree with each other

An audit of the module found the frame-descent bug was one instance of a
pattern: six independent definitions of 'is this editable' and seven of 'what
is focused', disagreeing with each other. A tool refusing what its sibling
accepts on identical page state is invisible at runtime — the agent follows a
snapshot that says one thing into a tool that says another.

- browser_type now accepts role="textbox" like browser_insert_text does. The
  snapshot advertises those elements as [textbox] with a ref, so refusing them
  meant rejecting exactly what the outline told the model to type into. Both
  the native and synthetic paths, and their descendant scans.
- pressKeyOnPage descends shadow roots and frames like every other focus
  reader. It was dispatching synthetic keys at the shadow host or <iframe>
  element, where they bubble but never reach the editor, while reporting
  success — and contradicting the activeElement reported beside it.
- not-editable and ambiguous-editable name what was found: the element's tag
  and role, and the candidate fields. Both had the data and discarded it, which
  is what turns one blocked step into twenty rounds of guessing.
- obstructedAfterNavigation requires a dialog that ARRIVED with the
  navigation. It compared against nothing, so every SPA route change under a
  persistent role=dialog reported a successful click as obstructed. The test
  that covered this asserted the false positive; it now pins both directions.
- browser_insert_text observes the top document when typing inside a frame,
  like every other input tool. A submit that navigates the top page was
  invisible to its frame-scoped observation.

* Let hover actually see what it mounted

Four independent defects made browser_hover blind to the most common thing a
hover produces — a row's action bar — so it reported no effect on a hover that
worked, and the agent fell back to clicking pixels off screenshots.

- The popup scan matched only role=tooltip/menu/listbox. Slack's message
  shortcuts bar is a labelled toolbar/group, so it registered as nothing at
  all. Added toolbar, menubar, labelled group, and [popover].
- The baseline was captured BEFORE prepareElementSurface scrolled the target
  into view, so scrollChanged was always set by the tool's own probe. That
  pinned every unproductive hover to 'background DOM churn' instead of the
  honest 'nothing happened', and hid scrolling the hover really caused.
  Re-baselined once the scroll settles and before the pointer moves.
- The MutationObserver attached only on the first observation, while the roots
  list is rebuilt every call and grows as shadow roots mount. Components that
  appeared later were never observed, so their DOM changes raised no revision.
  Roots are now observed as they show up.
- observationTruncated was computed and never read, so a scan capped at 12k
  nodes reported 'nothing appeared' with the same confidence as a complete
  one — and portalled overlays live at the end of <body>, exactly what the cap
  drops. Hover now says the page was too large to scan and to confirm visually.

* Stop the browser agent acting on the wrong element, and say why it refused

Four findings from the module audit, the first of which could silently do the
wrong thing rather than merely fail.

- A ref whose node is gone is re-adopted by structural resemblance, matching on
  ORIGIN only so a pushState between snapshot and act does not kill every ref.
  That leniency also let a ref to a row control in one view rebind to the
  identical control in a view the app had since navigated to — acting on the
  wrong message, signalled by nothing louder than recovered: true. Adoption now
  requires the same path; a view swap reports the ref stale, and the caller
  re-snapshots. Revalidating a still-connected node stays lenient, because that
  is literally the node the model chose.

- A hit INSIDE the requested element is its own nested control, not an overlay.
  Both produced 'covered by X — close or move the overlay', advice that cannot
  be followed because there is nothing to close. Nested hits now say so and
  point at retargeting.

- browser_click_at, browser_insert_text, and browser_drag listed targetChanged
  in their effect formulas, but none passes an elementId, so no targetState is
  ever captured and the term was always false — coverage that read as real.
  Removed, with a test pinning the dependency.

- The seven effect formulas are deliberately NOT collapsed into one predicate:
  drag must trust domChanged where others must not, hover must ignore field and
  focus changes, click counts focus only for editables. Forcing one would make
  each tool wrong differently. The differences are now documented in one place
  next to the shared computation, so divergence is a declared policy rather
  than an accident.

* Let edit_workflow configure block retries

* Updates

* Always focus the resource the agent is working on, and its browser tab

The resource panel had a carve-out: an already-open browser session declined
to replace another selection and only got an attention marker, so agent
browser work happened off-screen. The panel now follows the agent to whatever
it touches — browser included — and an event can still opt out explicitly.

The browser panel also follows the agent BETWEEN tabs: the store already
tracked automationTabId (and the strip marked it), but the visible tab never
changed. It now switches when the agent's target tab changes, so watching the
agent never means hunting for the tab it moved to. Keyed on the target
changing rather than on it being set, so a user who browses elsewhere
mid-run is only pulled along when the agent itself moves.

* Never paint a browser snapshot at stale geometry (the modal-open flash)

Opening a modal locks scroll, which removes the window scrollbar and reflows
the panel — so a capture taken before the lock describes a rect the panel no
longer occupies. The handshake painted that frame anyway and only then
retried, so the replacement landed visibly offset from the page it stands in
for: the flash. A capture is now checked against the host's live rect before
it is painted; a mismatched frame is skipped and re-captured at the settled
layout instead (modal retries go 2 -> 3 to absorb the extra settle).

* Name the workflow in deployment and workflow-scoped tool titles

'Checked deployment status' never said which workflow — nor did the deployed-
state read, run settings, block outputs/inputs, redeploy, promote, or the
global-variable write. These tools carry a workflowId (often defaulting to the
current workflow), so only the client can resolve a name: the enrichment layer
now resolves it for the whole workflow-scoped family and passes it as
workflowName, which every workflow title already reads.

Titles: Checking {workflow} deployment status, Reading deployed {workflow},
Checking {workflow} run settings, Reading {workflow} block outputs, Tracing
{workflow} block inputs, Redeploying {workflow}, Promoting {workflow} version
{n} to live, and 'Adding workflow variable {name} in {workflow}' — each
falling back to its unnamed form when no workflow resolves.

* Name the block that ran; never fall back to a raw block id

run_block and set_block_enabled carry only a blockId, so their titles would
have printed an opaque UUID ('Running 7f3a2b91-… in Invoice Sync'). The
enrichment layer now resolves blockId against the workflow store the same way
it already did for run_from_block's startBlockId, and the base titles no
longer accept an id as a name — an unresolved block reads 'Running block'
rather than a UUID.

* Add the missing Removing -> Removed rewrite

The table work introduced 'Removing automation'/'Removing enrichment' with no
past form, so those rows kept their present tense after completing.

* Name the target resource in the remaining tool titles

Table tools keep their operands nested under args and identify the table by
id, so their rows said 'Adding rows' with no hint where: enrichment now lifts
the nested args and resolves tableId against the cached workspace table list,
giving 'Adding rows to Runtimes', 'Adding column status in Runtimes',
'Reading views of Runtimes'.

Also: a block-schema read names the block instead of the file ('Loading
Slack', 'Loading Google Sheets tips'); browser type/insert show the text they
send, middle-ellipsized; downloads name the file; library-docs searches name
the library and query; knowledge-base searches include the query; generated
media names its output file; and diff_workflows, list_deployment_versions,
and publish_custom_block joined the workflow-name enrichment set.

* Bubble nested agents' tool calls into the parent's status line

The collapsed status only scanned a group's OWN tool items and skipped nested
agent groups, so a parent that had delegated froze on its last own tool while
its child did the actual work — the line described nothing that was running.
Status now walks the whole subtree: any tool at any depth counts, the most
recently started running one is shown, and the rest become the same '+ n'
overflow. With nothing running it falls back to the last tool at any depth,
so an idle parent still reflects where its subtree got to.

* Align nested tool call status rows

* Revert branch-local KB connector error-message edits

Restores apps/sim/connectors/ to staging state. Two copilot-focused commits
on this branch (3a9fc1c5a5, 24b24e5f8e) drove by nine KB source connectors
(airtable, confluence, discord, github, gitlab, google-drive,
microsoft-teams, notion, slack) to enrich credential-validation error
messages. The env-reference resolution machinery those errors supported is
kept; the product connector surface stays unchanged in this branch so the
staging promotion remains scoped to copilot work.

* Fix the failing audits: NUL escape and route-count ratchet

check:source-text: resource-vfs.ts used a raw NUL byte as its folder-index
key separator (comment and template literal), which makes git treat the file
as binary and hide it from review. Written as the '\u0000' escape — the
runtime string is identical.

check:api-validation:strict: baseline 1120 -> 1122 for the two routes added
on this branch since the last bump.

* Add Force Reload (Cmd+Shift+R) to desktop View menu

* Route Force Reload through focused-resource boundary; regen docs manifest

* Align the title-bar surface audit with the zoom rework

'Scale desktop title bar with page zoom' (833d2f126d) changed the CSS contract
in two ways its audit test still pinned the old shape of: the lane vars gained
a max() floor around the platform env() terms so page zoom cannot shrink the
lane below the OS-drawn lights, and the control square became fixed px — CSS px
already scale under zoom — leaving only the centering offset derived from the
lane height. The test required the bare env() prefix and calc() on all three
control vars, so it failed the commit that implemented its own regression
comment.

Pins now assert the env() term inside the clamp (still platform-derived, the
test's actual intent) and split the control vars: offset must stay computed,
size and icon are explicit constants.

* Budget the two structurally slow tests explicitly

sso-trust imports the whole Better Auth module graph (2.5s on an idle
machine) and events.attribution scans call sites across the repo. Under a
fully-parallel uncached run on a loaded machine both blow the default
timeout while passing in isolation and on CI — a verdict decided by machine
load, not by the code. 30s budgets make a loaded local run mean what it says.

* Carry the cross-service trace id in sim log lines

* Improve nested tool status presentation

* Refresh secret schemas and shared test mocks

* Open the deployed graph of org-published blocks, read-only, org-wide

A consuming workspace could see a published block's interface but never what
it does: the backing workflow lives in the publishing workspace, and other
workspaces are nameable, not readable. Publishing a block org-wide is the act
of sharing it, so the graph it executes is now readable from any org
workspace — the DEPLOYED graph, not the publishing workspace's live editor
state, so nothing in-progress leaks and what you read is what runs.

The namespace also adopts the root's index/detail split: custom-blocks.json
slims to names with a detail pointer, organization/custom-blocks/{type}.json
carries provenance plus the deployed graph (loaded lazily through the cached
loadDeployedWorkflowState; credential ids and env references inside it belong
to the publishing workspace and say so), and organization/README.md is the
namespace guide WORKSPACE.md is at the root — files, usage, the in-depth
block inventory, and forks.json documented only when actually mounted.

The block list moves to materialize time (same indexed query the components
pass already runs) because the README, the index, and the per-block key-view
entries all need it; only the graph stays lazy.

* Withhold the deployed graph from external collaborators

Workspace access and org membership are different grants: an external
collaborator can open the workspace and use the published block, so the names
index and the interface schema stay visible to them — but the deployed graph
is org implementation internals, and their detail files now simply do not
exist. isHostOrganizationMember is the viewer bit the host context already
resolves for exactly this distinction.

* Fill out the organization namespace: workspaces, permission groups, credential groups

Three more read-only files, all lazily loaded — the paths appear in the key
view so glob discovers them, but no query runs until a read — and all gated by
registration, so an unpermitted viewer's file simply does not exist:

- workspaces.json (org members): the org's full workspace map with the
  viewer's access flag and fork parentage — account/workspaces.json only ever
  showed what the viewer can reach. Inaccessible workspaces stay nameable,
  not readable.
- permission-groups.json (org admins): every group with member count,
  targeted workspaces, and the restrictions its config activates.
  access-control.json remains the per-viewer binding. The queries are lifted
  into lib/permission-groups/queries.ts because their only prior home was
  inline drizzle in the route handlers, which the VFS cannot import.
- credential-groups.json (entitlement-gated): per-option configuration
  readiness and enrollment progress — the two facts that decide whether a
  credential_group workflow will do anything at runtime. The note teaches the
  contract that bit the audit: an active group with zero completed
  enrollments yields an empty loop, not an error. Enrollee emails are
  workspace-admin-only, matching the settings page; counts come from the
  first enrollment page and say so when truncated.

The README documents each file only when mounted for this viewer.

* Stop reporting a click that navigated as a failed click

The field case: 'Begin Assessment' submits a form. The navigation tears the
origin document down while the press completes, so everything after dispatch —
the CDP call's own completion, the synthetic dispatch's return value, the
postcondition reads — fails against a destroyed context, and a maximally
successful click was reported 'Failed clicking element'. The agent's own
follow-up investigation in the transcript diagnosed exactly this.

navigationRescue detects it at the driver level, where the navigation epoch
and URL survive the renderer teardown: when the page provably navigated since
dispatch began, a dispatch-path failure becomes a success carrying
navigatedDuringDispatch and a note explaining why no postconditions exist.
Applied to all four click dispatch paths (unframed CDP, framed native,
synthetic in-page, click_at).

The soft path had the same blindness: with the after-state unreadable,
urlChanged computed false and a navigating click reported 'no observable
change'. navigatedByDriver now folds into navigated/effectObserved, which also
keeps the new-dialog obstruction check meaningful on real navigations.

* Re-hide the browser under a modal after main loses the occlusion lease

The punch-through: a modal opens, the native view hides behind a painted
snapshot, and the renderer records applied: true. Then one heartbeat commit is
skipped — renderer jank past the 2.5s bounds-lease TTL is enough — and main
expires the lease, resetting panelOccluded on its side. The next heartbeat
finds the modal marker still present and calls setDesired(true), but the
lease's dedupe sees applied === desired and sends nothing; the bounds commit
that follows lays out an unoccluded native view above the open modal, and no
later event ever re-hides it. The comment on this branch already claimed it
'reasserts the lease' — the dedupe made that claim false exactly when the
lease had been lost.

While the occlusion marker is present, each heartbeat now drops the applied
belief (assumeRevealed) before setDesired, so the reassert is a real, forced,
idempotent hide IPC — one per second while a modal covers the browser — and
any main-side lease loss self-heals within a heartbeat.

* Make a stale ref name which of its five causes fired

A field run burned five snapshot->click cycles on a STATIC landing page, every
one refused with the same sentence — 'the page changed since the last
snapshot' — and the agent reasonably concluded the page was regenerating its
DOM. It was not; the resolver was refusing, and the message could not say why.
Five distinct conditions produced that one string: an id missing from the
registry, a connected node whose identity drifted, the view-changed adoption
gate, no confident replacement, and a replacement tie.

The resolver now stamps the reason (with the drifted node's current identity,
or the from->to paths for the view gate) and every stale producer carries it
into the driver message. Same pattern as not-editable: a refusal that names
its cause costs one round; an opaque one costs a loop and a wrong theory in
the bug report.

* Pulse throttling off when the browser view is revealed, so it actually paints

The blank-page report: a navigation completes while the view is hidden, the
page 'finishes loading', and the panel shows white until the user re-navigates
by hand. invalidate() on reveal was already there but recomposites the LAST
frame — and the last frame is blank, because background throttling suspended
the rAF the page's SPA paints its first frame from. The reveal now pulses
throttling off (forcing the renderer to produce a real frame), invalidates,
and hands the policy back to the session a second later through
reassertTabThrottling, which preserves the automation-tab exemption.

* Let the agent browser join meetings and finish passkey sign-ins

Camera and microphone: 'media' joins the agent partition's allowlist, but
every grant is gated on the macOS grant first — asked via
systemPreferences.askForMediaAccess so the system prompt appears on first use,
and answered from getMediaAccessStatus on checks — so System Settings stays
the real authority and a page can never hold a grant the OS refused. Granting
site permission without the OS grant produced the misleading NotReadableError
Google Meet showed. Packaging gains the camera entitlement and usage string
(macOS kills the process on prompt without one) and the mic string now covers
meetings.

Passkeys: WebAuthn itself is Chromium-native and nothing in our handlers
blocks it — USB security keys need no permission at all. The hybrid transport
(passkey on a nearby phone via QR) rides Bluetooth, which signed builds
silently lacked: the bluetooth entitlement and usage string enable it.
iCloud-Keychain platform passkeys remain outside what an entitlement here can
grant — Apple restricts that to approved browsers.

* Compile and preview Sim-styled pages

* Wait for the batched prepare intent instead of instantly failing apply_file_edit

The model batches prepare_file_edit and apply_file_edit into one round and
the Go loop runs same-round tools concurrently, so apply could reach the
executor before its prepare staged the intent. The instant no-intent error
cost a model retry round and flashed 'Failed creating …' on the shared file
row before the retry succeeded. The apply handler now polls briefly (10s
cap) for the intent; a truly missing prepare still errors at the deadline.

* Store page source, render docs-styled documents on view

The pdf model for agent pages: the .html file keeps the markdown-shaped
source (frontmatter + prose + sim: fences) and every surface renders the
docs-styled document on demand — preview panel, /api/files/serve, public
shares, and downloads all call the same pure compiler, now shared in
lib/workspace-files. The docs chrome is reproduced from the real fumadocs
source: the left sidebar's exact pill metrics, the clerk TOC with its
animated scroll indicator, divider-style tables; cards and stats left the
vocabulary. Table cells and kv values render inline markdown, and
sim:workflow/table/knowledge/file links resolve to real workspace routes,
bridged out of the sandboxed preview to the app router. Hand-written
imitations of rendered output are rejected at apply_file_edit with a steer
back to source, and a streaming page hides its source behind the live
rendered preview (batched ~2s) the way a generating pdf hides its script.

* Stagger the page rails so the resource panel keeps the docs sidebar

Rails were gated at 1100px of iframe width — the chat resource panel never
reaches that, so pages rendered single-column there. The rails now stagger
the way the docs do on a laptop: >=640px keeps the section sidebar (240px)
beside the content, >=1060px restores the full three-column frame with the
clerk TOC, and only a truly narrow pane collapses to one column.

* Fix in-page anchors escaping the preview; add the docs' toggle, code frames, pagination, and images

Clicking a TOC or section link (or pressing Enter in the section filter,
which clicks one) navigated the sandboxed frame off about:srcdoc in
Electron and landed on a cookie-less sign-in page — the shell now
intercepts every '#' anchor and scrolls directly. The page chrome gains
the docs' exact theme toggle (emcn Sun/Moon, 30px rounded-lg, top right),
framed code blocks with language label and copy button, and footer
previous/next cards from prev/next frontmatter. Workspace images
(![alt](sim:file/<id>)) compile to /api/files/view and the preview host
inlines them as blob: URLs so the cookie-less frame can render them;
sim:accordion joins the vocabulary as the faq component with title keys.

* Size the section sidebar to its content so it appears at panel widths

The left rail was a fixed 240/300px column, so it only earned its place
once the pane was wide. fit-content caps at the docs width but shrinks to
the longest section title (150px floor for the pills), and the two-column
tier now starts at 560px instead of 640.

* Let the clerk TOC join at 860px by sizing it to its content

Same move as the section sidebar: the TOC column fits its longest link
(capped at the docs' 268px, 150px floor), so the full three-column frame
starts at 860px of pane width instead of 1060.

* Open external links from pages in a new tab

The preview bootstrap cancelled every non-anchor click, so an external
link (the Sim docs, a vendor page) did nothing. External http(s) links now
compile with target=_blank rel=noopener for the standalone and share
surfaces, and the sandboxed preview bridges the click to the host, which
window.opens a new tab — same channel the workspace deep links use.

* Lock Sim pages to the rendered view via an internal record type

The record's contentType is stamped text/x-sim-page when apply_file_edit
detects page source — the file stays .html to the user (serving and
downloads still emit text/html), but every surface now knows what the file
holds before content loads. The viewer forces the rendered view for these
files at every moment: the first streamed chunk (whose frontmatter is
still partial) no longer flashes raw source, the gaps between an agent's
tool calls no longer flip back to raw HTML, and both toggle surfaces (the
Files toolbar and the resource-panel tabs) stop offering a code view for
them. Mid-stream compiles run lenient — a fence still being written is
malformed by definition, so its skip-notice callout is suppressed until
the stream settles.

* Honor the model's declared page type; default copilot .html to a page

An explicit contentType on create_empty_file always wins (the skill now
declares text/x-sim-page for pages, text/html for bespoke raw pages);
with no declaration a copilot-created .html defaults to the page type.
The first apply_file_edit still re-confirms from the actual content, and
the category map knows the internal mime explicitly instead of falling
through to the extension.

* Default undeclared .html back to plain text/html

A file is a Sim page only when the model declares it at creation or the
first written content proves it — never by extension alone.

* Match the docs' PageFooter for page navigation

The invented bordered cards with Previous/Next labels are replaced by the
docs' actual footer: the destination name with a 14px emcn chevron on a
flex-1 hover pill (rounded-lg, px-3 py-3, --surface-active), next
right-justified, and a spacer holding the empty half — verified against
apps/docs/components/docs-layout/page-footer.tsx.

* Scroll the rails invisibly, like the docs

The sticky TOC box is overflow-y auto, and the clerk track's absolutely
positioned SVGs could tip it a few pixels into overflow — Chromium then
painted a full scrollbar beside the rail. Rails now hide their scrollbar
chrome entirely (scrollbar-width none + webkit display none), matching
how the docs scroll their sidebar and TOC.

* Send sim:file links to the Files page, like a markdown link

A workspace-file link in a page now navigates exactly as one tagged in a
.md does: an in-app SPA push to /workspace/{ws}/files/{id} (the Files
page with the file open). The fullscreen /view route stays reserved for
the standalone surface; image refs keep the /api/files/view byte route.

* Inline workspace images after the page compiles, not before

The blob substitution ran on the raw source, where the compiled
/api/files/view src it looks for does not exist yet — so the sandboxed
cookie-less frame fetched every image itself and got 401s (broken image
icons). The substitution now runs on the built document, covering
compiled pages, legacy stored-compiled pages, and bespoke HTML alike.

* Highlight the section you are AT in the left rail, not the last one visible

The rail's current-section pick walked every heading visible in the
viewport and kept the last h2 — so clicking a section landed correctly
but highlighted whichever later section peeked in from below. Current is
now the last h2 at or above the top reading line (matching the 72px
scroll-padding a clicked anchor settles at), falling back to the first
visible section when everything is below the line.

* Stop the TOC jittering sideways as the highlight moves

Active TOC links step from weight 430 to 470, and the rail is a
fit-content column — every active-section change re-measured the longest
link and shifted the rail a pixel or two side to side. Each link now
carries a hidden zero-height ghost of itself at the active weight, so
the column always occupies its bold width and the highlight moves
without the layout moving.

* Absolutize links and images in served page documents

A downloaded page must behave like a downloaded .md whose links are
absolute: clicking a workflow reference opens Sim in the browser at that
workflow. The standalone renderer (plain download, fullscreen viewer,
shares) now compiles sim: links and workspace image refs against
getBaseUrl(); in-app surfaces keep relative paths and SPA navigation.

* Drop the eyebrow; add the docs' top page controls

The docs have no eyebrow line, so compiled pages no longer render one
(old sources still parse; the field is ignored). The title row gains the
docs' top controls: Copy page (copies the page text) and prev/next
chevrons wired to the same neighbors as the footer cards, disabled-dim
when a side is missing.

* Read kv keys and table first columns as labels, the docs way

kv keys dropped the blanket monospace — they render as the docs' row
labels (500, primary, sans), with backticks in the source opting a
code-like key (a path, an env var) into the inline-code chip; keys now
run through inline markdown to make that work. Table body first columns
pick up the same label treatment the docs tables show.

* Platform font and emcn chrome for pages

Pages live inside the app, and the platform — emcn, every workspace
surface — renders the system stack, not the docs' Inter webfont; Inter
made pages read as foreign next to the app around them. The face is now
the platform stack with weights on the platform scale (400/500/600), the
Inter delivery machinery (page-font.ts, the preview data-URI fetch, the
public woff2) is gone, and the section filter wears emcn ChipInput's
exact chrome (30px rounded-lg, --surface-5 fill flipping to --surface-4
in dark, --border, 14px, no focus ring). The docs' geometry — layout,
spacing, rails, tables, code frames — is unchanged.

* Color-only active state in the TOC — width can never move again

Two attempts at reserving the bold width (a hidden ghost, then freezing
measured rail widths) each traded one artifact for another: the ghost
did not stop the fit-content column re-measuring, and the freeze made a
long link wrap to two lines when it gained weight. The root cause was
letting the active state change a width-affecting property at all: the
active TOC link now shifts color only (muted to primary — the clerk
thumb already carries the emphasis), the search input is a plain text
field (the native search clear button is not emcn chrome), and the
ghost/freeze machinery is gone.

* Drop the Copy page control; keep the top chevrons

The title-row actions keep only the previous/next chevrons (rendered
when the page has neighbors); the Copy page button is gone.

* Set-level sidebar: docs-style groups with the current page expanded

Multi-page sets can now carry the whole set's sidebar. nav frontmatter
(groups of labelled page links, identical on every page of the set)
compiles into hidden set-nav markup whose sim: links resolve like any
other; the shell lifts it into the left rail as muted group labels over
page links, recognises the current page by title, and nests that page's
section list beneath it — the docs sidebar's exact shape. Pages without
nav keep the plain section list.

* Center the content column at the docs' measure

On wide panes the 1fr center cell stretched, so content hugged the left
rail with dead space before the TOC. The main column now caps at the
docs' ~760px measure and centers in its cell, matching how the docs
balance a wide viewport.

* Docs Steps, code-tab groups, and API method chips

Three docs components join the vocabulary: sim:steps renders the
numbered timeline (muted circle markers, hairline connector, title and
content per step); sim:tabs renders the docs' grouped code block (mono
tab chips, one pane at a time, the icon copy control targeting the
visible pane); and a METHOD prefix on a set-nav page entry renders the
API-reference chip — sidebar entries only, on the platform badge tokens
(blue and purple added to the mirrors and the live bridge).

Also: preview images switched from blob: to data: URIs — blob URLs are
origin-bound and the sandboxed frame's origin is opaque, so Chromium
refused to render them — and the page view-lock is sticky per file so a
patch stream cannot flash raw source.

* Downloaded pages carry their images

Absolute URLs made LINKS survive a download, but an embedded image
request from a downloaded file is cross-site and carries no session
cookie, so images 401ed outside the app. The standalone renderer now
inlines every workspace image the page references as a data: URI at
serve time — like a pdf carrying its images — capped at 8MB per image,
restricted to the page's own workspace, falling back to the URL
reference on any miss. Applies to serve, download, and public shares.

* Dead-center the content column between equal gutters

The rails are content-sized and unequal, so the old grid (fit-content /
1fr / fit-content) skewed the middle cell toward whichever rail was
narrower. The wide tier now uses the docs' geometry: a fixed 760px
content column centered between two equal flexible gutters, the sidebar
hugging the container's left edge and the TOC its right — rail widths
can no longer move the content.

* Defer title-bar history state out of currententrychange dispatch

The Navigation API fires currententrychange synchronously from the
history mutation that caused it, which can originate inside another
component's useInsertionEffect (style libraries navigating during
commit) — setState there trips React's 'useInsertionEffect must not
schedule updates'. The arrow-state sync now defers to a microtask,
flushing after the commit unwinds, with a disposal guard.

* Show the section sidebar only when there are sections to list

Fewer than two sections (and no set nav): the left rail and its filter
disappear and the reserved left gutter collapses — the content column
leads the container with the TOC trailing. Two or more sections, or a
multi-page set, keep the full centered docs frame.

* Execute the page shell in a DOM harness

jsdom runs the real shell against compiled pages and asserts the layout
decisions: both rails on a many-section page, only the left rail dropped
on a one-section page.

* Medium panes keep the TOC, not the sidebar

Between 560 and 860px the frame showed the section sidebar and hid the
clerk TOC — backwards by our own reasoning, since the sidebar is the
redundant list on a single page. The TOC now survives at medium widths
and the sidebar joins only on wide panes.

* Sidebar is for doc sets only; stagger the rails like the docs

The left rail now exists only for multi-page sets — on a lone page it
just repeated the TOC. A set opens its sidebar at 560px with the TOC
joining on wide panes (the docs stagger); a lone page waits until 700px
and then shows the TOC alone. Set-sidebar spacing tightens to the docs
values, with the current page's nested sections styled as small muted
entries behind a hairline instead of full chips.

* Center the lone-page content and TOC as a pair

On a page with no sidebar the content column stretched while the TOC
hugged the far edge, leaving a field of dead space between them. The
content now caps at reading width with the TOC directly beside it and
the pair centered in the pane, and the TOC waits until 800px so narrow
panes stay single-column a while longer.

* Equalize grid-template specificity across the rail tiers

The 560px tier selects .art-cols:not(.no-side-nav) at (0,2,0), so the
860px tier's bare .art-cols template at (0,1,0) could never win and a
set page on a wide pane kept the 2-column template — wrapping the TOC
to the next grid row, bottom-left. The wide template now carries the
same :not() guard, the 860 block's duplicate of the 800px no-side-nav
rules is gone, and a test pins every template rule to equal specificity
so a future tier can't silently lose the cascade again.

* extract_doc_assets: pull a reference deck's assets into the workspace

Sim-side handler for the new file-agent tool: given an uploaded .pptx
or .docx, unzip it (OOXML is a zip), parse theme1.xml into theme.json
(color scheme as hex, major/minor fonts, slide size from
presentation.xml) and write every ppt|word/media file into a
"<Name> assets" folder with original bytes and real content types.
Re-runs overwrite the set in place. Pure extractor unit-tested against
in-test-built packages; display label "Extracting assets from <file>".

* extract_doc_assets learns .pdf via the doc sandbox

PDFs have no zip structure or declared theme, so extraction runs in the
same vetted sandbox that compiles and renders documents: poppler's
pdfimages dumps every embedded image in its native format (masks
filtered via -list), pdfplumber contributes each image's placement
rects in page points plus the document's font names, and rendered pages
are sampled into an explicitly-inferred color palette. theme.json for a
pdf carries fonts, page size and count, the inferred palette, and a
per-asset placement map.

* Pages have one navigation rail; compile errors go to the agent

The left sidebar leaves the renderer and the DSL: the shell builds only
the content column and the clerk TOC (pair centered at 800px, one bare
.art-cols selector per tier so the cascade cannot invert), the filter
box goes with it, and nav frontmatter is tolerated but no longer
rendered — sidebar METHOD chips and the set-nav markup are gone.

Malformed sim: blocks no longer render a reader-facing "block was
skipped" card: the block is omitted and the failure is reported as a
diagnostic that apply_file_edit appends to its result, so the authoring
agent sees exactly which fence to fix. The lenient flag existed only to
suppress those cards mid-stream and is removed.

The steps timeline connector now derives its position from the marker
size, so it stays centered under the number circles.

* Sync tool catalog: extract_doc_assets accepts pdf

* Asset extraction yields the rebuild recipe, not just the parts

pptx: theme.json now maps every image to its slide-by-slide placements
(slide rels resolve rIds to media names; each pic frame's EMU offset and
extent convert to inches) plus the slide count.

pdf: a second layout.json is written — per page, the text blocks with
content, position, font, size, and fill color; the filled rects
(backgrounds and scrims); and rect-over-image overlay detection with
coverage, which is the "image opacity" effect decks fake with a tinted
rect. Stream alpha is unrecoverable, so overlays name the color and the
rendered page remains the reference for strength.

* Split shared-baseline text runs into separate blocks

Two text boxes sitting at the same height merged into one wide line;
a gap much wider than a space now starts a new block, so columns and
label/value pairs land as distinct entries in layout.json.

* Extract faithful document layout recipes

* Add .chart files: live interactive ECharts docs, static or table-backed

* Size charts by width-driven aspect, not panel height; separate title and legend

* Map table chart rows from storage column ids to display names

* Inject table rows as datasetIndex 0 so specs can transform; stagger array legends

* Give .chart files their own bar-chart icon

* Chart table sources gain groupBy/aggregate/pivot shaping; renderer-owned chrome

* sim:chart page fence: ECharts SSR to themed inline SVG; shared option builder

* sim:chart hydrated embeds: inline or .chart file refs, live table reads per serve

* Finish extensionless pages and document staging

* Render live charts without server hydration

* Cover active-theme page token overrides

* Keep artifact tokens synced with the app theme

* Use tabs for multi-page Sim docs

* Preserve dollar-prefixed tool credentials

* Build chart specs from validated fields

* Sync new integration docs into Copilot manifest

* Add in-document tabs to Sim pages

* Rebuild page TOC on tab changes

* Keep page tabs with docs chrome

* Stabilize tabbed page layout

* Regenerate docs manifest for staging's Modal docs page

* Share one divider between the bar and the chrome tab row

* Pin the keyless OCR path in the unreadable-document test

---------

Co-authored-by: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2026-08-20 14:05:46 -07:00
Theodore Li 6f34dd6248 fix(credentials): hide gated service accounts from connected list (#6898) 2026-08-20 16:52:30 -04:00
Justin Blumencranz 43850e3567 improvement(tables): disable the default view's delete action with a tooltip (#6897) 2026-08-20 13:15:13 -07:00
Vikhyath MondretiandClaude Opus 5 97c1688c49 feat(modal): add Modal Labs integration (#6896)
* feat(modal): add Modal Labs integration

Modal has no public REST control plane — the Python/JS/Go SDKs all speak
gRPC — so this covers the two surfaces that are reachable over HTTP:
deployed Web Functions/Servers, and the OpenAI-compatible Endpoints API.

Three operations: call a deployed function with proxy-token auth, generate
a chat completion on an Endpoint, and list the models a token can reach.

Auth sends the token pair as Modal-Key/Modal-Secret rather than the
combined bearer form, so a Web Function that validates its own bearer
token keeps the Authorization header free. Both URL fields require https
since Modal terminates TLS everywhere, and a cleartext URL would leak the
token.

Chat completion declares request.modelInput so the system prompt and user
message project to canonical placeholders before egress. Call Function
deliberately does not — a Web Function runs arbitrary user code, and
nothing proves its body reaches a model.

/v1/models fields beyond `id` are inferred from OpenAI compatibility
rather than printed in Modal's docs, so they are marked optional.

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

* fix(modal): type the wire payloads and default chat to the shared endpoint

Chat Completion required an endpoint URL and passed a blank one straight
into modalOpenAiUrl, which throws — while List Models already fell back to
the shared inference host and the generate-on-modal-endpoint skill tells
agents to leave the field empty for Shared Endpoints. Skill-driven chat
calls against the shared host failed instead of using that default. Chat
now falls back the same way and the block field is no longer required.

Replaces every `any` in the Modal tools with declared wire types for the
OpenAI-compatible /v1 payloads. Fields stay optional because the shape
comes from whichever inference engine backs the endpoint, so the readers
keep their defensive `??` guards — the types exist so a future change to
that mapping fails the compiler instead of shipping.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 13:12:34 -07:00
3b4d9e9149 fix(schedule): reconcile interrupted schedule executions (#6780)
* fix schedule execution recovery

* simplify schedule recovery provider handling

* fix(schedules): stop reconciliation rewriting healthy carriers and index its scan

Follow-up hardening on the schedule recovery reconciliation.

Reconciliation settled every carrier it examined, including ones that had
already reached a terminal status and recorded their own outcome. Because the
normal completion path never stamps the reconciled marker, each successful
run's carrier was picked up on the next tick and rewritten: `completedAt`
bumped, `error` nulled and `output` replaced with a recovery stub, all of which
`GET /api/jobs/[jobId]` surfaces. A completed carrier whose execution log had
aged out was additionally flipped to failed. Settle only carriers still in
flight; a terminal one is owed schedule accounting and the marker, nothing more.

The recovery scan matched no index. There was none on `async_jobs.updated_at`,
and the unreconciled-terminal branch tested a jsonb extraction, so the whole
OR fell back to a sequential scan and sort of `async_jobs` on every tick. Add
the partial index, and spell the branch's status list and metadata key as SQL
literals: Postgres cannot prove a parameterised predicate implies a literal
index predicate, so a bound key would have left the new index unused.

Irrecoverable carrier tombstones were exempted from retention with no secondary
expiry, so the one class of row that can never reconcile grew without bound.
Give them a longer bounded window instead.

`WORKFLOW_EXECUTION_MAX_ROWS_PER_RUN` had become a per-status budget when the
stale-execution sweep gained its `redacting` pass, silently doubling the
per-run cap. Share the budget across both passes, and add the matching partial
indexes so the second pass keeps an index rather than seq-scanning.

Also consolidates the carrier metadata keys, their predicates and the jsonb
merge into one module -- the two routes were the writer and reader of the same
keys with no shared symbol, so a rename type-checked clean while silently
breaking retention.

* fix(schedules): rotate deferred carriers and stop the redacting sweep failing live runs

Addresses the review findings on the reconciliation hardening.

A carrier whose accounting was deferred got no write at all, so it kept its
`updatedAt` and stayed at the head of the `updatedAt`-ordered recovery batch on
every tick, starving every other claimed carrier and never becoming eligible for
retention. This was a regression from the previous commit: settling used to bump
the timestamp for every examined row, and skipping the settle for already-terminal
carriers removed the bump with it. A payload with no `scheduledFor` can never
reconcile, so such a row pinned a batch slot permanently. Bump `updatedAt`
unconditionally and keep only the reconciled marker conditional.

The stale-execution sweep terminalized `redacting` logs on the execution deadline.
That deadline bounds execution, while `redacting` covers payload masking after the
run already finished -- so a run that used most of its budget entered redaction
with the deadline due, and the sweep failed it five minutes later while the worker
was still masking. Schedule recovery then read the log as a failed occurrence and
counted a failure that never happened, even though the worker's terminal write
later restored `completed`. Sweep `redacting` on the generic stale window only.

`getScheduleNextRunAt` falls back to a daily cadence when a schedule has no cron
expression. Deployment cannot persist such a schedule, so the branch is
unreachable, but this change widened its use from failure recovery to every
outcome -- log a warning when it fires rather than silently guessing a cadence.

`executionDeadlineAt` was missing from the shared `workflowExecutionLogs` schema
mock, so it read as `undefined` and assertions comparing against that column were
trivially true. Add it.

* refactor(schedules): drop vestigial recovery code and close a builder gap

Follow-ups from a full re-read of the change. No behavior change except the
removed dead code paths.

The metadata merge stripped a `scheduleRecoveryBlocked` key on every write. That
key has never been written by any shipped code -- it appears nowhere in staging
and nowhere in history outside this branch -- so the strip guarded against a
state that cannot exist, at the cost of an extra jsonb operation and a bind
parameter on every reconciliation write.

`processScheduleItem` set `carrierObservedOrLookupUncertain` immediately before
returning on an ambiguous enqueue. The flag is only read from the surrounding
catch block, which a normal return skips, so the assignment was dead and read as
though it were load-bearing. Replaced with a comment stating why the occurrence
is preserved.

The stale-execution sweep carried two near-identical `jsonb_set` templates that
differed only in their error expression, kept flat because the test mock renders
nested SQL fragments as placeholders. The suite now has a recursive renderer, so
the error expression is a named per-status value and there is one `jsonb_set`.

Success was the only schedule outcome without a named update builder, which left
`executeScheduleJob` using two idioms for the same guarded write and left the
update shape untested. Add `buildScheduleSuccessUpdate` beside its cancellation
and failure siblings, use it from both call sites, and cover it the way
`buildScheduleCancellationUpdate` is covered -- a mutation of its `failedCount`
reset previously passed every suite.

---------

Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
Co-authored-by: Waleed Latif <walif6@gmail.com>
2026-08-20 11:55:45 -07:00
Justin BlumencranzandClaude Fable 5 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 BlumencranzandClaude Fable 5 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 MondretiandClaude Opus 5 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 BlumencranzandWaleed Latif 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
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 MondretiandClaude Opus 5 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 MondretiandClaude Opus 5 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 MondretiandClaude Opus 5 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 MondretiandClaude Opus 5 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
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 MondretiandClaude Opus 5 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
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