Commit Graph
40 Commits
Author SHA1 Message Date
Vikhyath Mondreti 2da885582c test(realtime): wait for the idle read the streak test depends on (#6673)
The guard failed intermittently in CI — `expected 1870 to be less than 1000`,
which is the carried-streak backoff, meaning the streak was never reset before
the phase that measures it.

The middle phase cleared the fault and then waited a FIXED 1000ms for an idle
read to land. It usually did. But the pending backoff from the two failures
before it runs 400–600ms then 800–1200ms, so the next read is due anywhere up
to ~1805ms — and the phase ends at 1800ms. When both jitters drew high the read
arrived after the fault had already been re-armed, so it failed instead of
succeeding, the streak survived at two, and the measurement caught the third
backoff (1600–2400ms) rather than the first.

Waiting for a duration where the thing being waited for is an event is the bug.
Each phase now waits for its own event: two failed reads to build the streak,
then a read that actually RETURNS to clear it.

Also measure failure-to-failure rather than read-to-read. A successful read can
land in the instant after the fault is re-armed, and as the first sample it
would make the gap ~5ms — passing for the wrong reason, the same false-pass
shape review caught in this test last round.

20 consecutive runs green; still fails on the un-fixed reader every time
(1753ms, 1688ms, 1881ms, 1701ms, 1837ms against the 1200ms bound).
2026-08-13 13:15:25 -07:00
Vikhyath Mondreti bed25e2a86 fix(realtime): keep the file-doc store reconnecting instead of dying quietly (#6661)
* fix(realtime): keep the file-doc store reconnecting instead of dying quietly

A relay that lost Redis for longer than its retry budget did not degrade — it
went silently split-brain and stayed that way. The reconnect strategy returned
an `Error` after ten attempts, which tells node-redis to give up and CLOSE the
client, and a closed client rejects every command with "The client is closed"
for the rest of the process's life. From that point the task kept serving
clients while its rooms stopped receiving other tasks' updates, its own edits
stopped reaching the shared stream (also the crash buffer between persists), and
seeds, locks and the persist If-Match token all failed.

The tail loop then treated that as a transient read error — `running` is only
false during shutdown, so it retried every 500ms forever, one warning per
attempt. A tab left open overnight produced thousands of identical lines, which
is how the actual failure stayed invisible.

- Never stop reconnecting. This process holds live documents whose only
  convergence path is that connection, so a connection it can rebuild is always
  worth rebuilding. Same capped backoff, now via the shared
  `backoffWithJitter`, and no error return.
- Back the reader off after a failed read (500ms → 10s) instead of retrying at
  the read cadence, re-open a client that was CLOSED — node-redis reconnects a
  dropped client, never a closed one — and log the first failure of a streak
  then one in twenty, carrying the streak length, so an outage stays visible
  without burying itself.

Pinned by a test that models a closed connection: six read attempts in three
seconds before, about three after, and proof the reader is re-opened rather
than abandoned.

* fix(realtime): end the reader's failure streak on an idle read, not a busy one

Review findings, both accurate.

The streak reset sat after the entries were applied, so a blocking read that
timed out with nothing new — the idle steady state — skipped it via `continue`.
A healed outage's count therefore survived through normal polling, and the next
unrelated blip opened at the backoff cap: minutes of avoidable split-brain, and
a log line claiming a failure count it never earned. The streak now ends on the
read RETURNING, which is what proves the connection works.

Also: the new test built a raw `setTimeout` promise instead of the shared
`sleep`, which CLAUDE.md calls out by name.

* test(realtime): assert the retry delay, not a count inside a window

The streak-reset guard could pass on the very regression it exists to catch. It
counted read attempts inside a 1900ms window, and the jittered delay for a
carried streak is 1600–2400ms — so whenever jitter landed below about 0.95, a
second read fell inside the window and the assertion held even though the idle
reads had never cleared `failures`. A single falsification run happened to draw
a long delay, which is exactly how a guard like this goes quiet.

Assert the delay itself instead. The first retry after a reset is 500ms ±20%
(400–600ms); carried over it is the third, 2000ms ±20% (1600–2400ms). Those
ranges are disjoint, so the check no longer depends on which jitter is drawn:
against the old placement it now fails every time (measured 2160ms, 1925ms,
2046ms against the 1000ms bound).
2026-08-13 10:54:20 -07:00
Waleed 6de8ba2504 fix(v2): close the correctness gaps an end-to-end audit found (#6655)
* fix(v2): stop a third-party tool description from 500ing MCP discovery

`v2McpToolInputSchema` declared `description: z.string().optional()` inside a
`.catchall(z.unknown())` object, and a declared key beats the catchall. The MCP
SDK's own `ToolSchema.inputSchema` does not declare `description` at all, so any
value — including the JSON `null` a Python server emits for an absent one —
passes its validation and reaches Sim unchecked. The builder's outbound `.parse()`
then threw, and the discovery error policy correctly declines to classify a
Sim-side schema defect, so the endpoint that completes MCP onboarding answered a
bare 500. The key is dropped and left to the catchall; `type`, `properties`, and
`required` stay pinned because the SDK enforces those at least as tightly.

Also in the v2 resources family:

- The single-resource query schemas for MCP servers, skills, custom tools, and
  secrets are now `.strict()`, matching every list in the same family. A mistyped
  flag was silently ignored behind a 200.
- `openapi/resources.ts` re-derived `RESOURCE_ERRORS` and
  `RESOURCE_CONFLICT_ERRORS` inline in 21 of 22 operations. They now import the
  shared constants; the generated spec is unchanged, which is the point.
- The internal MCP refresh route stamped `updatedAt` alongside `lastToolsRefresh`.
  `updatedAt` means "configuration last changed" and is a public keyset sort, so
  a refresh moved rows out from under an in-flight page. `updateServerStatus`
  already held that invariant; the route now matches it.
- The discovery cooldown is a typed `McpServerCooldownError` rather than a
  substring search for `cooldown`. `McpConnectionError` interpolates the server's
  display name into its message, so a server named after the word was reported as
  a transient cooldown when its connection had genuinely failed.

* fix(v2): close correctness gaps in the workflows deployment surface

Deploy and rollback bodies were plain objects, so a misspelled key was
stripped rather than rejected. On rollback that is silent misbehavior:
an omitted `version` legitimately means "reactivate the preceding
version", so `{"versoin": 5}` rolled back somewhere else and answered
200. Both v2 bodies, the run-read query, and the versions cursor are now
strict.

Deployment versions are an `integer` column, but the path param, the
versions cursor, and the v1 body each bounded it differently or not at
all — an out-of-range value overflowed the comparison into an
unclassifiable 500. One exported bound now covers all three.

Resume admission raised bare `Error`s for a stale contextId or an
already-resumed run, which the resume surfaces could not classify and
reported as 500. They now use the sibling `ResumeAdmissionError` already
in that file, carrying 404/409/400 and whether an automatic retry can
clear the refusal.

Docs corrections: rollback publishes the 409 its webhook-path conflict
already produces; deploy/undeploy/rollback reject a workspace key with
403, not the concealed 404 they documented; the workflows OpenAPI module
imports the shared error sets instead of re-deriving them; import and
the folder ops explain their folder-tree 413. The export route is marked
`headSafe: false` so a HEAD probe stops filing a WORKFLOW_EXPORTED audit
event for an export that never happened. `runId` is one bounded schema
across the run and log resources.

* fix(v2): conceal knowledge upload existence, tighten knowledge/files bounds

Security: the four knowledge document-upload routes rendered a bare upload
error policy with no resource concealment, while every sibling knowledge route
uses one. Because the use case resolves the knowledge-base context before
workspace authorization, the unconcealed 403 told any valid API-key holder that
a knowledge base exists in a workspace it cannot reach — the exact signal
GET /api/v2/knowledge/{id} withholds by answering 404 either way. All four now
use the composed concealing policy, which also renders the 415/402/413 the
route-local renderer already handled; that duplicate renderer is deleted.

Contracts:
- POST /knowledge/search is strict. It was the only non-strict v2 request body,
  so a mis-cased rerankerEnabled or topK returned 200 with the key stripped,
  changing what the caller was billed and silently disabling reranking.
- The document list takes limit, cursor, and search from the shared v2 schemas.
  search was an unbounded, empty-accepting v1 string, so ?search= answered 200
  with a full page here and 400 on GET /knowledge, and the term reached an
  unindexed filename LIKE scan with no ceiling.
- The 16 non-strict single-field workspace query slices across both families are
  strict, matching GET /knowledge/{id}/tags.
- GET /audit-logs takes workspaceIdSchema instead of a bare string (?workspaceId=
  was forwarded as a filter and returned zero rows) and the shared run-window
  bounds for startDate/endDate.

Documentation:
- listAuditLogs drops the 404 it has no code path to emit.
- upsertFileShare describes its workspace-key refusal as the 403 it renders;
  the operation denies the key by principal kind, which the concealment policy
  does not rewrite.
- The 12 body-reading knowledge and files operations publish the 413 their
  pre-validation body read raises, and the file list publishes the folder-tree
  413 its now-capped path index raises.

Correctness: queryWorkspaceFilePage loads its folder path index under
MAX_FOLDERS_PER_WORKSPACE like the workflow, table, and knowledge lists. An
uncapped index does not fail on truncation, so a real folder outside the read
rows resolved to undefined and answered "Folder not found".

* fix(v2): publish the reachable 413 on body-carrying resources ops

`parseRequest` buffers a JSON body through `parseJsonBody` under
`DEFAULT_MAX_JSON_BODY_BYTES` before any schema runs, and the v2 builders supply
`V2_PARSE_DEFAULTS.payloadTooLargeResponse`, so every operation whose contract
declares a body already answers 413 above the cap. The resources family
published it on none of them. A status a caller cannot see in the spec is a
status they will not handle.

Adds `RESOURCE_BODY_ERRORS` and `RESOURCE_CONFLICT_BODY_ERRORS` to the shared
sets and applies them to the seven affected operations: createMcpServer,
updateMcpServer, createSkill, updateSkill, createCustomTool, updateCustomTool,
and setSecret. All seven are `defineV2JsonRoute` handlers on non-GET methods
with no `parseOptions` override, so the 413 is genuinely reachable on each. The
new sets are opt-in rather than folded into the base sets precisely because
reachability is not automatic — an operation with no body, or one whose payload
reaches it through an uncapped path, would be publishing a response that can
never arrive.

A sweep test pins the invariant across the resources, billing, and logs
documents. It is one-directional by construction: several bodyless operations
publish 413 for their own folder-tree and render ceilings, so the converse would
flag correct documentation.

Also completes the shared-constant consolidation started in cd3efefab9:
`openapi/billing.ts` and `openapi/logs.ts` each re-derived `RESOURCE_ERRORS`
inline in two operations. Both now import it, and both regenerate byte-identical.

* fix(v2): head-safe binary downloads, coded 403s, and truthful surface docs

Adds `headSafe` to `defineV2BinaryRoute`, mirroring the JSON builder: a HEAD
on a route that declares itself unsafe is authenticated and rate-limited, then
answered bodiless before parsing or executing. `GET /api/v2/files/{fileId}` is
the one binary v2 route and it records a `FILE_DOWNLOADED` audit event, so a
HEAD probe used to fabricate a download that never happened.

Names the cause of five refusals that reached the wire as codeless 403s
(billing principal-kind, personal-keys-disabled and role, secret admin and
write, the workspace table quota, and public sharing), adding three members to
the closed `FORBIDDEN_DETAIL_CODES` set. The billing cross-tenant refusal is
concealed as a 404 instead of coded, and the credential-list and knowledge
file-ownership refusals stay codeless deliberately, documented at the site.

Makes `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` reachable: an operation that
denies workspace keys also omits them from `principalKinds`, so the kind guard
always fired first and callers got `PRINCIPAL_KIND_NOT_PERMITTED` instead of
the published code.

Drops the unused 410 response, shares one `order` schema between the two run
reads so both specs spell the enum the same way, and corrects the false
statements about 403 codes, 413 causes, cursor schemes, and full-set lists in
the conventions skill and the contract TSDoc.

* fix(tables): close the v2 tables correctness and contract gaps

- updateColumnOptions was the only column mutator with no lock assert: an
  options-only PATCH applied on a schema-locked table, and an option REMOVAL
  cleared cells on a delete-locked one. Assert schema always, escalate to the
  destructive gate only when options are dropped.
- GET/DELETE /tables/imports/{id} 500'd on a first-party import job (null
  payload) or an unrepresentable status. Both now read as absent, so the answer
  is the 404 it always was.
- Offset cursors stamped the sort but not the filters, so a page-2 cursor
  replayed under a different predicate paged an unrelated sequence silently.
  Offsets now carry a filter fingerprint and refuse a mismatch.
- Publish 413 on every tables operation that accepts a request body: the v2
  JSON builder reads the body under a byte ceiling before validation, so the
  status is reachable on all of them. Derived at document assembly so a new
  route cannot regress it.
- Enforce MAX_VIEWS_PER_TABLE on view create, making the list contract's
  "small bounded set" claim true.
- Accept the upload control token on the import read, so an upload-backed
  import is readable during the phase its own 201 reported; drop the `queued`
  status the reads can never return.
- Declare the Find search-term cap, the Find match cap, and the run row-id
  ceiling the domain already enforces.
- Uniform 201 on the row and column creates.

* docs(v2): record why the two migrate-on-read GETs stay head-safe

An enumeration of side-effecting v2 GETs flagged these two for issuing a
workflow_blocks update. The write is convergent and would be issued by the
next ordinary read, and headSafe: false answers 200 unconditionally, so
declaring it would cost HEAD its existence check to prevent nothing.

* fix(api): classify the caller input that reached the driver unvalidated

Four families of caller-reachable 500s share one shape: a value the
contract admits, the application forwards, and the database rejects.
An unclassified driver throw renders as INTERNAL_ERROR, so a bad
request came back as a server fault — on pure reads as well as writes.

NUL bytes are rejected at the contract boundary, in parseRequest, not
per field. A shared string primitive only protects the fields somebody
remembers to build on it, and it cannot protect the values that have no
string schema at all: a table cell and a predicate value are z.unknown()
because their type belongs to the column, not the wire, and those are
exactly the values found reaching the driver. One scan over the already
validated params/query/body covers every field including the ones nobody
has enumerated. Only U+0000 is rejected; every other control character
is ordinary content that Postgres stores verbatim.

Date bounds on a filter are now parsed, not merely type-checked, with
the same normalizer the date column type uses to store cells — so the
filter grammar and the storage grammar agree, and gt/gte/lt/lte on both
JSONB date columns and the createdAt/updatedAt system columns answer an
unparseable bound with 400 instead of an invalid-input-syntax 500.

An afterRowId/beforeRowId anchor that does not exist is a classified
not-found rather than a bare Error, and a zero-byte knowledge document
is refused at admission: every parser rejects an empty buffer outright,
so the upload could only ever consume storage and quota on its way to
processingStatus failed.

* fix(v2): stop six endpoints from returning a confident untruth

Six defects that share a shape: a 200 that misrepresents what happened,
which is the one class a caller cannot detect from the response.

Knowledge search silently degraded. Reranking is implemented and does
run, but a deployment with no Cohere credential, a provider error, or a
timeout was swallowed into a warning log and answered 200 with plain
vector ordering and no `rerankerScore` anywhere — indistinguishable from
a reranker that ran and agreed with the vector order. The fallback stays
(an outage should not take search down) and is now reported:
`rerankerStatus` is required on every search response. v2 also omitted
the `rerankerModel` default the internal contract supplies, so
`rerankerEnabled: true` alone failed the use case's model guard and
returned unreranked results after paying for the widened candidate
retrieval; it now defaults like its sibling.

`GET /billing/logs` accepted `startDate`/`endDate` with any relative
period and dropped them, answering over the default 30-day window — a
caller reconciling charges got real rows that were not the rows it asked
for. Both bounds are now rejected outside `period=custom`, take the same
strict UTC form as `GET /logs` via the shared `v2RunWindowBoundSchema`,
and reject an inverted window instead of returning an empty page.

MCP registration stamped `connectionStatus: 'connected'` and
`lastConnected: now` at insert without contacting the endpoint, and did
the same on any non-OAuth re-registration while leaving `lastError`
stale. `tool-validation` gates tool availability on that column, so an
unreachable server read as healthy. Both paths now leave the columns at
their honest defaults for `mcpService.updateServerStatus` to move after
a real discovery; the client-side optimistic copy matches.

`skills.create` allowed a workspace API key while every other skill
write denies one, so a key could only ever accumulate skills it could
never remove — and the row it left was attributed to the workspace's
billing owner, minting an editor grant for a human who did not act.
Creation now denies a workspace key, making the lifecycle symmetric on
the per-skill editor model that authorizes the rest of it.

`runCount` counts successful non-paused runs and is never decremented by
retention, so it disagrees with the runs list in both directions; the
description now says so rather than claiming "total recorded runs". Run
retention itself was undocumented — free-plan runs are hard-deleted after
30 days, which is why a workflow reports runs beside an empty list — and
is now stated on both reads over the execution-log table.

* fix(tables): refuse the writes v2 was silently discarding

- Uncoercible cell values were stored as null under a 200 on any optional
  column: "abc"/true/[1] into number, "yes"/1/{} into boolean, "not-a-date"
  into date, an undeclared option into select, an object into string. The
  read side already 400s on the same mismatch in a predicate, so the two
  halves of the API disagreed about the same value. `coerceRowValues` /
  `coerceRowToSchema` now take an explicit policy and default to `reject`;
  `null` is passed only where a machine produced the value for a cell no
  caller typed — a computed (workflow/enrichment) write and a CSV import,
  neither of which has anyone to answer with a 400.
- A multi-select coerced `["green"]` to `[]` — the drop was inside the
  registry, so no policy above it could see it. It now refuses any part that
  matches no option, which is what the single branch and the bulk retype gate
  already did.
- A bare number in a date cell was read as epoch milliseconds, so the far more
  common Unix-seconds shape stored a timestamp 50 years early. The unit is not
  recoverable from the value and both readings are in range, so a bare number
  is refused in both directions and the retype gate no longer needs an
  override to be stricter than the write path.
- Unknown column names were dropped by the name→id remap: an insert of
  {"nosuchcol":"x"} created an empty row under a 201, and a patch of
  {"zzz":"x"} answered updatedCount:0, indistinguishable from an empty match.
  The v2 row boundary now names them and refuses.
- The table ceiling was enforced only inside createTable, which for an
  upload-backed import does not run until the CSV has crossed the wire: a full
  workspace got a 201 and a presigned PUT for up to 5 GiB, then a 403 at
  complete with an orphaned object left behind. The advisory check now runs
  when the session is created; the authoritative one stays in the transaction
  because the quota can move mid-upload.
- Cap workflow groups per table. GET /tables/{id}/groups is published as a
  full-set list, and the group count had no bound of its own — the indirect
  one does not survive an update path that adds no columns.
- Present a group's outputs/dependencies/inputMappings by column NAME. They
  are created by name, stored by id, and were read back as ids on a surface
  that is otherwise name-keyed, so a group could not be round-tripped.
- Publish the predicate grammar: the operator set, the per-type restrictions,
  and that `*` — not `%` — is the wildcard. It was true only in the SQL
  builder's own comments, so the natural guess matched zero rows under a 200.
- Stop advertising a `workflowId` default of "" on group create; a manual
  group that omits it has always been refused.

* fix(v2): bind every paged list's cursor to its filters, not just its sort

A v2 cursor names a position in one sequence, and a list decides that
sequence from its sort AND its filters. Only the sort was stamped on the
shared keyset codec, so a cursor from an unfiltered walk was accepted
under a changed `search`, `scope`, `deployedOnly`, or folder and answered
from a sequence the caller never asked for. The two offset lists already
stamped both; nothing else did.

The failure differs by scheme but is silent in both. An offset lands at
an unrelated ordinal. A keyset stays internally coherent — correctly
ordered, duplicate-free — and drops every match sorting before its
position, which a caller holding an opaque token reads as "almost
nothing matched".

One mechanism, shared with the table-row codec: canonical JSON plus a
SHA-256 fingerprint (`lib/api/cursor-binding.ts`), stamped by
`cursorFilterScope` alongside `cursorSortKey`. The two stamps stay
separate so the 400 names which half changed. `limit` is never bound —
it selects how much of the sequence to return, not what it is.

The three lists whose token is minted by a domain codec (`/logs`,
`/audit-logs`, `/billing/logs`) get the same binding by wrapping that
token in a query-stamped envelope; the domain cursor is untouched.

`present` now also receives the parsed request, so a presenter reads the
filters it stamps straight from the query instead of the use case
carrying an HTTP cursor concern back out — the `cursorSort`/`cursorScope`
round-trips through three application services are removed.

`list-pagination.test.ts` now declares each paged list's binding and
checks it against the contract in both directions, so a new list, or a
new filter on an existing one, fails until its binding is decided.

* fix(v2): authorize HEAD probes and declare every v2 query schema

Two ways the v2 surface answered a request it had not checked.

`headSafe: false` exists so a HEAD cannot fire the side effect its GET
performs — an outbound MCP discovery, a FILE_DOWNLOADED audit event, a
WORKFLOW_EXPORTED audit event. The short-circuit sat between admission
and parsing, so it returned a bodiless 200 before resource authorization
ran at all: authorization lives inside the use case, and the use case was
exactly what the short-circuit skipped. Any valid API key drew 200 for a
denied principal kind, a nonexistent id, another tenant's workspace, and
a request missing a required param, while the GET beside it answered 403
or 404. That is an existence oracle over MCP server ids, file ids, and
workflow ids.

`OperationUseCase` gains an optional `authorize()` that runs the phase
before the business transaction — allowed-principal check, canonical
load, asserted-scope comparison, current access check — and stops.
`defineAuthorizedWorkspaceUseCase` shares one implementation between it
and `execute`, so the two cannot answer differently. A HEAD on a
not-head-safe route is now admitted, parsed, and authorized like the GET,
rendering refusals through the route's own error policy, then answered
bodiless. The builders refuse at definition time to pair
`headSafe: false` with a use case that has no `authorize`, so the next
such route is a boot failure rather than a silent 200.

Separately, `parseRequest` validates the query slice only when the
contract declares one, so an omitted `query` means "never look at the
query string" rather than "takes no query params". 69 v2 contracts
omitted it and accepted anything: `?bogus=1` was a 200 on
`GET /workflows/{id}` and a 400 on every list. They now declare
`noInputSchema`, and 8 more contracts that declared a query without
`.strict()` are tightened. A sweep over the contracts tree is the
enforcement — a compile-time gate on `defineRouteContract` was tried and
reverted because the required intersection collapses inference of the
sibling generics.

Four route tests appended `?workspaceId=` to a PATCH/PUT that reads it
from the body; that copy was being silently dropped and is now a 400.

The generated specs are byte-identical: the OpenAPI generator learns that
a slice declaring no keys publishes no parameters.

* test(tables): pin the multiselect paste on the refusal, not the silent empty

cleanCellValue runs the same registry coercion the server does, so tightening
multiselect on the server changed this helper too. The case asserting an empty
array was pinning the silent-drop the tightening removed.

* docs(v2): make the API-key security description render as plain prose

The description was already published on every spec but did not appear in the
rendered Authorization block. It carried a raw > and backticks, which the
markdown pass in the docs renderer does not survive; the operation description
on the same page renders fine. Reworded to plain prose with the same substance.

* fix(v2): bind the query cursor to its filter on every shape

Two agents each fixed half of this: the shared list codecs gained filter
binding, and the table codec gained a fingerprint, but the pure-keyset shape
stamped it on neither encode nor decode. A keyset position is absolute in
(order_key, id), which is why it was left unbound — but absolute ordering is
not completeness. Replaying the cursor under a wider filter silently omits
every match sorting before it, so paging predicate A then B returned rows 7,9
where the full B sequence is 1,3,5,7,9.

Also answers a lost create race with the conflict it already documents, and
shortens three descriptions that dwarfed their siblings — the forbidden-code
catalogue now lives on the error envelope's details field, published once per
document instead of on all 135 operations.

* fix(tables): make a saved view's column references survive the write

A view config stores every column reference as a stable column id, but two
things wrote it in different vocabularies and nothing translated between them.

`config.sort` was pruned on read against the live column ID set while the
contract defines `sort[].field` as a column NAME, so every name-keyed sort —
the only kind the v2 surface can express — pruned to nothing and the view came
back with `sort: null`, on both create and PATCH, with no warning. The same
prune dropped a sort on `createdAt`/`updatedAt`/`id`, which are sortable row
columns that simply are not in `schema.columns`. `config.filter` had the
opposite failure: it was stored verbatim, so a predicate naming a column that
does not exist saved happily and then 400'd on every `/query`, `/query/count`,
and `/rows/find` that tried to use it.

The write path now canonicalizes a config before storing it: every column
reference (layout keys, `sort[].field`, each `filter` leaf `field`) is resolved
to the column's stable id, and `filter`/`sort` are validated against the live
schema so a reference that can never resolve is refused instead of saved. The
v2 read presents the config back keyed by column name, matching
`presentV2WorkflowGroup` and every other v2 row/data surface — a caller never
sees a `col_…` id, and what it wrote is what it reads. Resolution is a lookup
with pass-through, so the id-keyed first-party UI is unaffected.

Column LAYOUT stays unvalidated on write and pruned on read: it auto-saves as
the user drags, so racing a column delete must self-heal, not fail the drag.
The read path still never prunes a predicate, for the reason already documented
there — a pruned condition silently widens the view's row set.

* fix(storage): validate at the decode and multipart boundaries, bound derived keys

Four caller-reachable 500s shared one shape: input passed boundary
validation, then failed in the storage/key layer. Each is fixed at the
boundary that owns the transformation, not at the call sites.

Percent-encoded NUL in a canonical folder path. `parseRequest`'s NUL scan
sees `%00` as three ordinary characters; the NUL only exists after
`parseFolderPath` decodes it. Reads survived as 404s, writers carried the
decoded name into an INSERT and the driver threw. The rejection now lives
in `encodeFolderPathSegment`, the single chokepoint both building and
parsing funnel through, so it covers every escape a caller can spell.

NUL in a multipart field. A multipart route declares no body contract, so
its fields never reach contract validation at all — the knowledge-document
key was sanitized while `original_name` was not, and the object landed in
storage before the insert threw. `readFormDataWithLimit` is the shared
multipart reader every such route already funnels through, so the scan
goes there and runs before a caller holds a File to upload, which removes
the orphan rather than cleaning it up.

Storage-key overflow at 225 characters. Every generator embedded the file
name in a path component it also prefixed with a timestamp and a
uniquifier, so the effective limit was 255 minus that prefix while the
contract advertised 255 — a 225-character name produced a 256-byte
component and ENAMETOOLONG from local storage, and the upload session
handed out a transfer URL that could never succeed.
`buildStorageKeySegment` reserves the prefix out of the component's budget,
making the key independent of name length and the declared limit honest.

The NUL predicate is now shared from `@sim/utils/string` by all three
boundaries instead of being restated at each.

* docs(v2): make the published spec describe the API it has

Three descriptions asserted behavior the code no longer has, and three rules
the code enforces were published as unconstrained strings.

`downloadFile` and `listMcpServerTools` still told callers a `HEAD` on a
not-head-safe route "is answered with an empty 200 ... reports only that the
endpoint exists and the caller is authorized". That was true of the old
short-circuit, which sat between admission and parsing and therefore returned
200 for an id the same caller's `GET` refused. The builders now authorize a
HEAD exactly as the GET, so the spec said the opposite of a security fix. One
`HEAD_MIRRORS_GET` constant replaces both sentences and is added to
`exportWorkflow`, whose `headSafe: false` was never documented at all. A test
walks the `app/api/v2` tree for the declaration and fails on any operation that
carries it without the sentence, or that resurrects the old claim.

`createMcpServer` promised that re-registering an existing URL "rewrites the
configuration and returns the server to the same unverified state"; it is a
409 pointing at PATCH. `authType` claimed Sim "detects it from the server when
omitted" — registration deliberately never contacts the server, and the column
defaults to `headers`. The default stays: `headers` and `none` are
behaviourally identical (only `oauth` branches), so changing it is a migration
with no caller-visible payoff, while the sentence was simply false.

`predicate` was the API's most consequential gap: a `pipe` over `z.unknown()`
documents from its input, so the leaf keys `field`/`op`/`value` appeared
nowhere in the contract and `{column, operator, value}` was a 400 a caller
could not correct against. Both predicate schemas now publish a real recursive
JSON Schema through `.meta()`, self-referencing so the recursion resolves from
one `$defs` entry, with every bound read from the constant that enforces it.

Also published: the canonical folder-path rule and its 4096-byte cap on the
four path components (the `superRefine` contributed nothing to JSON Schema);
the closed 12-value `recursive` vocabulary on a destructive delete; and the
null-matching behaviour of the negating operators. The clamping `limit` branch
drops `minimum`/`maximum`, which in JSON Schema mean "rejected outside" and
made SDKs refuse locally what the server clamps.

`deleteFile` stops publishing a 409 nothing in its path can raise. `restoreFile`
and `abortFileUpload` keep theirs — the report called them unemittable, but
restore raises `FileConflictError` after exhausting its rename retries and
abort refuses a completed session.

Description tail, across the seven specs: p99 733 to 465, max operation 1643 to
1114, over 700 chars 31 to 13, over 400 70 to 61. Constraints moved from
operation prose onto the fields they constrain rather than being deleted.

* fix(v2): make upload completion, blank query values, search, and folder filters answer correctly

Four defects on the v2 surface, each reproduced before it was fixed.

Upload completion dispatched document indexing from inside the completion
transaction, so a queue or processing failure returned 500 after the object was
stored, the document row was created, and the session was marked completed —
and the only recovery, replaying the request, answered 200. The dispatch is now
a follow-on step that runs after the session is durably completed and is logged
rather than raised. Its outcome stays visible on the document itself (`failed`
with an error, or `pending` when it was never picked up), and the recovery path
re-queues a `pending` registration instead of keying off a message left on the
session.

A query parameter sent with no value was read as `0`, `false`, or the parameter
default: `?limit=` became `LIMIT 1` on the three lists that clamp, and
`?minCost=` on `/logs` became a live `cost >= 0` filter. `search` and `cursor`
already rejected a blank and documented "omit the parameter instead"; that rule
now applies to every v2 parameter, enforced on the raw query before coercion so
a parameter added later inherits it.

The document list matched `_` and `%` in `search` as live LIKE wildcards while
every sibling list escaped them through `searchFilter`, so the documented
substring match returned everything for `a_itest`. It now uses the same helper.

A `folderPath`/`folderPaths` naming no folder answered 404 on `/logs`, `/files`,
`/workflows`, `/tables`, and `/knowledge`, while every other filter answers an
empty page and the sibling folder lists already do. All five now return an empty
page. Mutations keep their 404.

* chore(v2): regenerate the specs from the merged sources

The four spec conflicts in the wave-3 merge were resolved by taking one side,
which left them describing neither branch. Regenerated so the published
documents match the contracts they are built from.

* docs(v2): give a built-in skill's id its real form

The contract said a built-in skill uses its name as the id. The ids are
`builtin-` plus the name, so a client following the description asks for
/skills/research and gets a 404 where the spec promises the skill.

* fix(uploads): keep local upload artifacts inside NAME_MAX

`POST /api/v2/files/uploads` accepted a name of up to 255 characters,
returned 201, and handed back a transfer URL that could never succeed:
the PUT against it 500'd and `complete` then reported the object missing.

The local provider named its staged object after the destination —
`{key}.{uploadId}-{uuid}.tmp` plus a `.upload-metadata.json` sidecar — so
the staged component was the key's length plus ~99 bytes of fixed
overhead. Past roughly 125 characters of name that crossed POSIX
`NAME_MAX`, and `ENAMETOOLONG` is not a `LocalUploadBodyError`, so it
escaped as a 500. Multipart `complete` built the same name and failed the
same way. Only local storage is affected; S3, Azure, and GCS have no
per-component limit.

`buildStorageKeySegment` already budgeted the key to 255, one layer above
where the overflow happened. Two changes close it at the layers that own
each suffix:

- Staged artifacts move to a `.staging` root and are named from the
  upload id alone. A name derived from the destination inherits its
  length and then adds to it; a fixed-width one removes the arithmetic
  instead of re-budgeting it, so no suffix added here later can depend on
  the caller's file name. The staging root is a cleanup sweep root, which
  also reclaims artifacts that used to be orphaned beside the
  destination.
- The durable sidecar is reserved out of the key budget centrally.
  `LOCAL_UPLOAD_METADATA_SUFFIX` moves next to the budget that must
  account for it, and the budget is derived from a list of sidecar
  suffixes, so adding one shrinks every key builder at once.

The declared `maxLength: 255` stays honest: a 255-character name now
completes PUT and `complete` end to end.

* fix(uploads): budget every key built from a caller-supplied name

Auditing the rest of the codebase for the shape that broke the
upload-session PUT found five more key builders that put an unbounded
name into a path component local storage writes directly.

Three are on the same route as the original bug: `table_import`,
`profile_picture`, and `workspace_logo` built their key inline with
`sanitizeFileName`, which maps characters and never truncates, while
their sibling purposes went through `buildStorageKeySegment`. A
255-character name broke `table_import` at the metadata sidecar and the
other two at the object write itself.

The other two are local-storage writers reached from elsewhere:
knowledge-base connector sync capped the document title at 200 and then
appended a timestamp, a uuid and `.txt` on top of the cap, landing at
exactly 255 with no room for the sidecar; the Mistral-OCR staging and
chunk keys inlined the sanitizer with no bound at all; and inbound email
attachments went into a key with neither sanitizer nor bound, on a file
name an outside sender chooses.

All now derive their component through `buildStorageKeySegment`, so the
reservation is stated once. The upload-session test asserts it for every
purpose the contract admits, which is what keeps a newly added purpose
from reintroducing the hand-built form.

* fix(v2): stop the logs and billing reads answering 500 or a silent restart

Four caller-reachable failures on `GET /logs`, `GET /logs/{runId}`, and
`GET /billing/logs`, each fixed at the layer that owns the guarantee.

`minDurationMs`/`maxDurationMs` were published as `number` against an
`integer` column, so `1.5`, `-0.5`, `2147483648`, and `1e30` all reached
Postgres as bind parameters it refuses to parse. They are now whole
milliseconds bounded to int4, and the generated spec says so.

`0000-01-01T00:00:00Z` satisfies the published `date-time` pattern but
names no instant Postgres can store, since the proleptic Gregorian
calendar has no year zero. `v2RunWindowBoundSchema` now rejects it, which
covers both log families and the files-audit read that share the schema.

A scoped cursor whose inner token was the empty string passed the
`typeof === 'string'` envelope check and then read as falsy in every
domain reader, so both lists silently served page one again with a
`nextCursor` inviting another lap — the exact failure
`UNKNOWN_CURSOR_MESSAGE` exists to make visible. An empty inner is now
unreadable, and the sibling `decodePublicLogCursor` gets the same
treatment for its `id` half. The rejection message no longer names
`sortBy`/`sortOrder`, which neither operation accepts.

`GET /logs/{runId}` reported `folderPath: null` for both a workflow at
the workspace root and a folder it could not resolve, so a caller could
distinguish neither, and `null` is not a value `folderPaths` takes back
as a filter. The root is now `/`, matching the workflow resources.

Also, from the same audit: comma lists reject an empty entry the way
`folderPaths` already did instead of dropping it; a query param sent
twice is named as duplicated rather than reported absent; and the
`triggers=all` sentinel, the detail-level promotion by
`includeTraceSpans`/`includeFinalOutput`, and the 403/404 split against
the billing family are documented where each is decided.

* fix(v2): pin naive timestamps to UTC and close six contract divergences

Application-written timestamps reached the wire as a local wall clock
labelled `Z`. Every column in `schema.ts` is `timestamp without time
zone`, so the instant a value denotes was decided by whoever wrote it and
whoever read it, and the writers disagreed: `now()` renders in the
session's TimeZone, drizzle's `mapToDriverValue` is `toISOString()`, and
a raw `Date` bound through postgres.js is cast down in the session's
TimeZone. The read side disagreed the same way — postgres.js parses oid
1114 with `new Date(x)`, which is the process's local zone, while a value
it hands back as a string is read as UTC by drizzle. The result passes
every `date-time` check, so it silently corrupts sorts and range
predicates and can place `updatedAt` before its own `createdAt`.

`packages/db/timestamps.ts` removes the ambiguity at the driver boundary
rather than at the call sites: the session TimeZone is pinned to UTC so
all three write paths store the same wall clock, and oid 1114 is parsed
as UTC so every read path recovers that instant. `withUtcTimestamps`
merges both into a client's options, because `connection` is nested and a
pool setting its own `application_name` would otherwise drop the
TimeZone. Production already runs both in UTC, so nothing changes there;
every other environment now behaves the way production does.

Alongside it, six places where the published contract and the code
disagreed:

- Multi-select `ncontains` was documented as "the exception" that
  excludes nulls. It never did, and no test claimed it did — `data` is
  never NULL, so containment is false for an absent key and the negation
  is true, exactly like every other negation. The sentence was wrong.
- `recursive` published twelve lowercase spellings while `z.stringbool()`
  folded case, so the server honoured `recursive=True` as a destructive
  recursive delete that a generated client would have refused to send.
  Narrowed to case-sensitive: accept exactly what is published.
- The upload data plane answered with a bare `{ error: string }`. Being
  absent from the OpenAPI documents is a statement about addressability,
  not about behaviour; both PUTs now use the canonical envelope, and what
  the transfer step promises is published on `transfer.url`.
- Full-set lists told callers to "send it back as `cursor`" on a
  `.strict()` query that rejects `cursor`. `v2CursorListResponse` now
  takes `paged`.
- A `HEAD` on a download skips the read that produces `Content-Length`,
  so it cannot size a download; the description says so.
- The upsert conflict-target rejection echoed the storage id a name-keyed
  surface had already translated to, and the scoped-cursor 400 named
  `sortBy`/`sortOrder` params `/audit-logs` does not accept.

* improvement(v2): cut the extraneous half out of the published descriptions

The v2 spec's description median was already healthy at 42 characters; the
tail was not. 174 descriptions ran past 200 characters and 13 past 700,
almost all of it rationale, cross-references, and constraints restated on
the wrong object.

Trim the shared error, folder-path, retention, pagination, and workspace-key
constants first, since each is published on between two and twenty-seven
operations. `FOLDER_TREE_TOO_LARGE` dropped the clause explaining why the
tree has to load, `FULL_SET_LIST` dropped a second sentence restating its
first, `RUN_RETENTION` dropped the `runCount` caveat that already lives on
`runCount`, and the 503 and 499 descriptions dropped the paragraphs
narrating why they are documented at all. That reasoning belongs in the
TSDoc beside each constant, which is where it now is.

Then the operations. Execute Workflow and List Runs each restated a rule
their own parameters already carry — the `X-Run-Id` uniqueness claim and the
`order` sort deviation — so both moved to the parameter that owns them. The
run-status enum sent a caller to `paused.automaticResumeWaitingReason` and
then explained that field in place of describing it; the explanation moved
onto the field, which previously said only that it was "the reason automatic
resume is waiting".

Align the parameter vocabulary a caller meets in every family. One `cursor`
description had forked on the table row query, one `sortBy` on knowledge
documents, and the table row `limit` published neither its bounds nor its
default. `nameSortCollation` is now a function of the column it names, so
the knowledge document list can state the caveat about `filename` without
claiming a `name` field it does not have. `scripts/openapi/documents.test.ts`
pins `cursor` and `sortOrder` to one string each, and the retention window to
both reads that publish it.

Distribution over the seven documents: mean 71 to 67, p95 223 to 199, p99 453
to 370. Over 200 characters 174 to 147, over 300 94 to 59, over 400 54 to 20,
over 700 13 to 9. The median is unchanged at 42.

* fix(v2): keep one unreadable-cursor message

Two branches each added the constant, in cursor-binding and list-query. It
belongs beside its sibling REFILTERED_CURSOR_MESSAGE, so the list-query copy
and its importers move there.

* fix(v2): bind a cursor to what a set filter means, not how it was spelled

workflowIds, triggers and folderPaths are comma lists the query treats as
unordered sets, and tagFilters is an object whose key order carries no meaning.
Fingerprinting the raw spelling bound the cursor to the spelling, so a caller
who reordered an equivalent filter mid-walk got a 400 for a page that was
genuinely the next one.

* fix(v2, db): make two unfalsifiable tests observable and document strict query

Three follow-ups on the w5 policy work: one decision recorded, two tests that
could not fail.

The `query: noInputSchema` sweep is kept. It is a real tightening — 69 v2
operations that ignored an unknown query param now answer 400 — so it was
weighed rather than assumed. The v2 body slice on those same endpoints was
already `.strict()`, and every v2 list already rejected `?bogus=1`, so the
split was arbitrary rather than a promise: the same typo was a 400 on
`GET /workflows` and a silent 200 on `GET /workflows/{id}`. A parameter the
server drops without saying so is the bug class the lists' rule already exists
to prevent. No first-party caller is affected — the two SDKs send only
`includeOutput`/`selectedOutputs`, both declared; the UI and the desktop app
make no v2 calls at all; `requestJson` appends nothing implicitly and no v2
cache buster exists; every docs example uses a declared param. A third-party
caller appending a tracking tag does break, which is why the behavior is now
documented in the API reference with the exact 400 body rather than left to be
discovered, and why the reasoning sits in the v2 conventions skill next to the
rule instead of only in a commit message.

`packages/db/timestamps.test.ts` asserted that `withUtcTimestamps` registers a
UTC parser on oid 1114 by reading it off a bare postgres.js client. Every real
client is then handed to `drizzle()`, which overwrites that entry with a
transparent parser, so the assertion held whether or not the parser had any
effect. The mechanism is fine and stays: drizzle's own `PgTimestamp` mapper
appends `+0000`, so the read is UTC-correct either way and the session
`TimeZone` pin — the write-side fix — is untouched by `drizzle()`. The test now
resolves the parser both before and after `drizzle()`, pins the clobbering it
depends on, and asserts the instant recovered through the full composition, so
a regression in either layer is red. `timestamps.ts` records why the inert
entry is kept.

`nul-byte-boundary.test.ts` embedded a raw U+0000, so git classified it binary
and rendered it as `Bin 0 -> 4102 bytes` — the test proving the NUL hardening
works was the one file a reviewer could not read. The escape is byte-for-byte
equivalent at runtime. Two older files had the same defect and are fixed the
same way. `check:source-text` now fails the build on a raw NUL in any tracked
source file, and `.gitattributes` forces source files to diff as text so the
next one is visible in review rather than hidden by it.

* fix(w5): narrow three fixes that reached past the harm they were fixing

The workflow-create `23505` handler answered for the whole transaction, which
also runs `saveWorkflowToNormalizedTables`. `workflow_blocks.id` is a global
primary key, so a block-id collision — an integrity fault already seen in
production — surfaced as `A workflow named "X" already exists in this folder`.
Match on the constraint name; any other unique violation propagates unchanged.

Moving the knowledge dispatch out of the completion transaction was right, but a
dispatch failure then committed the session as `completed` and left the document
at `pending`, which nothing sweeps and `retryProcessing` refuses. Record the
failure on the document instead, so it lands on the existing failed-document
path, and describe what the code does rather than a recovery branch that cannot
fire for this state.

The MCP re-registration reset stopped a registration claiming a connection it
never made, but reset for any re-registration. `isServerEligibleForDiscovery`
skips an OAuth row that is not `connected`, so a rename removed every tool the
server published with no path back. Scope the reset to url, transport, headers,
auth type, OAuth credentials, and revival.

* fix(tables): confine the write-policy tightening to what the caller sent

The null-policy work made `reject` the default for caller-supplied writes,
which is right, but it landed on the wrong values.

- A partial update coerces the MERGED row, so an untouched legacy cell failed
  an unrelated column's update — and failed a paged bulk job after its earlier
  pages had committed. The merged-row callers now name the patch's keys; every
  other key follows the `null` policy, in the in-memory copy only (the write
  sends the patched keys alone).
- A multiselect whose members do not all resolve returned `{ok:false}`, which
  on the machine paths that pass `'null'` — CSV import, computed writes, the
  cell-write snapshot — erased the whole cell. Those paths now consult a new
  `salvage` hook and keep the members that do resolve; a caller-supplied write
  still 400s on an unknown option.
- Refusing a bare number in `date.coerce` reached the executor, v1, copilot and
  the grid. The refusal stays where there is a caller to tell, and `salvage`
  restores the milliseconds reading where the only other answer is a blank cell.

Also: the cursor docblocks claimed pure-keyset cursors were left unbound while
the code and its tests bind them; a saved-view create took the table's SCHEMA
advisory lock, so it queued behind column rewrites whose statement timeouts run
past its 3s lock_timeout, and now takes a views-scoped lock instead; and a view
whose column was deleted could not be saved at all, because the Save chip always
resends the filter — references the stored config already carries are now exempt
while a newly introduced one is still refused.

The cursor version is deliberately not bumped: the stamp is additive, unfiltered
in-flight tokens keep working, and a filtered one fails with the accurate
"restart paging without the cursor" rather than a generic unreadable-cursor 400.

* test(db): narrow the mapped timestamp to Date

mapFromDriverValue is typed unknown, so the composition assertions did not
type-check outside the test's own runner.

* fix(v2): correct four stale contracts and clear the merge debris behind them

Five of the reported defects were real and four of them were documentation
that had stopped describing its own code.

`cleanCellValue` said only "coerce a raw input value"; it also answers `null`
for anything the column type refuses, and since the multiselect write path
started refusing partial matches that is the difference between a paste
storing one option and blanking the cell. It deliberately does not consult
`salvage`, which would read the same paste as the option that did resolve —
that reading is for writes with no caller to answer, and a typed cell has one.
The pairing is now asserted, so a future helper that "improves" the paste by
salvaging it fails.

`EXECUTE_OPTION_CONSTRAINTS` carried two stacked TSDoc blocks, the second
explaining that the enumeration had moved onto the fields; the body schema
still told a reader the six combinations were enumerated in the constant. The
deployment route's second block orphaned the endpoint documentation above it,
and `list-query.ts` kept the TSDoc for a cursor message that now lives, with
its own rewritten doc, in `cursor-binding.ts`. Two agents left near-identical
essays arguing the same 400-vs-403-vs-409 question about the table ceilings
and concluding that neither status changes; the decision is recorded once, in
`billing.ts`, and `service.ts` points at it.

The credentials use case echoed `sortBy`/`sortOrder` back with a TSDoc
explaining that the presenter needs them, which it no longer does — it reads
`query.*`. The local upload roots move from the data-plane provider to
`core/storage-key.ts`, beside the sidecar suffix, so the cleanup sweep can name
what it reclaims without importing the transport that writes it.

`documents.test.ts` justified sweeping only knowledge and files for the 413 by
saying the same sweep over the other five documents still reported gaps. It
does not: widened to all seven, every body-carrying operation publishes it.

Three reports did not survive checking, and the evidence is recorded where the
next reader will look. An empty rerank result is not the reranker matching
nothing — `rerank` asks for `top_n` over a non-empty document list, so an empty
array means the response carried nothing usable, which is what `unavailable`
already promises. The zero-byte knowledge document is refused on the
upload-session path too, by `validateFile`, under both boundary contracts;
that parity is now pinned, and it fails if the guard is removed. The MCP
re-registration reports exactly the connection fields its SET clause writes,
and the create mutation already drops both caches — what lags is the status
badge, not the tools, because discovery is gated on `connected` for OAuth rows
only.

* fix(v2): de-duplicate a set filter before fingerprinting it

The filters compile to inArray, which is set membership, so workflowIds=A,A,B
selects exactly what A,B does. Sorting alone still bound them to different
pages, so an equivalent filter with a repeated member 400d mid-walk.

* fix(w6): close a head-authorization hole, a TZ leak, and five tests that could not fail

Six risks an adversarial read of this week's diff raised, verified one at a
time. Two of the six were already correct and are reported as such rather than
changed.

`v2HeadAuthorizationResponse` optional-called the use case's authorization
phase, so a use case without one would have answered the bodiless 200 that
`headSafe: false` exists to prevent. The definition-time guard does cover both
builders that reach it — they are its only callers — but an optional call turns
a missing phase into that leak silently, so the responder now refuses instead
of skipping.

`packages/db/timestamps.test.ts` assigned `process.env.TZ` at module scope and
never restored it. `TZ` is process state: a worker running files back to back
carried Asia/Tokyo into every file that followed, and only when the ordering put
it after this one. The zone is now set and restored around the file, with both
properties the suite depends on intact.

Upload publication moved its staging area out of the destination's own
directory into a shared `.staging` root, which makes the publishing `link` a
cross-subtree one. A volume mounted under part of the uploads tree puts the two
on different devices and `link` answers `EXDEV`, which the same-directory link
could not. Publication now copies onto the destination's device and links from
there, keeping the create-or-fail step that stops a replay from overwriting a
stored object.

Five tests that passed regardless of the code:

- `resolveFolderPathFilter` was only ever exercised through hand-written
  reimplementations in the suites that mock it out, so widening a miss to
  unfiltered — every filtered list answering with the whole workspace — left
  them all green. The real helper is now tested where it lives.
- The only measurement of `generateWorkspaceFileKey` asserted the key's last
  component against `NAME_MAX` rather than the component plus the sidecar
  written beside it, so it passed with the sidecar reservation removed.
- `GET /logs` asserted only that a rejected cursor does NOT name `sortBy`,
  which almost any wording satisfies, including one saying nothing at all.
- The skills lifecycle test asserted that the four writes agree on a
  workspace-key policy, which a lifecycle uniformly allowing one also
  satisfies; it now pins the policy they agree on and the kinds they admit.
- The v2 skills create test lost `expect(capture).not.toHaveBeenCalled()` when
  the create path moved to a personal key. The behaviour it pinned is gone —
  the workspace-key create is refused now — so it is re-homed as the refusal
  reaching the caller as a 403 with no analytics behind it.

Two claims did not hold. `CURSOR_VERSION` is correctly left at 1: the filter
stamp is additive, a pre-stamp token still decodes, an unfiltered read still
resumes, and only a filtered replay fails — with a conflict that names the
filter, where a version bump would answer a generic unreadable-cursor 400 to
every in-flight token. Tests pin all three, plus the minted version itself. And
the upload-session key-budget cases do exercise the real shared budget through
the real segment builder; only the workspace-key prefix is the stub's, which is
now stated where the stub is declared.

* refactor(v2): collapse two names for the cursor scope key onto one helper

`cursorFilterScope` in the v2 response module was a one-line pass-through to
`cursorScopeKey` in `lib/api/cursor-binding`, so the same function was reachable
under two names from two modules. Routes now call `cursorScopeKey` directly, the
way they already import `unorderedScopePart` and the cursor messages from that
module, and the wrapper plus its duplicated doc comment are gone.

Also folds the `id -> name` column map in the v2 tables presenter onto
`buildColumnNameById`, which the same file already imports and calls thirteen
lines above; restores two doc comments that had drifted onto the wrong
declaration; and replaces three `as Date` casts in the timestamp test with
`toEqual(new Date(...))`, which needs no cast and additionally fails when the
mapped value is not a Date at all.

* refactor: delete three pieces of surface this branch added with no consumer

`v2CursorSchema` had one caller, `v2PaginationFields`, in the same file, and its
only parameter was a default nobody overrode — so the export and the parameter
were both unreachable. Inlined into the pair it belongs to; the emitted schema
and its description are byte-identical, so the generated OpenAPI does not move.

`PatchedKeys` was declared `ReadonlySet<string> | readonly string[]`, but all
four callers pass `Object.keys(...)` and no test passes a set, which left the
`instanceof Set` arm of `policyResolver` unreachable. Narrowed to the array form
the callers actually use.

`NUL_CHARACTER` was exported from `@sim/utils/string` and imported by nobody —
every boundary imports `containsNulCharacter` instead. Kept as the module-local
constant the predicate reads, dropped from the package surface.

* docs(v2): state why the local upload data-plane routes bypass the builders

Both local-storage PUT routes use raw `withRouteHandler`. The global rule
allows that only for documented protocol or lifecycle exceptions, and their
TSDoc explained the OpenAPI exemption and the error envelope but never the
builder bypass itself. Record the actual reason: a signed `upload-token` is
the credential, so there is no API key, `Principal`, or semantic operation
for a builder to authenticate and authorize against, and the body streams
straight to storage rather than being parsed.

* test(v2): pin cursor-to-filter binding on the tables and runs lists

The branch binds every paged cursor to the filters it was minted under, but
the binding was enforced end-to-end on only 4 of 16 paged lists. The
contract-level CURSOR_BINDINGS sweep looks like the safety net and is not:
it checks each contract against a hand-maintained map of param names, never
against what a route actually stamps into cursorScopeKey, so it stays green
for a route that dropped the stamp entirely.

Confirmed by deletion. Removing tableCursorFilters from both call sites on
GET /v2/tables left all 8 tests passing, and the runs route was worse — its
one relevant assertion was weakened from toEqual to toMatchObject in this
same branch, leaving the new filter field unpinned.

Adds a mint-then-replay test to each: a cursor minted under one filter set
and replayed under another is a 400 that never reaches the use case, with a
same-filter resume case as the control so the 400 cannot be satisfied by
blanket rejection. Restores toEqual on the runs cursor payload, pinning that
a filter is stamped without hardcoding the fingerprint.

Both new guards were verified to fail: removing the binding reddens the
refiltered test on tables, and both the refiltered and the re-armed toEqual
test on runs.

* fix(tables): keep the v2 write strictness inside v2

The write-path tightening on this branch changed shared code that every
first-party surface reaches, so the workspace grid, the internal
`/api/table` routes, `/api/v1`, the Copilot table tools, and the executor's
Table block all inherited a contract only `/api/v2` publishes. Each of them
now behaves exactly as it does on staging again, and v2 keeps the strictness
by opting into it.

- `coerceRowValues`/`coerceRowToSchema` default to the `null` policy again —
  an uncoercible optional cell is blanked and the row is written. `reject` is
  reached through `RowWriteOptions.uncoercibleValues`, which the v2 row
  routes set via `strictWrite` on the application input.
- The same `strictWrite` scopes the unknown-column refusal to v2. Copilot
  feeds the model's raw arguments in unfiltered, so a hallucinated key, an
  echoed `id`, or a name left over from a rename had begun refusing the whole
  write.
- Multiselect and bare-epoch values land again for first-party callers
  through the registry's existing `salvage` hook, which the `null` policy
  already consults; the grid's `cleanCellValue` consults it too, so a paste
  naming one live option and one deleted one keeps the live one instead of
  erasing the cell.
- The saved-view name→id remap no longer rewrites a ref that already means
  something else, so a user column named `id`/`createdAt`/`updatedAt` cannot
  hijack a view's system-column sort or filter.
- `createTableView` tolerates the refs its own config carries unless the
  caller is strict, so "Save as view" stops 400ing on a dangling filter the
  Save chip accepts.
- The bulk update runner is byte-identical to staging again.

The 100-view cap stays: the list read is unpaginated, so the promise it makes
only holds if the write side enforces it, and it refuses a new view rather
than an existing config.

* test(v2): pin cursor-to-filter binding on seven more paged lists

Extends the mint-then-replay guard from tables and workflow runs to the
remaining paged v2 lists the audit found with no route-level coverage:
credentials, audit-logs, custom-tools, mcp-servers, secrets, knowledge
bases, and knowledge documents.

Each gets a cursor minted by driving GET under one filter and replayed
under another, asserting a 400 carrying REFILTERED_CURSOR_MESSAGE that
never reaches the use case, plus a same-filter resume control so the 400
cannot be satisfied by blanket rejection. The three cursor schemes are all
covered: keyset (readSortedCursor), the scoped wrapper audit-logs uses for
its domain token, and the offset cursor on knowledge documents.

The documents suite had no GET coverage at all, so its list use case gains
a real mock and the route's GET export a describe block.

All fourteen were verified to fail: dropping the cursor-filter argument
from both call sites on each route reddens exactly that route's refiltered
test and leaves every other assertion in the file green, which is the
failure mode the contract-level CURSOR_BINDINGS sweep cannot see.

* test: cover four untested behaviors and drop five tests that cannot fail

Adds coverage that goes red when the behavior is reverted:

- `rejectDuplicateQueryValues` through `parseRequest`, not just the pure
  helper — the existing blank-query tests stay green even when parseRequest
  ignores the flag entirely.
- `failUndispatchedDocumentProcessing`'s pending + not-deleted WHERE guard,
  asserted on the condition tree so removing it fails.
- The widened `present(result, request)` signature, so dropping the second
  argument stops being a silent no-op.
- The NUL scan on `readFormDataWithLimit`'s content-length branch — the
  branch every ordinary browser and curl upload takes, and the one the
  existing multipart tests never reached.

Removes tests verified incapable of failing: the credentials projection row
(the outbound `.parse()` strips unknown keys either way), the per-document
413 sweep (vacuous on two of three documents, subsumed by the sweep in
scripts/openapi/documents.test.ts), the two upload-session rows that assert
their own `generateWorkspaceFileKey` stub, the storage-key row whose 20-byte
name never reaches the budget, and the views-lock assertion against a
function `views/service.ts` does not import.

* fix(v2): parse a bound list filter once, so the scope matches the query

The logs list fingerprinted `workflowIds`, `triggers`, and `folderPaths`
through unorderedScopePart, which trims each member, then split the same raw
values itself with `.split(',').filter(Boolean)`, which does not. So
`?workflowIds=A,B` and `?workflowIds=A, B` produced one fingerprint and two
different result sets: the second selects on a member with a leading space
that matches no row. A cursor minted under one was accepted under the other,
which is the exact failure the filter binding exists to refuse.

Extracts parseUnorderedList as the single parse. unorderedScopePart now
derives from it, and the route passes the array to the query and the joined
form to the scope, so the members fingerprinted are by construction the
members filtered on. Also drops three inline splits.

Reported by Greptile.

* fix(v2): bind an AND-conjoined filter array as a set, not a sequence

The knowledge documents list fingerprinted tagFilters through canonicalJson,
which sorts object keys but preserves array order. Each filter compiles to a
condition in and(...whereConditions), and AND is commutative, so the same
clauses written in a different order select the same documents — and got a
different fingerprint, refusing a cursor for a page that was genuinely the
next one.

Adds unorderedJsonScopePart beside parseUnorderedList: members are
canonicalized, de-duplicated, and sorted, so `A AND A` binds like `A` and
clause order stops mattering. A non-array or unparseable value still binds
by its raw spelling, since that request fails validation anyway.

Replaces the route-local canonicalTagFilters, and corrects the claim on
canonicalJson that array order only ever costs a restart — for a set-valued
filter it costs a spurious 400.

Reported by Greptile.

* fix(v2): bind list filters by the value the query acts on, not its spelling

Third report of one root cause, so this fixes the cause rather than the case.
A cursor scope must fingerprint what the query filters on; every place it
fingerprinted the caller's raw text instead, two spellings of one filter got
two scopes and a valid next page got a 400.

Knowledge documents: tagFilters bound the raw query text while the route
already parsed it two lines below for the use case. The schema defaults
operator to 'eq', so {tagName,value} and {tagName,value,operator:'eq'} are
one filter to the query and were two scopes to the cursor. The scope now
binds the parser's output, which also subsumes the clause-order fix — both
route tests go red against the raw-text form.

Logs and workflow runs: startDate/endDate bound the raw text, but
z.string().datetime() admits every sub-second spelling of one instant, so
`…00Z` and `…00.000Z` name one window and got two scopes. New
instantScopePart binds the parsed instant.

Replaces unorderedJsonScopePart, which took raw text and could not see a
schema default, with unorderedScopeOf over the parsed value.

Swept all fourteen routes that build a cursor scope for the same divergence;
these were the only ones where a scope part is derived differently from the
value reaching the use case.

Reported by Greptile.

* fix(v2): bind the audit and billing window bounds by instant

The previous sweep for this defect looked for a transform in mapInput, so it
missed the two routes that pass their raw bounds to a use case that parses
them deeper. Both fingerprinted startDate/endDate as text while their
predicates convert to a Date, so `…00Z` and `…00.000Z` name one window and
got two scopes, refusing the genuine next page.

Billing keeps stamping the raw params rather than resolveDateRange's output,
for the reason already recorded there: a relative `period` resolves against
the clock, so hashing the resolved window would reject every next page.
Normalizing the explicit bounds is compatible — instantScopePart is a pure
function of the caller's own text and resolves nothing.

Re-swept all fourteen cursor-scope routes by scope part rather than by
transform site. Every temporal and structured part now binds canonically;
the rest are enums and identifiers with one spelling per value.

Reported by Greptile.

* fix(v2): drop an inert field from the document tag-filter scope

resolveKnowledgeTagFilters builds every structured filter with the stored
definition's fieldType and never reads the caller's — not for resolution, not
for validation, not in its output. Fingerprinting it made a field the query
ignores decide whether a cursor resumes, so adding or removing a matching
fieldType refused a page that had not moved.

Swept the other twelve cursor-scope routes for the same shape. No scope part
is absent from its mapInput, this was the only scope carrying a structure
resolved against stored state, and knowledge/search has no cursor at all.

Reported by Greptile.

* refactor(v2): derive the body 413 from the contract in every document

Two mechanisms encoded one rule. `withRequestBodyErrors` derived the 413 from
`route.contract.body` for the tables document, while the resources document
hand-picked RESOURCE_BODY_ERRORS / RESOURCE_CONFLICT_BODY_ERRORS at nine
sites. The cross-document sweep caught drift, but only after the fact: a new
body operation that forgot the _BODY_ variant published a reachable 413
nowhere until a test failed.

Hoists the mapper to openapi/shared.ts and applies it in both documents, so
the rule is derived rather than remembered. The two hand-picked sets and
their shared TSDoc are gone.

Regenerating all seven specs produces zero drift, which is the proof the two
mechanisms were computing the same thing.

* refactor(v2): collapse duplicated cursor and validation mechanisms, drop dead exports

One rule, one implementation:

- `parseRequest` hand-inlined the "caller envelope or default" validation-error
  projection four times. Extract `projectValidationError` and route all four
  through it.
- Nine keyset lists hand-rolled the `present` half of the cursor pair that
  `readSortedCursor` already owns the read half of. Add the symmetric
  `writeSortedCursor` and use it everywhere.
- `GET /workflows/{id}/runs` re-derived `readSortedCursor`'s invalid/refiltered
  ladder from `decodeSortedCursor`; it now calls the shared reader and keeps
  only the key-arity check that is genuinely its own.

Files and exports that no longer earn their place:

- Inline `credentials/utils.ts` into its single consumer.
- Delete symbols with zero references repo-wide: `v2CustomToolWriteError`,
  `secretCredentialTypes`, `v2CursorList`, `v2WorkspaceAccessError`,
  `resolveFolderPathIdentity`, `folderPathForId`, `v2FolderPathMutationError`,
  and seven of twelve `tables/utils.ts` exports.
- Drop `export` from symbols used only inside their own module.

No behavior change; every response body and error message is byte-identical.

* docs(v2): cut duplicated and non-load-bearing comment prose

Five rationales were written three to five times each by parallel agents
that could not see one another. Each now has one home and the rest point
at it:

- HEAD existence oracle -> the headSafe option on defineV2JsonRoute
- cursor query binding -> cursorScopeKey in lib/api/cursor-binding.ts
- storage-key prefix budget -> buildStorageKeySegment
- NUL / U+0000 -> the containsNulCharacter predicate
- blank and duplicate query values -> their own implementations

Also drops changelog-in-source (prose narrating what the code used to
do), anchorless module headers attached to no declaration, rejected-
alternative essays, and @param tags that only restate the signature.

Comments only: the diff contains no executable-code change.

* fix(v2): name the undecodable-cursor failure on the two sortless lists

GET /workflows/{id}/versions and GET /workspaces/{id}/members threw a bare
'Invalid cursor' literal where every other v2 list uses a shared constant.
The right one is UNREADABLE_CURSOR_MESSAGE, not INVALID_CURSOR_MESSAGE:
both lists take only limit and cursor, so naming sortBy/sortOrder would
answer one 400 with advice that earns a second.

Their missing filter scope is correct and stays. Neither contract accepts a
filter — v2PaginationFields is the whole query — so there is nothing to bind,
and limit is excluded from a scope by design.

Pins the message on the versions route, verified to fail against the literal.

* test(openapi): give the determinism check a chosen timeout

`serializes all documents deterministically` serializes all seven published
documents twice — roughly 2MB of JSON — under vitest's 5s default, which is
not a budget anyone picked for it. The published specs grew 3.3% on this
branch (961KB -> 993KB) from richer descriptions, which is far too small to
move a comfortable test and is enough to tip one already sitting just under
the cap. Measured at 5.1s in isolation with nothing else running.

Raises it to 30s for the openapi suite rather than trimming a real assertion.

* fix(v2): make the NUL path scan linear, and force a write surface to choose

Two findings from a simplify pass, both in code this branch added.

findNulBytePath copied `[...path, key]` per child, which is O(nodes x depth).
A caller controls that depth directly: v2 row cell values are `z.unknown()`,
so nesting passes Zod untouched and reaches the scan. Measured on Node 22 --
JSON.parse accepts a 200KB body nested 100k deep in 9.8ms, and the scan then
blocked the event loop for 27.7s. Frames now carry a parent link and the path
is materialized once, for the node actually reported: 27.7s -> 5ms, with
byte-identical paths across nested arrays, records, NUL keys and clean input.
The always-run first pass drops Object.entries for Object.keys, which halves
its cost on large bodies by not allocating a pair array per object.

`strictWrite` was optional with the lenient default, so a v2 write route added
tomorrow would silently inherit first-party behavior -- unknown column dropped
under a 201, uncoercible cell stored as null -- defended by nothing but five
copies of a literal. It is now required on the five write-shaped inputs, so
omission is a compile error. The type-checker named every caller: the five v2
routes already passed true, and the three Copilot sites now say false
explicitly, which is the behavior they already had.

* refactor(v2): apply the body-413 mapper to every OpenAPI document

The earlier unification wired withRequestBodyErrors into two of the five
content documents and left files-audit, knowledge and workflows hand-writing
the entry, so the helper's own claim that "a new body route cannot forget it"
held on 40% of the surface while reading as global.

Regenerating all seven specs produces zero drift, which is the useful proof:
the mapper agrees with every hand-written entry today, so the gap was never a
missing 413 — it was a missing guarantee for the next body route added to
those three documents.

The existing hand-written entries stay. The mapper is one-directional and
several bodyless folder reads publish 413 for the folder-tree ceiling, so
stripping them by hand would risk removing one the mapper cannot restore.

* refactor(v2): fold the v2 validation renderer into the shared parse defaults

V2_PARSE_DEFAULTS calls itself "the parse failures every v2 route renders the
same way", but the option deciding how a v2 validation failure renders sat
outside it and was re-stated at seven sites. A raw route that spread the
defaults and stopped emitted a non-v2 error envelope.

Removes the redundant line from the five sites that only restated it. The two
builders keep theirs: theirs sits after `...options.parseOptions`, so it is a
deliberate override that stops a caller swapping the v2 renderer, not a copy.

Also adopts the mandated `filterUndefined` in cursorScopeKey in place of the
Object.fromEntries/Object.entries form CLAUDE.md forbids, and collapses a
one-element `as const` array plus a Math.max over it to the single `.length`
they computed.

* test(persistence): keep the wire round trip without tripping the utils audit

check:utils forbids `JSON.parse(JSON.stringify(...))` and points at
structuredClone, which is right for a deep clone and wrong here: this test
exists to prove the schema accepts a `deployedAt` that arrived over HTTP as a
string as well as an in-process `Date`. structuredClone preserves the `Date`,
so adopting it would leave the test asserting nothing about the wire form.

Splits the serialize and the parse into two statements. The round trip stays
lossy — verified `JSON.parse(JSON.stringify(...))` yields a string where
structuredClone yields a Date — and the pattern the audit matches is gone.

Arrived from staging in #6660, so `check:audits` is red on origin/staging too,
not only here.
2026-08-13 10:52:20 -07:00
Vikhyath MondretiandClaude Opus 5 e8d278b6e4 fix(files): stop the collaborative editor rewriting and reflowing a document on open (#6652)
* fix(files): stop the collaborative editor rewriting and reflowing a document on open

Opening a file rewrote it. Binding an editor to a seeded document emits a Yjs
update of its own — ProseMirror appends an empty paragraph to any doc that does
not end in one — which the relay saw as a real edit and persisted. Every open
therefore uploaded the file under a FRESH storage key and deleted the old one,
404ing the page's own in-flight content read, bumping "Last Updated" just from
viewing, and churning a blob per open. Worse, a trailing blank line cannot
serialize, so the file never recorded that paragraph and nothing reconciled the
two: each client that seeded without seeing another's contribution stacked one
more. A real document reached 18 against the placeholder's 1 — measured as the
pane growing several hundred pixels the instant the live editor took over.

- Seed and merge through the editor's own normal form (`editorNormalForm`), so
  binding is a no-op and `canonicalizeYDoc` collapses an accumulated run back to
  one. Placed at the collab boundary, not in `parseMarkdownToDoc`: only the CRDT
  has to agree with the editor — every other consumer of the parse renders
  through a real editor that normalizes itself.
- Skip a persist whose projection already matches the durable bytes. Byte length
  is the free reject, so the compare read only happens when a no-op write is
  actually on the table.
- Revoke collaborative readiness on a fatal join. The sticky `syncedOnce` latch
  outlived the document: after a readiness timeout the provider drops `synced`
  so the gate closes, but the latch re-opened it on the offline fallback's seed
  flag — handing back an EDITABLE editor on a document the provider had
  abandoned, with client autosave gated off because collaboration is nominally
  on. Keystrokes went nowhere and vanished on reload, with no error shown.
- Recover from a superseded storage key instead of stranding the reader: a 404
  re-resolves the file record, so the read re-keys onto the current object. And
  do not focus-refetch durable bytes while the relay owns durability.
- Prefetch the workspace file list in the layout, where the sidebar already
  reads it. `HydrationBoundary` defers an already-seen query to an effect that
  SSR never runs, so a page-level prefetch of that key could not reach the
  server render — the file route rendered a spinner and disagreed with the
  client about the header's markup (a hydration mismatch).
- Load the document font with `display: block`. A swap repaints prose in
  metric-adjusted Arial first, so paragraphs re-wrap when the real face lands.

Also: a detail-route `loading.tsx` (the segment was inheriting the list chrome),
`normalize.ts` renamed to `field.ts` now that it holds only the field constant,
and the duplicate `COLLAB_DOC_FIELD` in the streaming path folded into it.

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

* fix(files): serve a whole document on join, and stop persistence deadlocking

Opening a file right after an edit replayed it — the block you had just moved
moved again, in front of you. A room rebuilds itself from the file's Redis
stream one entry at a time, into the same Y.Doc that fans every update out to
its room, and the join attached the socket before that finished and before the
server seed. So a client was never sent the document; it was sent the
document's history, and it watched that replay. The new join-readiness test
reproduces it exactly — ['AAA', 'BBBAAA'] where one state was due — and fails
without the fix.

Underneath it, persistence had deadlocked. The If-Match token is a REMEMBERED
timestamp: held in the room, which dies with it, and in a cluster key written
fire-and-forget. A relay that exits in the moments after a successful write
comes back holding a version older than the file's, every persist then fails
the CAS, and because a conflict neither writes nor advances the token, that
document can never be persisted again. Measured on a live file: token
1786594348911 against a content version of 1786594418409, with 103 unpersisted
entries still in the stream. The durable markdown froze there — and the
editor's placeholder is built from it, so every reload painted the pre-edit
document and the live one corrected it on screen.

- Assemble a room before attaching a client to it. The join awaits the stream
  catch-up and the seed, so the first sync IS the finished document, in one
  message. The seed is memoized on the room, so concurrent joins wait for the
  same one instead of the second being served an empty doc; a task that loses
  the seed lock PULLS the winner's seed from the stream rather than waiting for
  the tailer to push it, which is what left a freshly uploaded file read-only
  until its readiness deadline lapsed.
- Drop the client-side quiet-frame gate that was standing in for this. It was
  unsound in both directions: it delayed a document that was already correct,
  and it opened mid-flight anyway whenever updates arrived more than a frame
  apart, which is what a cross-region Redis and a long room history produce.
- Await the cluster version write after a successful persist. It is the only
  record that survives a teardown, and one round trip after a blob write is not
  a cost worth a wedged document.
- On a version conflict, ask the CONTENT, not the clock. If the file still
  holds the bytes this document last projected — the tag written with every
  persist — then nothing out-of-band exists to protect, so re-sync the token and
  write. Any other bytes and the conflict stands exactly as before.
- Actually run the stale-storage-key recovery. It was requested from inside the
  failing read's own queryFn, where react-query drops it, so nothing re-resolved
  the record and the reader sat on a dead key showing "Failed to load file
  content" until something unrelated refetched. It now runs off that cycle and
  cancels a read already in flight, which could only hand back the dead key.
  While the record is re-resolving the surface reports loading, not failure.
- One document per file, for its whole life. A document rebuilt from markdown is
  a DIFFERENT document to Yjs — its items carry new client ids — so a client
  still holding the old one merges the file into itself, twice, on both sides.
  The seed now stores what it builds (until that row existed, a file opened but
  never edited was rebuilt on every open) and resumes it with a CRDT diff when
  the markdown moved on out-of-band. A document also carries an identity the
  join ack names, so a tab that outlived its room is refused rather than merged.
- Let a browser keep an embedded image. The inline route sent no-cache with no
  validator, so every open re-downloaded the whole image — measured at ~1 MB per
  open of a real document, with the image area blank until it landed. A `key`
  names one storage object and a content write never rewrites one, so those
  bytes are immutable; a `fileId` names the file, whose bytes move, so that form
  still revalidates.

* fix(files): name every collaborative document, and only cache what the URL names

Two findings from review, both real.

A document stored before identities existed is returned by the seed's fast path
on every open, and that path never named one — so those files could never
acquire an identity, and the join-ack guard could never fire for them. That is
the population most likely to have a tab that outlived its room, which is the
case the guard exists for. The fast path now names an unnamed document and
stores it, once: minting without storing would name it differently on every
open and the guard would start refusing clients that hold the very same
document.

The inline route marked a response immutable whenever the caller passed a key,
but a key is resolved to a FILE and the file's current key is what gets
streamed. A content write landing between those two reads would serve the new
bytes under a URL naming the old object — and cached for a year, that is wrong
forever. The flag is now what it always meant: the URL names the exact object
that was streamed.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 22:59:19 -07:00
245ad1bd46 feat(workflows): new workflow block card, progress indicator, colors, dsl for natural language preview, retry configs (#6458)
* improvement(workflow): refine canvas interactions and rendering

* fix(workflow): keep outputs on the right, focus newly created blocks

Connection anchors: an output now always leaves a card from the right.
The cursor swell lets a drag start on any edge, but the left side is the
input, so anchoring an outgoing edge there drew a line out of the input
port and read as a second input. `normalizeCursorSourceHandleId` resolves
every drag to the right anchor, `normalizePositionedSourceHandleId`
collapses `source-left` alongside the legacy vertical anchors (so data
from the API, an older client, or a stale save self-heals on load), and
only the right-side source anchor is mounted.

Drops in `onConnectEnd` are always source -> target. The branch that
reversed the edge for a drag starting on an input could never run: the
`target` handle is `isConnectableStart={false}` and the positioned side
anchors are `isConnectable={false}`, so React Flow never reports an input
as a drag origin. Removed it and its now-unused imports.

A newly created block is centered once its node mounts and is measured,
so a card added from a drag-release, the block menu, or the toolbar is
never left off-screen or under the editor panel.

The editor panel's block icon uses the same type accent as the card's
badge instead of the block's legacy `bgColor`, which had left the panel
on the old per-integration brand colours.

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

* fix(workflow): floor header-only card height, adopt brand tag palette

The Start card intermittently collapsed after load, squashing the
action-menu tab so its icon row sat over the card.

`.workflow-drag-handle` is the host the border renderer measures, and both
it and the header row took their height from `blockHeight && blockHeight >
0`. `blockHeight` comes from the deterministic-dimensions pass and is
already floored at MIN_PAINTED_HEIGHT (48), but it is absent on the first
frames — and with no floor the host collapsed to its natural content
height (25.5px for a header-only trigger, exactly the title's line box).
The border builds its perimeter from `host.offsetHeight`, so that window
painted a sub-floor card: too little straight edge remained on the
vertical runs for the action-menu tab, which collapsed into the corner
arcs. Whether you saw it depended purely on whether the dimension publish
had landed, which is why it reproduced on one workflow and not another.

Floor all three: the host, the header row (so `items-center` centres the
title and type tag rather than pinning them to the top), and the border's
own `offsetHeight` read.

Also raise ACTION_MENU_CONTENT_READY_THRESHOLD to 0.9. At 0.8 the 24px
icon row was revealed while the swell had only reached 22.4px of its 28px
— shorter than the row it contains. Secondary to the above, but a real
overflow window on its own. The test now pins the ratio rather than the
constant.

Tag palette moves to fixed brand values (hex, not derived oklch) with two
inks — #F8F8F8 on dark fills, #1A1A1A on light. Tones are renamed to match
what they render. `green` (2.55:1) and `orange` (3.15:1) sit under WCAG AA
against their paired ink; both are deliberate brand decisions and are
documented in the component.

Deploy and Run take two new Button variants rather than className
overrides, so `tertiary` stays green everywhere else.

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

* improvement(workflow): polish workflow canvas interactions

* fix(workflow): restyle loop drop target outline

* fix(workflow): shorten human block catalog label

* fix(workflow): canonicalize realtime edge handles

* code cleanup

* sizing fixes

* improvement(blocks): sentencify every block

* change tebse

* improvement(workflow): land notes UI and execution progress, consolidate duplicates

Ports the notes canvas editing and execution progress-indicator work, then
removes the parallel paths it arrived with so each concern has one owner.

Fixes found while consolidating:

- Note height was measured while the card was expanded to NOTE_EXPANDED_WIDTH,
  where text re-wraps shorter, and published as the node's compact height. The
  collapse then animated to a height never measured at the compact width.
- The edge pulse glow filter used the default objectBoundingBox units, so a
  straight horizontal edge — what an auto-laid-out chain produces — resolved the
  filter region to zero height and stopped the glow rendering entirely.
- A subflow's inner Start pill still read isNodeSelected while its border read
  usesSelectedVisuals, so the two disagreed during execution.
- The Run/Stop button's disabled prop gated only Run while its handler cancelled
  unconditionally, offering a Stop the cancel route answers with 403.

Consolidated:

- One note editor. The view's built-in textarea was unreachable in production
  (the app always injects the markdown editor) and was kept alive only by tests
  asserting against it; renderContentEditor is now required.
- onBlur/onCancel collapse to onEndEditing — content persists per keystroke, so
  there was never a draft for a cancel path to discard.
- DEFAULT_NOTE_COLOR, the note height bounds, the note content reader and the
  card width each had two or three definitions; each now has one.
- Removed with zero consumers: graphite/graphiteSubtle button variants,
  data-subflow-selected, inputPlaceholderClassName, an effect that could never
  fire, and getNoteColorOption's unreachable fallbacks.

Restores the role='status' announcement the progress rewrite dropped, and hardens
isNoteColor against inherited Object keys.

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

* improvement(workflow): reuse the platform markdown editor in notes

Notes carried their own TipTap wiring — a second markdown editor that
reimplemented, more thinly, what `RichMarkdownField` already does for the skill
modal, skill fields and the deploy version description. It is now a ~20 line
skin: the Note supplies its type scale and per-colour selection tint, and the
field supplies the extension set, frontmatter held out-of-band, the round-trip
safety gate and its raw-source fallback, and markdown paste.

`RichMarkdownField` gains two additive props, both defaulting to today's
behaviour so the file editor is untouched: `surface` ('field' | 'bare') and
`proseClassName`. All three existing consumers pass an explicit `minHeight` and
no `surface`, so they take the original path unchanged.

Exiting the note editor moved to the card, because the editor's `/` and `@`
menus consume Escape to close themselves and ProseMirror checks `editorProps`
before plugin handlers — intercepting it inside the editor would have broken
both menus. The card now honours Escape only when nothing already consumed it,
which also let `onEndEditing` leave the injection contract.

The note editor is lazy now, matching every other consumer: it was pulling
TipTap and the full extension set into the canvas's initial chunk.

Also:

- One `areRunFromBlockDependenciesSatisfied`. The ActionBar, the canvas context
  menu and the run-from-block handler each carried a byte-identical copy, and
  the handler expressed the snapshot requirement differently, so the affordance
  and the action could disagree. Each copy also re-scanned `edges` once per
  incoming edge, on every ActionBar on the canvas.
- Reduced motion is one `usePrefersReducedMotion` in @sim/emcn rather than a
  sixth ad-hoc `matchMedia`. The edge pulse now stops rendering instead of
  hiding: `motion-reduce:hidden` is `display: none`, which left four SMIL
  timelines running per edge.
- The pulse glow bleed covers the canvas minimum zoom. The strokes are
  `non-scaling-stroke`, so the 6px tail spans 3/zoom user units — 30 at 0.1.

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

* improvement(workflow): port canvas styling from workflow-updates

Ports the 14 styling commits your colleague added since the last sync, leaving
the ~68 staging PRs on that branch alone — those are platform/core work, not
this. Cherry-picked individually rather than merged so each conflict was small
enough to reason about.

What came in:

- Core block colors unify behind a two-level map: block type -> semantic role
  -> accent, replacing the flat per-type table. Adds `purple` and `content`
  tones to ChipTag, and a shared `WorkflowTypeIcon` that replaces the
  hand-rolled ChipTag + accent lookup at each discovery surface.
- Native triggers take semantic colors; the deployments block moves to the
  shared Rocket icon and drops its now-unused `iconColor`.
- Running-state polish: loader artwork and position, stop hover in dark mode,
  the loader blended into the execution swell, and tooltips suppressed for
  actions that are hidden mid-run.
- The toolbar drag preview clones the rendered icon container instead of
  rebuilding a bgColor tile, so it matches what the canvas paints.
- The sidebar shows route-derived workspace identity instead of a skeleton
  while the full record loads.

Conflict resolutions worth knowing:

- The running-loader artwork went through the shared `Loader` and back to the
  custom SVG on their branch; the second commit is the intent, so that is what
  landed — keeping our `role='status'` announcement layered on top.
- Two commits carried the lucide-react -> in-house icon migration along with
  them. That migration is a staging change we have not taken, so our imports
  stayed on lucide: adopting it in two files would leave the icon set split
  across the app.
- `getMappedWorkflowTypeAccent` referenced a constant their refactor removed.
  It had no consumers left once the search modal moved to `WorkflowTypeIcon`,
  and their branch deletes it too, so it is gone here.

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

* feat(workflow): make the subflow Start swell a real connection source

Dragging an edge into whitespace opens the add-block picker, but starting that
drag from a loop/parallel Start pill did nothing: the pill's border swell was
visual-only. Regular blocks and the container's own exit mint a draggable
cursor handle from their swell; the pill rendered only its invisible 14px
static strip, so grabbing the glowing affordance started no connection at all.

Everything downstream already worked and was nearly unreachable:
- the drop hit-test skips subflow containers, so a release inside the loop
  opens the picker there (z 2000, above containers)
- handleToolbarDrop parents the new block into the container at the drop point
- it already carries the exact boundary rule for this source: a container
  start handle only wires to a child of that container

The pill now runs the same cursor-handle machinery as the container view, with
one deliberate difference: its temporary handle carries the branch-cursor form
of the start id. The plain cursor id normalizes by block type — for a
container that is `loop-end-source`/`parallel-end-source`, the exit — so a
swell drag from Start would have persisted as an edge leaving the container.
The branch form passes `loop-start-source`/`parallel-start-source` through
normalization verbatim on both the picker and direct-connect paths; a test
pins that contract.

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

* fix(notes): stop the field's prose classes recoloring bare-surface editing

Opening a note for editing shifted the text and turned it black: the
ProseMirror root unconditionally carried `rich-markdown-prose
rich-markdown-field-prose`, which pin the field's own ink and type ramp —
`--text-primary` at 15px/25px, then 14px/22px — overriding the card's
`text-current` at 14px/20px the moment the editor mounted.

`surface='bare'` means the host owns typography (the Note card mirrors its
rendered view via `proseClassName`), so on that surface the root now carries no
shared prose classes. The field surface is untouched. Edit mode inherits the
note colour's ink — including the caret — and sits on the same metrics as the
read view.

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

* fix(workflow): give the in-flight connection line contrast inside containers

The drag line was drawn but camouflaged: its default stroke was the
resting-edge grey (#e0e0e0), which disappears against a loop body's opaque
`--surface-3` fill (~1.1:1) — so dragging an edge inside any container, nested
included, showed nothing. The z-order was never the problem; the connection
line layer already sits above every node.

The default token is now `--text-muted`, one value with contrast on every
canvas surface, still lighter than the `selected` variant so the variant
hierarchy holds. No per-surface special-casing.

Resting edges inside containers share the same camouflage (`--workflow-edge` on
`--surface-3`) — left alone deliberately: recoloring placed edges is a design
decision, not a bug fix.

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

* fix(notes): match edit mode to the read view, and land the caret where clicked

Three defects, all from the read and edit views being built independently.

1. Blocks jumped up ~12px on entering edit mode. Streamdown wraps its output in
   a container carrying `space-y-4` plus first/last margin resets, which outrank
   the per-element margins in NOTE_COMPONENTS — so that wrapper, not those
   margins, is what the read view actually paints. The editor had no equivalent.
   The rhythm is now named (NOTE_MARKDOWN_FLOW), passed to Streamdown
   explicitly so a dependency upgrade cannot move the read view out from under
   the editor, and mirrored on the ProseMirror root. Tailwind's JIT only sees
   literal strings so the mirror cannot be composed from the constant; a test
   pins the two together instead, and fails if either side drifts.

2. The caret was barely visible: it inherited the note's 75%-opacity ink. The
   palette owns per-colour chrome, so it now names the caret alongside the
   selection tint.

3. The caret always landed at the document end. The read view sits under a
   full-bleed overlay that must swallow the click to enter editing, so the
   point never reached the editor and `autofocus: 'end'` was all that was left.
   The view now forwards that point and the field resolves it through
   `posAtCoords` on create — after the DOM is laid out, which `autofocus`
   cannot wait for. Keyboard activation carries no point and still lands at the
   end.

`autoFocusAt` is additive on the shared field and defaults to null, so the file
editor and the other three consumers are unchanged.

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

* fix(workflow): stop edges rendering behind top-level subflows

Containers are z-indexed by nesting depth, so a top-level subflow is 0. Edges
derived their z from their parent container — `+1`, or 0 with no parent — so a
root-level edge landed on exactly the same z as a root-level subflow. Equal
z-index falls back to DOM order, and React Flow paints the nodes layer after
the edges layer, so the container's opaque body won: any edge crossing a
top-level loop or parallel was drawn behind it, in-flight or persisted.

Edges now sit in their own band above the whole container scale and below
cards, keeping both the deeper-container-wins ordering and the rule that a line
always passes behind card chrome. This is why the edge became visible only once
a block was dropped: the new block is selected, and an edge inside the
container was already `containerZ + 1`, clear of the tie.

The in-flight connection line is declared in the same scale rather than
inheriting React Flow's stylesheet default of 1001, which is both below a
selected container child and outside the scale this file owns. Its stroke moves
to `--text-secondary`, the token the canvas already uses for an active edge —
the previous `--workflow-edge` grey is ~1.1:1 against a subflow body.

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

* feat(notes): paste and drop images through the workspace-file pipeline

Traced the file editor's image path end to end and reused it verbatim rather
than minting a note-specific source: insertImages -> useUploadWorkspaceFile ->
POST /api/workspaces/{id}/files/presigned -> direct-to-S3 PUT -> workspace_files
row -> the editor persists the workspace-scoped
/api/workspaces/{id}/files/inline URL, which the serve route authorizes by
workspace membership and the embedded-image-ref machinery already recognizes
for share rewriting and referenced-by-doc tracking.

The shared field gains an optional `uploadImage(file) -> {url, alt} | null`
capability. With it, image paste/drop uploads sequentially and inserts each at
the evolving position, mirroring the file editor's flow, with a bail if the
editor unmounts mid-upload; without it, the existing swallow-guard on file
drops is unchanged, so the skill modal, skill fields and version-description
consumers behave exactly as before. The upload mutation owns its own toasts.

The note host wires the capability with folderId null, so note images land in
the workspace Files root — visible, manageable and deletable there like any
other upload. The note read view renders images through its Streamdown
components map with the card's own sizing.

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

* improvement for notes, subflows

* fix(uploads): surface the server's message when a multipart upload is refused

A file over the 50MB direct-PUT threshold goes through multipart initiate, which
is where the storage quota is enforced — but the client threw away the response
body and reported `Failed to initiate multipart upload: Payload Too Large`. That
is the string the upload mutation puts in its toast, and it names neither which
limit was hit nor by how much, so the one place that answer surfaces didn't have
it.

It now prefers `errorBody.error` exactly as `getPresignedUploadInfo` already does
on the single-PUT path, and passes the body through as the error's details.
Control flow is unchanged: still throws, still `MULTIPART_ERROR`, and the
cloud-storage-absent branch above still claims its 400 first.

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

* fix(notes): restore GFM in the note read view

Streamdown's `remarkPlugins` prop REPLACES its default plugin list rather than
extending it, and remark-gfm is one of those defaults. The note passed
`[remarkBreaks]` — so the read view silently lost every GFM construct: task
lists, tables, strikethrough and autolinks.

The editor writes all of them (it has TaskList, TableKit and Strike), so a note
round-tripped through editing came back as raw source the moment editing closed:
`- [x] HELLO` rendered as a disc bullet followed by the literal text `[x] HELLO`.
`NOTE_COMPONENTS` has carried table/thead/tbody/tr/th/td entries this whole time
that could never fire.

Restoring the plugin is only half of it: remark-gfm marks a checklist
`contains-task-list` and emits a native checkbox, which under the note's generic
`ul` styling renders a checkbox sitting behind a disc bullet — the same defect
the editor had before the chrome/typography split. The read view now drops the
marker and indent for a task list, lays the row out as a flex line, and styles
the checkbox to match `.rich-markdown-nodes input[type="checkbox"]` declaration
for declaration, tick clip-path included, so the two views agree either side of a
click.

`remark-gfm` is now a declared dependency of the renderer package rather than one
borrowed transitively from streamdown.

Five tests cover the GFM surface — checked/unchecked boxes, no literal `[x]`, the
marker only dropped for checklists, tables, strikethrough — and four go red with
the plugin removed.

Checked the other three Streamdown call sites (Chat, the chat interface renderer,
the changelog): none override `remarkPlugins`, so none were affected.

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

* refactor(canvas): drop dead markers and share the tile-brightness maths

Review pass over the branch against staging.

Dead code removed:
- `tileIconColorClass` in the renderer package — never called; only its
  `isLightTileColor` sibling is.
- `data-connection-selector-search-frost`, `data-workflow-cursor-edge` and
  `data-workflow-cursor-source-side` — written on three elements, read by no
  stylesheet, selector or test.
- `CHIP_TARGET_SELECTOR_TYPES`, `MAX_CHIPS` and `chipPriority` were exported from
  `canvas-rows.ts` but only used inside it.

Consolidated the one real divergence: the renderer package carried a hand-copied
mirror of the app's perceived-brightness maths, because it may not import app
code. The copy had already drifted — it dropped the `white`/`black` keyword
handling, so a block shipping `bgColor: 'white'` would render a white
`currentColor` icon on a white tile on the canvas while every other surface drew
it black. No block ships one today, which is exactly why nothing caught it. The
function now lives in `@sim/utils/color` and both sides import it; only the
0.75 threshold stays local to each.

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

* refactor(canvas): share the z-scale, fix the preview's edge layering, and re-home strays

The preview canvas carried its own z numbers and had the collision the editor
canvas was fixed for: containers at nesting depth, top-level cards at an implicit
0, and edges at 0/5/10 by execution status — so a default edge tied with a
top-level subflow and painted behind it, while a success edge painted over
unselected cards.

The scale now lives once, in `@sim/workflow-renderer/canvas-layers`, and both
canvases read it. The preview keeps its status ordering, expressed inside the
shared edge band rather than as a second set of magic numbers.

Placement and duplication:

- `perceivedBrightness` moved to `@sim/utils/color`, with its unit test, and its
  consumers import it directly. It had been re-exported through
  `lib/colors/brightness.ts`, and the renderer package kept a hand-copy.
- `filterAcyclicEdges`/`wouldCreateCycle` were pass-through wrappers in the
  workflow store's utils over the real implementations in `@sim/workflow-types`.
  Deleted; the three consumers import the source.
- `lib/ui/glass-surface.ts` was a one-constant, one-consumer app-wide module, and
  its consumer then aliased it a second time. Collapsed into the navbar shell.
- `nested-subflow-node` was set on nested container nodes in both canvases with no
  stylesheet, selector or test behind it.

`packages/workflow-renderer` now has its own vitest config, so the four mount
tests for its components live with the components instead of in
`apps/sim/lib/workflows/**`. That immediately earned its keep: `apps/sim`
excludes test files from type-check, and once these were checked, tsc found three
`SubflowNodeView` renders being handed a `renderContentEditor` prop it does not
accept — a copy-paste from the note cases that had been silently ignored.

Verified: type-check 23/23, 21,143 app tests + 49 renderer + 147 utils, biome
clean, all 23 audits pass (`check:bare-icons` imported the moved helper and was
repointed).

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

* fix field noun bug + notes

* fix(notes): let a note service the canvas actions the panel editor cannot

The panel editor clears any note put in front of it and renders nothing, but the
block menu still routed Rename and Open Editor through it.

Rename latched the editor's rename state onto the note — `handleStartRename`
reads the store directly, so it saw the id the menu had just set — and nothing
reset it when the clear ran. `handleSaveRename` writes to `renamingBlockIdRef`,
so the header went on showing a rename field over whatever was selected next and
saved that name to the note. Open Editor was a plain no-op that opened an empty
pane.

Rename now goes to the card, which expands and opens its own title — the same
menu-to-card routing Add Image already used, so both events now live in one
`lib/workflows/notes/canvas-requests.ts` and `add-image.ts` keeps only its
markdown concern. Open Editor is hidden for notes.

The panel editor also drops any rename whose block stops being the selected one.
That is belt-and-braces for notes now, but it closes the same hole for ordinary
blocks, where only the input's blur ended a rename and blur only fires if it held
focus. A rename interrupted that way is now discarded rather than left pending.

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

* feat(canvas): author sentences for Snowflake and Mintlify, repoint Instagram's

Staging's two new integrations shipped without a `canvasPresentation`, so their
39 operations painted the field rows the rest of the canvas has stopped using.

The Instagram break is the more interesting one: staging renamed the insight
metrics subblock `metrics` -> `insightMetrics` while this branch was adding
sentences that named `metrics`. Both hunks merged cleanly — the union check
reports the file as an exact union — and the result was two clauses pointing at
a field that no longer exists, which resolves to nothing with no error and no
log. Only `check:canvas-sentences` sees it.

Two Snowflake sentences say something the block does not do, so they anchor
elsewhere: `taskName` filters `list_task_runs`/`get_task_run` rather than keying
them, and `table` filters `introspect_schema` — blank means "every one", not "not
filled in yet", and a core chip would have claimed otherwise.

Coverage is back to 4727/4727 operations across 321/321 blocks.

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

* fix(canvas): keep the block-type tag naming its type after a rename

The header tag dropped its label whenever the block's title already said the
same word, so the same block read two ways depending on nothing the user did
deliberately: a freshly dropped Wait showed a bare icon, and its second copy —
auto-named "Wait 2" — showed "Wait". The tag looked like a badge that appeared
on rename rather than a fixed part of the header.

It now always names the type, which is what loop and parallel containers already
do with their own tag, so every card on the canvas reads the same way.

`blockName` was only ever read for that comparison, so the prop is gone rather
than left behind for a future reader to wonder about.

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

* fix(deploy): compare edge handles by port, not by spelling

Two places answer "does this need redeploying?" and they load their sides
differently. The client diffs the live store against `/api/workflows/[id]/deployed`;
the server diffs the normalized tables against the version's raw jsonb. Only
some of those paths run handles through `loadWorkflowFromNormalizedTables`, so a
snapshot holding a side-anchored id (`source-right`) met a canonical one
(`source`) on the other side and the set comparison read it as every edge being
removed and re-added.

Each answer therefore differed, and they arrive on separate query timelines: the
button reads the client's, the modal badge reads the server's, so the state
flipped between Live and "Update deployment" with whichever query landed last
until both settled.

`normalizeEdge` now canonicalizes both handles, so the comparison cannot tell
two spellings of one port apart no matter how its inputs were loaded. The
existing normalization in `materializeDeploymentState` stays — that path also
feeds React Flow, which needs the handle it mounts to match.

The preview's error port had the mirror problem: it rendered for every
non-trigger block regardless of `errorEnabled`, so a card with no error row grew
a red knob anyway. It now gates the way the editor canvas does, keeping the port
mounted when an error edge already leaves it so React Flow cannot drop that edge.

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

* fix(deploy): stop counting the error flag twice in change detection

`errorEnabled` has two homes. It persists inside the block's `data` jsonb — the
realtime server `jsonb_set`s it there, and load mirrors it back onto the block as
a field — so it reached the diff twice, and only some paths populate the copy.

`setBlockErrorEnabled` writes the mirror alone, so right after toggling the port
the live block said `errorEnabled: true` with `data.errorEnabled: false`, while
the snapshot the deploy had just taken from the tables said true in both. The
diff read the stale `data` and reported the workflow as changed the instant it
finished deploying — then a state refetch rehydrated the block and it agreed
again. That is the flip between Live and "Update deployment": the button and the
modal read two different queries, so each landing swapped the answer. A block
created in-session had the same shape from the other side, its `data` carrying no
key at all against a persisted `false`.

Excluded from `normalizeBlockData` alongside the other fields that are duplicated
out of the block's own state. The block field is still compared on its own, with
`!!`, so absent and `false` agree and turning the flag on is still a change.

Fixing the store to write both homes was the other option and is not taken:
nothing reads the in-memory `data.errorEnabled` (save and load both let the block
field win), so it would add a second copy that only the diff could see — which is
the shape of this bug, not its fix.

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

* refactor(blocks): give the error-output flag a column instead of two homes

`errorEnabled` had no column, so it persisted inside the block's `data` jsonb
and was mirrored onto the block as a field on load. Every writer had to route
its `data` through `withPersistedErrorEnabled` or silently drop the toggle, the
realtime op `jsonb_set`, and change detection saw the same value twice — which
is what made the deploy badge flip between Live and "Update deployment" after
toggling the port.

Its siblings — `enabled`, `horizontal_handles`, `advanced_mode`, `trigger_mode`,
`locked` — are all boolean columns; `data` is for React Flow and subflow state.
The flag belongs with them, so it now has `error_enabled` and one home. The
shuttle helper, its `BlockData` mirror, the store's fallback read, and the
comparison exclusion the duplication forced are all gone.

Backwards compatibility, since released versions draw the error port with no
toggle in front of it: a block already wired to an error edge HAS the output on,
because there was no other way to draw that edge. That rule is now stated in
three places and none may be narrowed to read the flag alone —

- the migration backfills `error_enabled` from the edges, so live rows are true
  before any new code reads them;
- `materializeDeploymentState` derives it for a version's frozen jsonb, which the
  migration cannot reach — otherwise every workflow deployed before the toggle
  would ask to be redeployed once;
- `workflow-block.tsx` keeps it at render time for states that reach the canvas
  through neither (imports, copilot edits), where unmounting the port would make
  React Flow drop the edge leaving it.

The migration also moves any `data.errorEnabled` a developer created on this
branch onto the column and strips the key; both statements match zero rows in
production, where it never shipped.

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

* fix(canvas): realign the Snowflake and Dynatrace sentences with staging's blocks

Both breaks are the class the union check cannot see: separate hunks of the same
file merged cleanly, and the result names fields that no longer exist. A sentence
that does resolves to nothing, with no throw and no log.

Snowflake's rewrite (#6474) moved database, schema, table, warehouse and
procedure onto canonical selector pairs, so seven clauses anchored on ids that
are gone. Each now names both members of its pair, which is also what keeps the
card readable for someone working in advanced mode. Its nine new operations have
sentences.

Dynatrace (#6463) scoped the mute reason to the operations that mute, because
unmuting accepts exactly one — so the two unmute sentences were asking for a
field their card no longer shows. They drop the clause.

Coverage is 4736/4736 operations across 321/321 blocks.

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

* fix(api): drop the block-data error flag from the workflow contract

Left behind by the consolidation: the flag no longer lives in `data`, and a
schema that still declares it there invites the mirror back through the wire.

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

* fix(deploy): compare edge handles by port, so a falsy one cannot read as changed

`loadWorkflowFromNormalizedTables` now runs handles through the canonicalizer,
which falsy-coalesces — so an edge persisted with `sourceHandle: ''` loads as no
handle at all. The server diffs that against the deployment version's raw jsonb,
which still has `''`, and the set comparison reads one edge as removed and
another added. Every workflow holding such an edge would ask to be redeployed
the moment this ships, for nothing. Two write paths use `?? null` rather than
`|| null`, so `''` is reachable.

Canonicalized inside `normalizeEdge` rather than at either call site: the two
sides are loaded by different paths and only some of them normalize, so the
comparison has to be unable to tell two spellings of one port apart however its
inputs arrived.

This is the change reverted in 066e18ac28. That revert reasoned only about
side-anchored ids, which are genuinely unreachable — it missed that the same
coalesce collapses the empty string, which is not.

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

* fix(deploy): keep ignoring the error flag's old home in block data

Deploying never converged: the badge asked to redeploy again the moment it
finished, forever.

The flag lived in `data` before it had a column. `0287` moves it, but a migration
only reaches the live tables — every deployment version already written is frozen
jsonb that keeps the old key. The wire schema no longer declares it either, so Zod
strips it from the live state on its way to the client. So the two sides of the
check genuinely differ: live `data: {}` against a snapshot's
`data: {errorEnabled: false}`, reported as `data.errorEnabled` changed. Deploying
cannot fix that — the next snapshot is taken from rows that still carry the key.

Confirmed against a real stuck workflow: 18 versions, both blocks reporting
`data.errorEnabled`, and the same two states comparing equal with this restored.

Removing the exclusion in 7934df7f88 assumed the migration could reach every copy
of the value. It cannot reach a frozen snapshot, so the comparison has to keep
tolerating the old key regardless of where it survives. The block field is still
compared on its own, with `!!`, so the flag itself is not ignored.

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

* revert(deploy): stop tolerating a block-data shape that never shipped

The flag's stint inside `data` began and ended on this branch: `main` and
`staging` have zero mentions of `errorEnabled`, and `ci.yml` gates every deploy
job on a push to main/staging/dev, so opening a PR deploys nothing. No released
version ever wrote the key, which means no production row and no production
deployment snapshot can hold one.

That makes the exclusion permanent code apologizing for a shape that cannot
reach the database it defends. Migration 0287 already strips the key from the
live tables, which is where a one-time data fix belongs; in production it matches
zero rows, and on a developer database it makes the next deploy write a clean
snapshot.

Reverts 5ece9f9e7e. That fix was correct about the mechanism and wrong about the
scope: it read a local database as evidence about production.

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

* improvement(canvas): retract bystander cards during a run, slant the sweep mark

Two things about a running canvas.

A run pinned every card's action bar open and suspended every card's hover on
top of it, so the canvas became a wall of open swells that could neither retract
nor respond to a pointer — and the `isWorkflowRunning && !isRunning` hover
treatment already written for those cards was unreachable. Only the card that is
actually running is pinned now; the rest behave as they do at rest, which is what
makes hovering one bring its bar up again.

The sweep's filled slot painted a full 24px square. It now paints a slanted band
across the slot, as a hard-stop gradient rather than a `clip-path` — the two end
slots already carry one for the swell silhouette and a second would have to win a
specificity race with it. The stops hold `--surface-2` exactly, so only the shape
changes. Each variant is spelled out because Tailwind's JIT reads literal class
strings and a composed `hover-hover:${FILL}` compiles to nothing.

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

* improvement(canvas): fill the running sweep one way, and tighten its mark

The sweep drained back to empty after each pass, which reads as undoing the
progress the block is making. It fills left to right and starts over. The
direction flag goes with it — the state is just the count now.

The slanted mark also sat too far off its neighbours. Slant and tightness trade
against each other here: the transparent wedge has to be at least as wide as the
edge's horizontal travel, or the cut clips a corner instead of crossing the slot.
Leaning 7° off vertical instead of 17° travels 2.9px across the 24px slot rather
than 7.3px, which brings the wedge in from 26% to 12% — 3.2px a side against
7.8px, so the gap between marks drops from ~17.6px to ~8.4px with the slant
still crossing cleanly.

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

* improvement(canvas): fill the running bar once, edge to edge

The sweep restarted from empty every time it filled, so the bar kept re-running
ground it had already covered. It fills left to right once and holds.

The mark also sat inset in its slot, which put a gap on both sides of every join
and made the row read as separate chunks instead of one bar. It now spans its
slot edge to edge, leaving only the row's own `gap-[2px]` between marks, and
takes its weight off vertically instead: `bg-clip-content` with symmetric padding
paints a 10px band inside the 24px slot without changing the slot's size, so the
swell measured around it does not move. `--surface-2` is untouched.

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

* improvement(canvas): paint the running bar as right-leaning uprights

The fill read as a row of horizontal slabs. Each filled slot now paints one
narrow upright bar leaning right, so a run fills as `/ / / /`.

Geometry, since the two constraints fight: leaning the edge 15° off vertical
carries it 4.3px across the bar's 16px height, so the transparent margin has to
stay above 15% or the cut clips a corner instead of crossing top to bottom.
38%/62% leaves a 7px bar with room to spare. Height comes from `bg-clip-content`
plus symmetric padding, which does not change the slot's own size, so the swell
measured around it stays put. `--surface-2` is untouched.

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

* improvement(canvas): pitch the running hatch to the row, not to one bar a slot

One bar per 24px button left the rest of the button empty, so the marks
inherited the button grid's rhythm and sat ~19px apart — a row of isolated ticks
rather than a loader.

The fill repeats now, at a pitch that divides the row's own rhythm: a slot plus
its `gap-[2px]` is 26px, so a 13px horizontal pitch puts exactly two bars in
every slot and stays in phase across the gaps, including the 40px end slots.
Bars land every 13px with a uniform 6px between them, whatever the run's length.

Stops are measured along the 105° axis rather than horizontally, so they carry
the `sin(105°)` factor: a 7px bar on a 13px pitch is 6.76px on a 12.56px period.

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

* fix(canvas): paint the running hatch once across the row, not per slot

The bars came out bunched in some places and spread in others. Per-slot
backgrounds cannot avoid that: each button starts its own gradient at its own
origin, so the phase resets at every slot — and the end slots are 40px against
the others' 24px, so the resets are not even uniform. Three passes of tuning the
stops were all chasing a constraint the approach could not satisfy.

The hatch is now one element spanning the row, so there is one gradient and one
phase. It sits behind the buttons and grows by width: the run/stop button keeps
an opaque fill while running and masks the part growing underneath it, and every
other slot is transparent mid-sweep so the hatch reads through. The slots no
longer paint anything themselves, and the per-slot filled flag goes with them.

`--surface-2` is unchanged; only where it is painted moved.

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

* improvement(canvas): close the running hatch's gaps

The hatch ran a 50/50 duty cycle — 6px of fill to 6px of air — which read as
sparse. It now runs 8px to 3px.

Both stops are measured along the 105° axis rather than horizontally, so each
carries a `sin(105°)` factor; the note records that, and that closing the gap
further is a matter of moving the first stop toward the second.

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

* fix(emcn): drop the brand highlight from popover menus (#6506)

Context menus opted into a palette of their own — `variant='secondary'` for a
brand-blue row highlight and `colorScheme='inverted'` for a dark card — so the
canvas, block, toolbar, terminal, sidebar, and preview menus looked nothing like
the menus everywhere else in the product.

Removes both overrides so they inherit the same surface, border, and
`--surface-active` highlight the terminal's overflow menu already uses, and drops
the brand state from the Popover itself along with the `variant` prop that only
ever selected it. One fewer way to style a menu.

* feat(executor): opt-in per-block retry (#6505)

* feat(executor): opt-in per-block retry

Adds a per-block retry policy, off by default, surfaced in the editor's
additional-fields disclosure alongside the block's other advanced settings.

A block that opts in replays its handler while tries remain, then rethrows the
final error so the error port behaves exactly as it does for a block that never
retried — retrying only delays the existing outcome, never changes it. Retry is
deliberately indiscriminate about the failure, since there is no reliable way to
tell a transient error from a permanent one and classifying would silently do
nothing for the generic errors people turn it on for. Only throws that are not
failures are excluded: a deliberate stop, a child workflow whose own blocks
already ran their policies, and the block types whose throw is control flow
(human-in-the-loop, sentinels, subflow containers, notes, triggers).

Eligibility lives in one predicate read by both the editor and the executor, so
a block can never keep retrying after an edit that hides its control.

`retry` is a nullable jsonb column; NULL means "runs once", which is how every
existing block already behaves, so the change is inert until someone opts in.
Bounds are clamped on read rather than rejected, so a value written before a
bound moved still resolves to something runnable.

Also decouples the additional-fields disclosure from `block.advancedMode`. That
flag decides which member of a canonical pair serializes, so opening the
disclosure used to be able to drop a block's configured credential. Expansion is
now view state; the stored flag is no longer written by the editor.

Retried blocks report their try count on the trace span, shown in log details.

* fix(realtime): allow the write role to persist a block retry policy

`update-retry` was added to the protocol but not to the write-role allowlist, so
the editor applied the change optimistically while the server dropped it and the
policy never reached the database.

Adds a test asserting the write role holds every per-block operation the protocol
declares, so the next block setting cannot repeat this silently.

* fix(editor): keep a retry number field's value when it is blurred untouched

Committing on blur normalized the draft unconditionally, and an untouched field's
draft is null — which normalizes to the default. Focusing and leaving Max tries
silently reset a configured 5 back to 3.

* improvement(canvas): close the running fill to solid, slant its leading edge

Gaps gone entirely: the bar is one solid fill now.

The slant moves onto the growing edge, because a repeat with its gaps closed has
no edges left to show. 4px of run across the 16px height is the same 15° lean the
bars carried, so the fill still leans right — it just leans at its front instead
of throughout.

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

* improvement(canvas): return the running fill to the squares' rhythm, sheared

Marks are 24px with the row's 2px gap after them again — the geometry the slots
carried before any of this — so they land where the squares did. The shear is
the only thing that is new.

It stays on the single spanning element rather than going back to per-slot
backgrounds: one gradient means one phase, which is what lets the 24/2 rhythm
hold across the 40px end slots instead of resetting at every boundary.

Stops are measured along the 105° axis rather than horizontally, so both carry a
`sin(105°)` factor: 24px of mark is 23.18px of stop, and the 26px pitch is
25.11px of period. Writing 24/26 directly renders ~3.5% wide and drifts out of
the squares' rhythm across the row.

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

* fix(canvas): trim the running fill to the swell's tapered end

The fill ran off the block. The row is a rectangle but the swell is not — its
last slot cuts a diagonal so the shape narrows toward the top, and a rectangular
overlay therefore painted past the gray edge up there while still sitting inside
it at the bottom. The per-slot version never showed this because each button's
own clip contained its fill; moving the paint onto one spanning element took that
containment away with it.

The overlay now carries the same taper, read off that slot's own path: 16.67px in
from the row's right at the overlay's top, 3.33px at its bottom, a slope of 20/24.
Only applied to the swell variant, which is the shape that tapers.

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

* fix(canvas): stop the handoff highlight pinning a bystander's toolbar open

Dropping `isWorkflowRunning` from `forceOpen` was not enough: it also read
`usesSelectedVisuals`, which is `isNodeSelected || isExecutionHighlighted`, and
the handoff highlight covers the block feeding the running one. So the upstream
card kept its bar down for the whole run — the wall of open swells this was
supposed to end, one card smaller.

Those are two different questions. `usesSelectedVisuals` still drives the
TREATMENT — the graphite silhouette and `data-node-selected`, so the eye can
follow the baton — while whether the toolbar is pinned open now keys off
selection alone.

The container keeps `isRunning` by itself. Selection was never a pin there, and
its own tests hold it to opening on hover.

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

* fix(realtime): refresh the error flag on a block upsert

`BATCH_ADD_BLOCKS` wrote `errorEnabled` on insert but left it out of the conflict
clause, so re-adding an existing block id kept whatever the row already held
while every sibling flag — enabled, advancedMode, triggerMode, retry, locked —
was refreshed from `excluded`. The client's value was silently discarded and the
old error-output state came back on the next load.

Mine: the insert side gained the field when the column landed and the conflict
set did not.

The other two block writers delete before inserting, so no stale row survives
them; this upsert was the only path that merged into one.

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

* update loader animation

* change the loader

---------

Co-authored-by: andresdjasso <andresdjasso@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Waleed <walif6@gmail.com>
2026-08-10 19:21:32 -07:00
Waleed e5d2f7d80a fix(realtime): stop read-only members persisting block positions (#6174)
* fix(realtime): stop read-only members persisting block positions

The socket operation ACL granted the `read` role block.updatePosition and
blocks.batchUpdatePositions on the premise that they were ephemeral cursor
sync. They are not: both are followed by persistWorkflowOperation, which
UPDATEs workflow_blocks.positionX/positionY and bumps workflow.updatedAt. A
member holding only `read` on a workspace could therefore permanently rewrite
the coordinates of every block of every workflow in it — a write across the
read/write boundary.

Live cursors ride their own `cursor-update` event, and the smooth-drag
broadcast is the UNCOMMITTED position path, which returns before persisting and
never consults the role table — so the read role needs no grant at all.

* test(realtime): assert the role ACL against production, not a fixture

The shared ROLE_ALLOWED_OPERATIONS fixture still listed the two position
operations for the read role, and three tests compared that fixture against
itself — so they certified whatever it said, including the grants this PR
removes. They now assert checkRolePermission over the protocol's complete
operation list (the fixture's copy omits subblock/variable/admin-only ops), plus
one test that pins the fixture to the production ACL so the two cannot drift
apart again.
2026-08-01 17:51:10 -07:00
Waleed 5686b7bb83 fix(realtime): enforce room access continuously, not only at join (#6170)
* fix(realtime): enforce room access continuously, not only at join

File-doc, table, and workspace-list rooms authorized once at JOIN and never
again, so a member whose workspace access was revoked or downgraded kept live
collaborative write access — including durable Yjs document writes — for the
whole lifetime of an already-open socket. The access-revalidation sweep
explicitly skipped every non-workflow room.

- sweep every room type, authorizing each against its own resource
- share one membership policy (ROOM_MEMBERSHIP_ACTIONS) between the join check
  and the sweep, so a file-doc room keeps requiring write in both
- gate file-doc document frames and table cell selections on the cached
  permission, evicting on a confirmed loss of access
- re-check the cached decision before a join commits, so a join that authorized
  just before a revocation cannot re-enter the room
- never let a join's own cache write clobber a revocation recorded mid-flight
- surface room-access-revoked to clients; the file-doc editor falls back to
  read-only instead of accepting keystrokes that go nowhere

* refactor(realtime): one shared eviction path for revoked room access

The sweep and both per-frame gates each open-coded emit + leave + local-state
cleanup. Route them all through evictSocketFromRoom so they cannot diverge on
what eviction means; workflow keeps its historical access-revoked payload.

* fix(realtime): re-check access before workspace-list room joins too

The workspace-files / workspace-tables joins committed straight from their
authorize result, so a join that authorized just before a revocation could put
the socket back in a room the sweep had already evicted it from. Mirrors the
guard the file-doc and table joins already had.

* fix(realtime): order role-cache writes by read start, not write time

Two authorizations can start in one order and finish in the other, so the
decision written last can come from the older read. A join that authorized
before a revocation but returned after the sweep's denial would bury it,
handing the socket another full cache TTL of access. Every writer now takes a
monotonic ticket before it queries and yields only to a later-started read.

* chore(realtime): drop the test-only unguarded role-cache writer

Tests can express the same setup with commitRoomPermission + a read ticket, so
the cache has exactly one write path and no export without a production caller.

* fix(realtime): keep handler-initiated table eviction retryable

Evicting leaves the Socket.IO room synchronously, which is also how the sweep
discovers work — so a presence removal failing in the per-frame path could never
be retried and left a ghost collaborator until disconnect. Failed (or
unconfirmed) removals now hand off to the sweep's existing cleanup lane instead
of a second retry loop.

* fix(realtime): re-resolve access at join commit instead of peeking the cache

The pre-commit recheck peeked the role cache, which reports an EXPIRED entry as
unknown and fails open — so a join stalled longer than the cache TTL could
re-enter a room the sweep had already evicted it from, including a file-doc room
where the next cold-cache frame is accepted as a durable write. All three joins
now re-resolve the way the workflow join always has; it is normally a cache hit,
since the join's own authorize just warmed it.

* fix(realtime): keep the join generation guard after the access re-check

The access re-resolve added in the previous commit sat AFTER the generation /
superseded guard in the table and workspace-list joins, so a leave or a newer
join landing during that await no longer cancelled the stale join — it would go
on to leave the room the client had switched to and commit the abandoned one.
The guard is now the last thing before the commit in all three handlers, as it
already was for file-doc and workflow.

* test(realtime): use the shared sleep helper in the new join tests

check:utils bans the inline new Promise(setTimeout) form; the two stalled-join
tests were the only new offenders.

* fix(realtime): leave the prior table room only once the join is certain

A table switch left the previous room before the access re-check ran, so a
denial there aborted the join and left the client in no table room at all —
silently dropped from one it may still be allowed to occupy. The leave now
happens after the re-check, matching the file-doc and workspace-list joins.

* fix(realtime): close the table join window between re-check and commit

Moving the prior-room leave after the access re-check left Redis awaits between
that check and socket.join, and superseded() only watches the join generation —
so a sweep revocation landing in that window could still put a revoked socket
back in the room. A synchronous cache peek immediately before the commit closes
it without reintroducing the await; the authoritative resolve moments earlier
wrote a fresh entry, so a differing read IS the revocation being guarded.
2026-08-01 17:20:06 -07:00
Waleed 06506bbd38 improvement(files): cache stream binding meta, tighten file cache-control, prune dead persist path (#6149)
* improvement(files): cache stream binding meta, tighten file cache-control, prune dead persist path

- Cache the agent-stream ProseMirror↔Yjs binding metadata per session and reuse it
  across streamed frames instead of rebuilding it (O(doc)) every frame; matches how
  y-tiptap's own binding maintains the mapping in place. Safe because the shadow doc
  only ever sees the agent's own reconciles.
- Default createFileResponse to a private, no-cache policy so auth-gated bytes are never
  stored in a shared cache; genuinely-public serve routes opt into public caching
  explicitly.
- Serve content-addressed (key=) embedded images with an immutable private cache to
  avoid re-downloading them on every doc re-open; fileId= embeds and public shares keep
  revalidating (the underlying key can change / a share can be revoked).
- Drop the dead conflict.version field from the collab-doc persist result and remove the
  wasted getWorkspaceFile re-read on the conflict path (the relay treats missing and
  conflict identically and never read version).
- Decorate-sort the files/folders lists (compute each sort key once) and index the
  move-menu subtree build (O(N) vs O(N^2)); ordering is preserved exactly.

* fix(files): serve inline images with no-cache so deletion/authorization is enforced per request

Embedded images are authenticated content whose backing file can be deleted or have its
access revoked at any time. Both key= and fileId= embeds serve
private, no-cache, must-revalidate, so every request re-runs the server-side
deletion/authorization check instead of serving a possibly-stale image from cache.
2026-07-31 20:09:45 -07:00
Waleedandmzxchandra 10bfb5d139 feat(realtime): shared room spine + live Files/Tables collaboration + Yjs document editing (#5991)
* feat(realtime): add shared room identity + authorization spine (#5929)

Introduces the foundation for a unified realtime "room" model spanning the
Socket.IO presence server (apps/realtime), the durable SSE event log, and the
ephemeral pub/sub fanout — all of which today reinvent their own room identity,
naming, and authorization.

- @sim/realtime-protocol/rooms: RoomRef { type, id }, ROOM_TYPES, and a
  roomName/parseRoomName codec. WORKFLOW deliberately maps to the bare id so the
  ~40 existing io.to(workflowId) callsites and presence state keys are unchanged;
  every other room type is namespaced so id spaces cannot collide.
- @sim/platform-authz/rooms: authorizeRoom(userId, room, action) generalizing the
  exemplary authorizeWorkflowByWorkspacePermission — one resource->workspace
  resolver per room type, then the shared resolveEffectiveWorkspacePermission +
  permissionSatisfies gate.

Pure foundation, no behavior change: nothing consumes these yet. Prune graph
stays at 14/25 (platform-authz already depended transitively on realtime-protocol
via apps/realtime).

* refactor(realtime): generalize presence server to multi-room [2/N] (#5930)

* refactor(realtime): generalize presence server to multi-room (RoomRef)

Generalizes the Socket.IO presence layer from single-workflow-room-per-socket
to a domain-neutral, multi-room-per-socket model keyed by RoomRef, so a second
domain (workspace files, next PR) can reuse the same membership + presence
engine. Behavior-preserving for workflow collaboration.

IRoomManager is now domain-neutral (addUserToRoom/removeUserFromRoom/
getRoomForSocket/getRoomUsers/updateUserActivity/... all take a RoomRef). The
workflow lifecycle broadcasts (deletion/revert/update/deploy) move out of the
manager into WorkflowRoomService, composed over the generic manager.

Backward-compat by design (no workflow migration, no regression):
- Workflow Socket.IO room name stays the bare workflowId (roomName() maps
  workflow -> bare id), so the ~40 io.to(workflowId) callsites are untouched.
- Workflow Redis presence keys stay workflow:{id}:users/:meta (the type prefix
  IS "workflow").

Multi-room correctness (from adversarial audit):
- socket:{id}:workflow single-value key -> socket:{id}:rooms HASH (type->id).
- The SHARED socket:{id}:session key is deleted only when the socket leaves its
  LAST room (refcount via HLEN) — a leave from one room no longer breaks the
  other room's handlers.
- disconnect enumerates the socket's stored rooms and rebroadcasts presence per
  room, instead of picking an arbitrary socket.rooms entry.
- presence broadcasts use a per-room-type event name (workflow keeps the bare
  presence-update; others are namespaced).

Workflow handlers wrap manager calls with a shared workflowRoom(id) helper;
UserPresence.workflowId -> room (the client never reads that field).

Tests: existing 112 realtime tests pass unchanged (behavior gate) + 7 new
multi-room tests (refcounted session, presence isolation, multi-room disconnect,
per-type event names). tsc clean, boundaries + prune (14/25) green.

* fix(realtime): harden multi-room disconnect + id-guard room removal

Two fixes from an adversarial regression audit of the multi-room refactor:

- Disconnect now handles `disconnecting` (where `socket.rooms` is still populated
  and authoritative) and falls back to the live Socket.IO room set for any room
  the manager's stored state no longer tracked. This restores reliable presence
  cleanup + departure broadcast even if the Redis `socket:{id}:rooms` key was
  evicted or TTL-expired — the one behavioral gap vs the pre-refactor disconnect.
- REMOVE_ROOM_SCRIPT now only drops the socket's room mapping (and runs the
  last-room session cleanup) when the stored id matches the room being removed,
  matching the memory manager's existing id guard. Prevents a mismatched-room
  call from wiping a different room's mapping or the shared session.

+1 test (id-guarded no-op removal). 120 realtime tests pass, tsc clean.

* fix(realtime): only rebroadcast disconnect-fallback rooms whose removal succeeded

Greptile 4/5 follow-up: the disconnecting-time fallback ignored
removeUserFromRoom's boolean and rebroadcast presence even when the removal
reported false. Now it only treats a room as removed (and rebroadcasts) when the
manager confirms it — symmetric with removeSocketFromAllRooms, which already only
returns rooms it actually removed.

* fix(realtime): exclude the disconnecting socket from its farewell broadcast

Greptile follow-up (transient-Redis-failure edge): if removeUserFromRoom fails on
disconnect, the socket's presence entry can outlive it (room hashes have no TTL)
and reappear as a ghost. Disconnect now broadcasts a correction to EVERY room the
socket was in (union of the manager's removed rooms and the live Socket.IO
membership) and passes the disconnecting socket id as excludeSocketId, so it is
never shown as a collaborator regardless of whether the Redis delete succeeded.
Any orphaned entry is still reclaimed by the next join's stale-presence sweep.

broadcastPresenceUpdate gains an optional excludeSocketId; normal broadcasts are
unchanged. +1 test.

* fix(realtime): make presence broadcasts liveness-aware (root-cause ghost fix)

Presence broadcasts now reconcile the stored list against the live Socket.IO
membership (io.in(room).fetchSockets()) before emitting, via a shared
filterVisiblePresence helper. This closes the residual behind the earlier
disconnect fixes: an entry orphaned by a failed removal (room hashes have no TTL)
could reappear in a LATER join's presence snapshot until the 75-min stale sweep.
Now such an entry is never emitted, because a non-live socket is filtered out of
every broadcast. Combined with excludeSocketId (which handles the disconnecting
socket, still momentarily live). Fail-safe: on a fetchSockets throw or an empty
result while entries remain, emit the unfiltered list rather than hide live
collaborators.

Also drops a dead guard in the disconnect union loop (rooms already removed are
skipped by the wasInRooms check) and the now-unused isSameRoom import.

+1 ghost-guard test. 122 realtime tests pass.

* feat(files): live presence avatars + live file tree via realtime rooms (#5932)

* refactor(tables): adopt shared durable event-log core (#5934)

* fix(realtime): address post-merge review-comment findings (#5937)

* fix(realtime): address post-merge review-comment findings

A re-audit of every inline review comment on the merged stack surfaced real
issues that the thread-resolutions and prior audits missed. Fixes:

Presence server (#5930 comments):
- connection.ts: snapshot `socket.rooms` SYNCHRONOUSLY before the first await.
  Socket.IO clears the room set once the synchronous part of a `disconnecting`
  handler returns, so reading it after `await removeSocketFromAllRooms` saw an
  empty set — the eviction fallback was dead. (Cursor: "Disconnect fallback
  misses live rooms".)
- workflow-room-service: restore the original managers' final unconditional
  room-state wipe via a new `deleteRoom(room)` manager method, so a deleted
  workflow leaves no lingering presence/meta even if a per-socket removal failed
  or a socket joined mid-teardown. (Cursor: "Deletion skips final room wipe".)

Files (#5932 comments):
- workspace-file-manager.uploadWorkspaceFile now fans out the live-tree signal
  (all direct-upload paths: multipart fallback, copilot create, /api/files/upload,
  v1 files — the presigned path already notified). (Cursor: "Creates miss live
  tree fan-out".)
- use-workspace-files-room: clear the pending retry timer on join success; and a
  module-scoped intended-room guard defers the unmount `leave` so a rapid remount
  re-claims the room and skips a stale leave — fixing presence flap + a
  leave-after-join race. (Cursor: "Retry timer survives join success" + "Remount
  churns files presence".)
- workspace-files handler: roll back a partial join (leave room + remove presence)
  in the catch, mirroring the workflow join. (Cursor: "Join failure skips
  membership rollback".)

+2 tests (deleteRoom). 127 realtime tests pass, both apps tsc clean,
api-validation + boundaries green.

* fix(files): scope workspace-files leave to a workspace (deferred-leave safety)

Self-review of the deferred-leave guard found a real bug: leave-workspace-files
was not workspace-scoped, so after a workspace switch (A->B) the deferred leave
from A would evict the socket from its new room B. The leave now carries the
workspaceId and the server no-ops if the socket's current files room differs.
Also excludes the leaving socket from the leave broadcast (consistent with
disconnect).

* fix(realtime): close files-room presence leak + validate join payload

Architecture-audit findings:

- S1 (real Redis leak): the files room inherited the shared manager but not the
  workflow join's liveness sweep, so an UNGRACEFUL disconnect (pod crash — no
  `disconnecting` event) left its presence entry in the no-TTL room hash forever.
  Added a shared `sweepStalePresence(manager, room)` (fetchSockets liveness +
  remove not-live-AND-stale entries, matching the workflow 75min threshold) and
  run it on files join; also filter the join ack through `filterVisiblePresence`
  so a joiner never briefly sees an un-swept ghost.
- S2: validate the client-supplied `workspaceId` on files join before it reaches
  the DB query (matches the /api/workspace-files-changed guard; fails closed).
- N2: corrected the notify doc — it is awaited (guaranteed dispatch before a Node
  route returns) and hard-bounded to NOTIFY_TIMEOUT_MS, not "never block".

+1 test (sweepStalePresence keeps live/fresh, reclaims not-live-stale). 128
realtime tests pass, both apps tsc clean, biome clean.

* fix(realtime): workflow-deletion always notifies + cleans by socket.io membership

Review-round findings on #5937:
- Always emit `workflow-deleted` (was guarded by users.length>0), so a socket
  still in the Socket.IO room after a Redis presence eviction is told the
  workflow is gone before socketsLeave kicks it — the editor no longer keeps
  showing a deleted workflow. (Cursor: "Silent kick skips deletion event".)
- Clean per-socket state for the UNION of live Socket.IO members and
  presence-tracked sockets, so an evicted/late-joined socket's room mapping +
  session are dropped too — not just presence-snapshot sockets. (Greptile: "Room
  deletion leaves reverse state".)
- deleteRoom now logs AND rethrows on Redis failure (like addUserToRoom) so a
  failed wipe isn't reported as a clean deletion; the request surfaces it.
  (Greptile: "Room deletion failures are suppressed".)

The two "deferred leave drops new membership" P1s were already fixed by the
workspace-scoped leave in a prior commit (leave carries { workspaceId }; server
no-ops on mismatch). 128 tests pass, tsc + biome clean.

* refactor(files): drop module-scoped deferred-leave; rely on workspace-scoped leave

Removes the one non-idiomatic construct (a module-level mutable
`intendedFilesWorkspaceId` + queueMicrotask). It only guarded a same-workspace
CONCURRENT remount, which doesn't occur in production (folder nav is shallow/no
remount; list<->detail is sequential) — a dev-StrictMode-only case. The real
cross-workspace race is already handled by the workspace-scoped leave: if B's
join runs first (auto-leaving A), A's leave no-ops because the socket's current
files room is B. Simpler, idiomatic, prod-correct.

* feat(realtime): Yjs relay server for collaborative document editing [4/N] (#5941)

Server-side Yjs relay for collaborative document editing (live carets + text selection) in the Files rich-markdown editor. Faithful y-websocket-style relay over the existing authenticated Socket.IO connection + shared room abstraction; in-memory Y.Doc + Awareness per file; awareness ownership binding, userId-keyed client-id uniqueness, seeder election with deadline re-election, concurrent-JOIN generation guard. 25 relay tests. Reviewed to Greptile 5/5 + Cursor pass across multiple rounds, plus an independent 4-lens audit (correctness/security/conventions/simplicity) and /simplify + /cleanup passes.

* feat(files): collaborative document editing — client provider + editor (#5946)

Client Yjs provider (FileDocProvider over the authenticated socket) + TipTap Collaboration/CollaborationCaret wiring for live carets + text-selection in the Files rich-markdown editor. Collaboration is a Files-page-only surface (explicit `collaborative` opt-in), disjoint from agent-streaming. Read-only + autosave-gated until synced+seeded. Merges into the realtime-rooms integration branch.

* feat(tables): live collaboration — cell-selection presence + live mutation propagation (#5957)

* feat(tables): live cell-selection presence — protocol + server + client hook

The realtime spine for Google-Sheets-style table presence (mode A, socket):

- @sim/realtime-protocol/table-presence: centralized wire protocol (events +
  TableCellSelection {anchor, focus, editing} + payloads) so server emits and
  client subscriptions can't drift.
- ROOM_TYPES.TABLE + resolveTableWorkspace registered in ROOM_WORKSPACE_RESOLVERS
  (tableId -> workspace via userTableDefinitions, honoring archivedAt); roomName /
  presenceEventName / disconnect cleanup / authorizeRoom all derive automatically.
- apps/realtime/src/handlers/tables.ts: join/leave (mirrors workspace-files) + a
  table-cell-selection relay (mirrors the workflow selection channel), broadcasting
  via roomName(room) since table rooms are namespaced. UserPresence gains a cell
  field threaded through the memory + Redis managers (Lua ARGV[7], null clears).
- Extracted the duplicated resolveAvatarUrl into handlers/avatar.ts.
- use-table-room.ts client hook: joins over the shared socket, tracks the roster
  (avatars) + patches per-socket cell deltas, exposes a throttled emitCellSelection.

Grid UI (avatars + selection overlay) lands next; concurrent cell-value edits
(last-write-wins via the durable log) are the follow-up PR.

* feat(tables): render live cell-selection presence in the grid

Wires the table presence room into the grid UI:
- Page (table.tsx): useTableRoom (gated off in embedded/mothership mode) —
  renders <PresenceAvatars> in the header and passes remoteSelections +
  emitCellSelection down to the grid.
- Grid emits its local selection: an effect resolves the index-based
  anchor/focus to stable (rowId, columnId) via refs and broadcasts it (with an
  editing flag for the active cell) through the throttled emitter.
- RemoteSelectionOverlay: draws each remote viewer's selection in their color
  (getUserColor), a darker fill while editing, and name-on-hover — measured from
  live cell rects in the content wrapper's space (scrolls with the grid),
  hidden when rows are virtualized off-window, pointer-events-none so it never
  blocks cell clicks (hover via pointer hit-test).

* test(tables): cover the table presence handler

Mirrors workspace-files.test.ts: join auth/unavailable/denied/success, plus the
cell-selection relay (asserts it persists via updateUserActivity and broadcasts
on the namespaced roomName, not the bare id) and leave.

* feat(tables): propagate manual cell edits live (last-write-wins)

A manual row edit now appends a lightweight 'edit' event to the durable table
stream; collaborators refetch the row (via the existing debounced rows-invalidate
the job events use) so the winning value shows live. The event carries no value —
peers refetch in their own wire format, so there's no auth-specific value
translation on the wire, and last-write-wins falls out of the DB's committed order
(the Google-Sheets model). Edits that also trigger a dispatch already emit
dispatch/cell events; the debounce coalesces the two.

* refactor(tables): apply /simplify findings

- Drop the dead 'add unknown peer' upsert branch in use-table-room (Socket.IO
  ordering guarantees a peer is in the roster before their selection delta).
- TableCellSelectionBroadcast = TablePresenceUser & { cell } (was a copy-paste).
- Make TableGrid's presence props required + drop the unused empty-default/guard
  (only table.tsx mounts it, always passing both).
- Drop the unused rowId from the 'edit' event (the handler invalidates all rows).
- Overlay: subscribe scroll/resize/pointer listeners once per scroll element and
  cache the wrapper origin, so incoming deltas re-measure without re-subscribing
  and the pointer hit-test never forces a per-move layout read.
- Server: cache the immutable socket session so a selection delta no longer reads
  it from Redis every time.

* refactor(tables): apply /cleanup findings

- Fix the remote-selection name label contrast: text-white is unreadable on the
  light-pastel user colors (same bug the Files caret fixed) → fixed dark #1a1a1a.
- Re-measure via useLayoutEffect so a moving peer selection updates before paint
  (no one-frame position lag).
- Drop 'mothership' from a comment (constitution copy rule).

Six cleanup passes ran (effect, memo/callback, state, react-query, emcn, comment);
the rest confirmed clean — all state/memos/callbacks/effects are load-bearing,
presence correctly lives in useState (socket-pushed), and the edit→rows-invalidate
granularity is right.

* feat(tables): propagate every table mutation live (edit + schema signals)

Comprehensive live collaboration for all user table mutations, via two value-less
durable signals + named helpers (signalTableRowsChanged / signalTableSchemaChanged):

- edit (rows refetch): single + batch row create, cell/row update, batch update,
  delete by id/filter, and upsert.
- schema (definition + rows refetch): column add/update/delete, workflow-group
  add/update/delete, table rename, and CSV import (which can add columns).
- Client handles 'schema' by invalidating the table detail (exact) + rows.

Execution paths (column run, cancel-runs) and async jobs (delete/import-async,
job-cancel) already propagate via cell/dispatch/job events — verified applyJob
refetches on terminal. No reorder routes exist. Table archive (route DELETE) is a
deliberate follow-up: it needs a table-deleted redirect event, not a refetch signal
(which would 404).

* refactor(tables): apply comprehensive /cleanup audit findings

Holistic + react-query + comment audits over the whole PR:

- Security/crash fix: a remote peer's rowId flowed unescaped into the overlay's
  querySelector — a hostile id ('x"]') threw SyntaxError inside a useLayoutEffect,
  crashing every other viewer's page. CSS.escape it, and validate + whitelist the
  untrusted cell payload server-side (shape + 200-char id bound) before it is
  stored/rebroadcast.
- Simplify the CELL_SELECTION relay: the delta attached userId/userName/avatarUrl
  that the client discarded (identity comes from the roster). Drop them + the
  getUserSession lookup/cache entirely — the delta is now { socketId, cell }.
- React Query: schema handler also invalidates lists() (parity with the local
  column-mutation set); document that the mutating client self-refetches by design.
- Comment tightenings; biome fixed a stale import order in workspace-files.ts.

* fix(tables): broadcast single-cell selections (focus falls back to anchor)

Cursor High: a normal cell click leaves selectionFocus null (the grid treats it as
a one-cell selection via focus ?? anchor), but the presence emit required BOTH anchor
and focus to resolve — so the most common selection never broadcast and clicking even
cleared a prior remote outline. Mirror the grid's focus ?? anchor semantics.

* fix(tables): reviewer + regression + per-LOC audit findings

Cursor review round (5 findings) + regression audit + per-LOC audit:
- Presence roster snapshot now KEEPS the cell we already hold for a known socket, so
  a join/leave broadcast can't revert a fresher CELL_SELECTION delta.
- Reset the selection throttle on table switch (was unmount-only), so a pending
  selection for table A can't flush into table B's room after a switch.
- Metadata writes (column widths, display) use a new lightweight 'metadata' signal
  that refetches only the definition — a resize no longer forces peers to refetch rows.
- Overlay re-measures on row add/remove/reorder via a tbody childList MutationObserver
  (a live refetch moves cells without a scroll/resize).
- Document the actor self-refetch create caveat (scrolled multi-page insert) accurately.
- isCellRef narrows to a partial instead of casting to the full type then re-checking;
  drop a redundant mount measure() (the layout effect covers it); text-[11px]→text-xs.

* fix(tables): drop ineffective metadata propagation + re-measure overlay on column resize

Cursor round on b8f28b04b:
- Remove the 'metadata' signal entirely. The grid seeds columnWidths/pinnedColumns
  from metadata ONCE (metadataSeededRef) and deliberately never re-applies them (to
  avoid clobbering a local in-progress resize), so refetching the definition on a peer
  never surfaced their width/pin change — an ineffective path. Width/pin live-sync needs
  reconciliation that doesn't clobber a local resize; that's a deliberate follow-up, not
  a no-op refetch. Structural changes still propagate via 'schema'.
- Overlay now also observes the content layer with the ResizeObserver, so a column
  resize (which grows the content, not the scroll container) re-measures remote outlines.
- Presence-merge comment now states both sides of the trade-off.

* fix(tables): re-broadcast local selection on (re)join

Cursor Medium: a selection made before the room join completes (or held across a
reconnect) was dropped server-side and never re-sent, so peers didn't see it until
the local user moved it again. Track the current selection in a ref (set on every
emit, cleared on table switch) and re-emit it from handleJoinSuccess once the room is
joined.

* fix(tables): re-broadcast selection when a peer's row change shifts it

End-to-end lifecycle audit (Low-Med): the selection emit resolved the stable
(rowId, columnId) only on selection/editing change, not when a live edit/schema
refetch inserted/deleted/reordered rows. The index-based local selection then sat
on a different logical row than the rowId peers held, so your outline showed on the
old row until you moved. Re-run the emit on rows/displayColumns change and dedup an
unchanged result (also drops the redundant null-on-open emit) so the broadcast stays
consistent with the local highlight.

* fix(tables): schema invalidates run-state/enrichment + guard stale join

Cursor round on cdc8796b8 (2 Medium):
- schema handler used detail exact:true, so it skipped the activeDispatches +
  enrichmentDetails sibling queries the local invalidateTableSchema refreshes via a
  prefix match. After a peer deletes/restructures a workflow group, peers could keep a
  stale running badge or enrichment panel. Now invalidates both siblings too (rows stay
  on the debounce).
- Guard against a stale join stealing the room: a fast table A->B switch could let A's
  async authorize finish after B, leave B, and strand the socket in A. Added a
  per-socket monotonic join generation checked after authorize (mirrors the file-doc
  relay's guard) + a test.

* feat(tables): live column width/pin/order sync

Collaborators now see each other's column resizes, pins, and reorders live —
the last piece of Google-Sheets-style layout parity.

- New lightweight `metadata` durable event kind (distinct from `schema`): only the
  table definition carries UI metadata, so peers refetch the definition alone — no
  rows/run-state refetch. The metadata PUT route now signals it.
- The grid reconciles server metadata against its in-progress gesture: the column
  being actively resized keeps its live local width, and an in-flight column drag
  blocks a reorder apply — so a peer's change never reverts the local action. Each
  field is reference-guarded (React Query structural sharing keeps unchanged
  sub-objects stable), so an unrelated peer change doesn't re-apply the others.

* fix(tables): escalate to schema signal when a reorder scrubs group deps

Independent audit of the metadata-sync commit found a stale-run-state hole: a
columnOrder PUT that moves a column left of a workflow group's leftmost column
makes updateTableMetadata scrub that group's dependencies and write a new schema —
a real structural change. But the route only fired the lightweight 'metadata'
signal (detail-only refetch), so peers' and the actor's activeDispatches /
enrichmentDetails queries stayed stale (a lingering running badge / enrichment
panel) — exactly what the 'schema' handler exists to prevent.

updateTableMetadata now reports whether it scrubbed the schema; the route emits
signalTableSchemaChanged in that case and the light signalTableMetadataChanged
otherwise. Width/pin/plain-reorder stay on the cheap detail-only path.

* feat(realtime): accurate in-file presence + collaborative-caret polish (#5965)

Per-session file-doc presence (avatars count other sessions like the canvas), StrictMode-safe stable Y.Doc (fixes blank-doc on join), flush caret cap + restored hover hit-slop, and three join-lifecycle race fixes unifying file-doc + workspace-files on one intent-tracked monotonic generation model. All findings root-caused with regression tests.

* feat(files): smarter bullet delete/indent and untitled-file title sync (#5971)

* improvement(files): smarter bullet delete/indent, fix empty-nested-bullet heading corruption

Backspace at the start of a list item now outdents a nested item or clears a
top-level item to a paragraph in place instead of deleting the row and jumping
the caret to the previous block; Enter on an empty nested item outdents. Empty
non-trailing top-level items still collapse cleanly since they cannot round-trip
as a lifted paragraph.

Also strips nested empty list-item marker lines on serialize: a nested empty
bullet re-parsed as a Setext heading underline, silently turning its parent line
into an H2 and dropping the bullet. Top-level empty items are preserved.

* feat(files): sync an untitled file's name with its leading heading

While a file is still named untitled(.md), typing a leading heading auto-renames
the file after it (debounced), and renaming the file first seeds a leading H1
from the new name. One-shot: coupling stops once the file has a real name, and
the heading seed always prepends so existing content is never clobbered.

* fix(files): count inline atoms in list-item emptiness, keep multi-block items on Backspace

Addresses review findings on the list Backspace logic:
- Emptiness now uses the caret block's content.size (counts inline images/mentions),
  not textContent, so a bullet holding only a non-text atom is no longer treated as
  empty and deleted.
- An empty first block whose item has sibling blocks removes only that block instead
  of lifting the whole item out of the list.

* fix(files): preserve the untitled to named heading seed across a rename during editor load

The parent captures the file name at mount (before content/session finish loading) and
passes it as the transition baseline, so a rename that lands in the loading window is still
seen as an untitled to named transition and the leading heading seed is not skipped.

* fix(files): drop the name-to-heading seed, keep title sync one-way

Removes the effect that inserted a leading H1 when an untitled file was renamed. On the
collaborative Files page every open client observed the untitled-to-named transition and
inserted into the shared doc, producing duplicate headings; it could also re-insert a heading
a user had just deleted while a rename was in flight. Seeding document content from an async
rename transition is the wrong model on a shared editor. The primary direction — typing a
leading heading renames a still-untitled file — is unaffected (it never mutates the doc).

* fix(files): keep empty lines between paragraphs on reload

The chunked markdown parser (parseMarkdownToDoc) parses each block stripped of the
blank lines between them, so it dropped the empty paragraphs @tiptap/markdown builds
from runs of blank lines — a saved visual blank line silently vanished on the next
load (the settle/reopen re-seed goes through the chunker). The whole-document parser
preserves them, but whether a gap yields an empty paragraph is a global, block-type-
dependent decision (kept between two paragraphs, dropped after a heading), so it can't
be reconstructed block-locally. Route documents with empty-paragraph blank-line spacing
to the whole-document parser for exact fidelity — the same tradeoff NON_CHUNKABLE makes;
ordinary single-blank-line separation still takes the fast chunked path. Adds a suite
asserting chunked output matches the whole-document parser for leading/trailing/between
gaps and around lists/headings.

* fix(files): only auto-name an untitled file when the user can edit

The debounced untitled→filename hook ran on every onUpdate — including the mount-time
seed and for view-only viewers — without checking edit permission, so a read-only user
could schedule a rename they have no permission to make (a spurious, server-rejected
write). Gate the derive-title on editor.isEditable (canEdit + settled + collab-ready,
the same signal the autosave path uses), at both schedule and fire time.

* fix(files): normalize line endings before the empty-paragraph guard; Enter/Backspace symmetry

- markdown-parse: EMPTY_PARAGRAPH_SPACING/NON_CHUNKABLE tested the raw body, but a classic
  \r-only file (blank lines are \r) would miss the \n-anchored guard and still be chunked,
  dropping empties. Normalize line endings once up front so the routing guards, the chunker,
  and the parser all see the same \n. +CRLF/CR test cases.
- keymap: Enter on an empty first block of a multi-block item now removes only that block
  (removeEmptyWrappedBlock) instead of exiting the list, mirroring the Backspace hasSiblingBlocks
  case — the trailing check no longer swallows multi-block items. +test.

* fix(files): editor audit follow-ups (trailing-blank read-only, collab rename, over-strip)

A 4-agent independent audit (UX vs inkeep + SOTA, cleanliness, adversarial correctness)
surfaced these:

- HIGH regression: files ending in a blank line opened READ-ONLY. The empty-paragraph
  routing preserved a TRAILING empty paragraph, but postProcess collapses trailing newlines
  → serialize/parse non-idempotent → isRoundTripSafe flipped the file read-only. A trailing
  empty paragraph can't be serialized stably, so parseMarkdownToDoc now strips trailing empty
  paragraphs and the guard no longer routes on trailing blanks. Interior/leading empties are
  unaffected. +regression tests.
- Medium: the debounced untitled→filename rename fired on remote Yjs edits too, so every peer
  renamed and could rename from a not-yet-synced heading. Gate on isChangeOrigin (local edits
  only; false for non-collab surfaces).
- Medium: stripEmptyListItemLines over-stripped a nested empty item that follows a same-indent
  sibling (a real placeholder the parser keeps). Narrowed to the actual Setext hazard — an empty
  item DIRECTLY under a shallower parent line — matching the function's own docstring intent.
  Probe-verified. +test.
- Low: corrected untitled-title.ts docstring that described a reverse name→heading coupling
  removed during review.

* fix(files): a remote edit must not cancel the local rename debounce

The isChangeOrigin gate cleared the debounce timer BEFORE bailing on a remote update, so
a peer's edit arriving within the 600ms window cancelled the local user's pending rename.
Bail on isChangeOrigin first, before touching the timer; only local edits clear/reschedule it.

* docs(files): correct EMPTY_PARAGRAPH_SPACING rationale after trailing-strip

The stacked trailing-empty-paragraph strip made the older comment overstate a
correctness necessity it no longer owns, mislabel trailing runs of 2+ blanks,
and advertise dead CRLF handling. Reword to match what the code actually does.

---------

Co-authored-by: Waleed Latif <walif6@gmail.com>

* fix(realtime): access-revalidation multi-room safety + cleanup pass

Fix a blocker surfaced by a full cleanup/simplify audit of the branch: the
access-revalidation sweep (staging's workflow-only #5917) treated every entry
in socket.rooms as a workflow id, but the generalized multi-room model puts
namespaced files/tables/file-doc rooms on the same io. It would resolve those
as bogus workflows, get null, and evict files/tables collaborators every ~30s.
collectScanTargets now decodes each room name with parseRoomName and sweeps
only workflow rooms; added a regression test and fixed the now-false TSDoc.

Other audit fixes (all behavior-preserving):
- workflow.ts reuses resolveAvatarUrl (drops db/user/eq imports duplicated
  from avatar.ts)
- PresenceAvatars: mr-1 was baked into the shared component, silently adding a
  margin to the workflow sidebar stack; moved to an optional layout className,
  re-applied on the tables/file-doc header surfaces only
- table DELETE routes only signal collaborators when rows were actually removed
  (matches PUT)
- events.ts definition kind: drop the never-emitted reason:'schema', fix its doc
- event-log: rename buildMemory -> buildEntry (it builds the entry on the Redis
  success path too, not just the memory fallback)
- remove dead resolveWorkspaceIdForRoom export; parallelize per-socket removals
  in handleWorkflowDeletion; gate the table columnIndexById map on remote
  selections; move file-doc module TSDoc off the FileDocOwner interface; fix a
  stale @returns

* fix(realtime,tables): close table-presence race + v1/copilot live-collab gaps

Validated each issue with subagents before implementing the cleanest fix.

- tables LEAVE in-flight-join race (B8): the table handler tracked no current-table
  intent, so an unscoped/same-table leave during an in-flight authorize left the
  socket stranded in the room (present in the roster, broadcasting a ghost until
  disconnect). Mirror workspace-files: a closure-local currentTableId + a leave that
  advances joinGeneration to cancel the racing join. + 3 regression tests.
- v1 + copilot live-collab signal gap (D1): tables edited via the v1 public API or
  Sim/copilot emitted no edit/schema signal, so open collaborators didn't live-update.
  Add the signals at those call sites (add-only, matching the existing route seam) —
  never in the service, so execution writes can't double-emit. Sync-only for copilot
  bulk ops, guarded on affected/deleted count; async job branches stay covered by
  their kind:'job' events; create/delete/get untouched.
- table join read consolidation (B5): sweepStalePresence returns its roster so the
  same-tab dedup reuses it instead of a second getRoomUsers.
- shared authorize slice (B6): extract only the guard-safe authorize->allowed branch
  into resolveRoomJoinAuth, shared by the three room handlers (the full preamble stays
  inline — file-doc's generation capture sits mid-ladder and must not move).
- resize-revert flicker (E3): a peer's value-less metadata event forces a refetch that
  could momentarily revert a just-finished local resize; a pendingWidthWriteRef keeps
  local widths leading until the width PUT settles.
- embedded-mode stray emit (E5): gate emitCellSelection on a bound table id so the
  embedded surface stops broadcasting cell selections the server drops.

* fix(tables): close two copilot live-collab signal gaps + harden presence sweep

Follow-ups from a comprehensive review of the branch:
- copilot batch_update_rows and import_file's inline append branch wrote rows
  but emitted no live-collab signal, so collaborators didn't see those edits
  live (the append's sibling replace branch already signalled). Add the guarded
  signal to both, matching the internal route.
- sweepStalePresence now reads the roster before the fetchSockets liveness probe
  and returns it on a probe failure, so same-tab dedup still runs during a
  transient fetchSockets outage instead of being skipped.
- reword an internal comment off the retired "mothership" term.

* fix(realtime): guard table join commit + rollback against supersession; drop no-op eviction cleanup

Review round on #5991:
- Table join re-checked the generation only once after authorize, then awaited
  leave/sweep/avatar before joining + registering presence. A table switch or
  leave in that window stranded the socket in the wrong room, and the failure
  catch could tear down a newer successful join. Resolve the avatar up-front,
  re-check generation immediately before the membership commit (matching the
  file-doc join), and skip the rollback/error for a superseded join. + a
  post-authorize-window regression test.
- access-revalidation cleanup treated removeUserFromRoom's no-op false as a
  transport failure and re-enqueued a still-connected socket forever. Only retry
  when the socket is still mapped to the room (a healthy null mapping means the
  entry is already gone). Repurposed the expired-mapping test to lock it.

* fix(realtime): guard table join leave-prior against superseding join

Round 2 on #5991: a superseded join's leave-prior could still run — during its
getRoomForSocket await a newer join commits to its room, so currentRoom is that
newer room and the superseded join would leave/remove/broadcast it before the
final guard aborts. Re-check the generation immediately after the lookup await,
before the leave mutation. Extended the post-authorize-window test to assert the
superseded join never tears down the newer join's room.

* fix(realtime): roll back a table join superseded during addUserToRoom

Round 3 on #5991: after the final generation guard, A could join + register
presence while a newer join B commits to its room during addUserToRoom's await
— B's leave-prior can't observe A's half-written entry, so A's late write wins
and strands the socket. Re-check after addUserToRoom and roll back A's own
Socket.IO join + presence (scoped to A's room, never touching B). + a regression
test hanging addUserToRoom mid-commit.

* refactor(realtime): DRY table-join supersession guards; fix stale comment

Cleanliness pass after the review rounds (no behavior change):
- Extract the four identical `joinGeneration !== joinAttempt || socket.disconnected`
  checks into a named `superseded()` helper (the catch keeps its intentionally
  narrower check).
- Remove a stale guard comment that was left stranded above the avatar resolve.
- Document the best-effort rollback catch.

* fix(realtime): file-doc rebind must not drop the current doc or leave a writable ghost

Two Cursor findings on the file-doc client-id ownership rebind:
- On a document switch, the prior room was left BEFORE the ownership check, so a
  CLIENT_ID_IN_USE rejection dropped the socket from the old doc without joining
  the new one (contradicting its own comment). Run the ownership check first, and
  leave the previous doc only once the rebind is guaranteed to succeed.
- Reclaiming a client id removed the stale prior socket from owners + awareness
  only; its socketToRoomName + Socket.IO membership remained, and handleMessage's
  SYNC path gates on socketToRoomName (not owners), so it stayed able to write
  document frames until disconnect. Fully evict the reclaimed socket. + 2 tests.

* refactor(realtime): serialize table join/leave to fix map-corruption at the root

Round 4 on #5991 surfaced a race the generation guards structurally cannot fix:
two concurrent joins for one socket race on the single-valued socket→room map —
a stalled addUserToRoom for table A lands late, clobbers a newer join's map entry
to A, and the rollback then wipes it, stranding the socket (map empty while it
holds table B). Guards protect JS suspension points; they can't stop an in-flight
Redis write from landing late.

Fix per architecture review: serialize this socket's JOIN + LEAVE on a per-socket
promise chain so their multi-step async Redis commits can never interleave —
restoring the atomic-commit property the synchronous sibling handlers get for free.
This DELETES the leave-prior guard and the post-commit rollback (the code that
caused the bug); four generation guards collapse to two identical superseded()
checks (skip a superseded queued op + one pre-commit check). Reworked the
interleaving-specific tests into a fast-switch-skips-superseded test; the leave-
cancels-join tests are unchanged. Local to the tables handler — no shared-infra change.

* fix(realtime): always roll back a failed table join; re-elect file-doc seeder on reclaim

Two review findings:
- Table join: the catch skipped rollback when superseded, but a socket.join that
  landed before addUserToRoom threw leaves the socket in the Socket.IO room with no
  matching socket->room map entry — unreclaimable by any later op (cleanup keys off
  the map). Under serialization the skip is unnecessary (the newer op hasn't
  committed), so always roll back. Simpler + fixes the strand.
- File-doc reclaim: fully evicting the prior socket didn't release the seeder role
  if it held it, so electSeederIfNeeded (which no-ops while seederSocketId is set)
  never re-elected and an unseeded doc stayed empty until the deadline. Clear the
  role on eviction so the join's election picks a new seeder. + 2 regression tests.

* fix(realtime): close revoke-race ghost presence in workflow join

An access-revalidation revoke landing between socket.join and addUserToRoom
socketsLeaves the socket while its presence mapping does not yet exist, so
cleanupEvictedSocket finds nothing to remove and the join then writes presence
for a socket already out of the room — a ghost collaborator until the stale
sweep. Hoist resolveAvatarUrl (the only await in that gap) above the re-auth
check so the whole re-auth -> socket.join -> addUserToRoom section is await-free,
matching the invariant the handler already relies on for the pre-join re-auth.
+ ordering regression test.

* refactor(realtime): serialize workflow join/leave; drop dead room-authz limb

Comprehensive independent audit follow-ups:

- workflow.ts join/leave now use the same opChain + joinGeneration serialization
  as the sibling handlers (tables, file-doc, workspace-files). It was the only
  async presence path left unserialized, so a rapid workflow switch A->B (or a
  leave racing an in-flight join) could strand presence in room A — a ghost
  collaborator still receiving A's operation broadcasts until disconnect. The
  join now aborts a superseded op at start and again right before the membership
  commit, and the catch always rolls back a partial join.
- leave-workflow drops the '&& session' gate: an idle user whose 1h session key
  expired (while the 24h room mapping is still live) can now leave cleanly
  instead of being stranded until disconnect. The room ref alone suffices.
- authorizeRoom: remove the dead ROOM_TYPES.WORKFLOW resolver + its
  getActiveWorkflowContext import. Workflow authorizes through its own path and
  never flows through authorizeRoom; the map now honestly covers only the
  workspace-scoped types (files, file-doc, table).
- Remove unused isSameRoom (zero callers) and a needless useMemo in
  PresenceAvatars (plain derivation, single copy).
- Tests: 4 workflow serialization/leave regressions.

All gates green: tsc (sim/realtime/packages) 0, 204 realtime + 11 protocol
tests, biome, api-validation, boundaries, prune 14/25.

* fix(realtime): align session TTL, harden committed joins from post-success rollback

Per-module comprehensive audit follow-ups:

- redis-manager: SESSION_TTL now tracks SOCKET_ROOMS_TTL (was 1h vs 24h). The room
  set outlived the session, and since getRoomForSocket reads the room set while the
  workflow handlers gate edits/presence on `room && session`, an active-but-idle
  collaborator got wedged into 'session expired' after 1h — sticky until reload
  (activity only EXPIREs the already-gone session; only addUserToRoom re-HSETs it).
  Both keys refresh together, so they now expire together (restores the pre-refactor
  consistency, where both shared one TTL).
- workflow.ts + tables.ts: a 'committed' flag stops the join catch from rolling back
  a genuinely-joined user when a trailing ack/broadcast/metric step fails on a Redis
  blip (a pure getUniqueUserCount log-metric failure could otherwise kick a live
  collaborator after success was already acked).
- workflow.ts: leave-prior now guards `currentRoom.id !== workflowId` (a same-workflow
  re-join no longer leave→re-adds and flickers peers' presence), and the join ack is
  liveness-filtered via filterVisiblePresence — both for parity with the tables handler.
- platform-authz: honest docstring + 400 message for the workspace-scoped-only
  authorizeRoom map (workflow authorizes via its own path).
- caret-presence: corrected an over-stated batching comment.
- +1 workflow regression test (post-success failure keeps the user joined).

Gates: tsc (sim/realtime/packages) 0, 205 realtime + 11 protocol tests, biome,
api-validation, boundaries, prune.

* fix(realtime): narrow join commit-guard to post-success; skip empty presence broadcast

Two Cursor findings on the prior audit-fix commit:

- The 'committed' guard in join-workflow/join-table was too broad: a failure
  BETWEEN the membership commit and the success ack (e.g. getWorkflowState) hit
  'if (committed) return' and emitted neither success nor error, hanging the
  client while it sat in the room. Replaced with the narrower shape: only the
  purely-decorative post-success steps (peer broadcast + log-metric) are wrapped
  best-effort; anything before the success ack still rolls back and surfaces a
  retryable error, so the client retries instead of hanging — while the original
  goal (a benign broadcast/metric blip never kicking a live, acked user) holds.
- broadcastPresenceUpdate read the roster via getRoomUsers, which swallows a Redis
  transport error to []. On a disconnect broadcast that emitted an empty roster and
  cleared every remaining collaborator's presence until the next healthy update.
  Split out a throwing readRoomUsers; broadcastPresenceUpdate now skips the
  broadcast on a read failure (getRoomUsers keeps its swallow contract).
- Tests: pre-success failure rolls back + retryable error (no hang); post-success
  failure keeps the user joined.

Gates: realtime tsc 0, 206 realtime tests, biome, boundaries, prune.

* fix(realtime): harden seeder recovery, join-generation, and misc robustness

Final line-by-line audit follow-ups (all LOW/MED, no P0/P1):

- file-doc: a sole client whose seed FETCH fails was added to triedSeeders,
  re-election found nobody, and the document stayed permanently empty until
  reload. Re-offer seeding a bounded number of rounds (MAX_SEED_ROUNDS) before
  giving up. Also bound clientId to a non-negative integer (it is an ownership key).
- tables + workspace-files: validate the room id BEFORE advancing joinGeneration,
  so a malformed/rejected join can't cancel a legitimate in-flight join.
- workflow + tables + workspace-files: suppress the client-facing join error when
  the op was already superseded (a retryable error naming the abandoned room could
  make a client re-join and cancel its newer join). The rollback still runs.
- redis-manager: set isConnected=true only after scriptLoad succeeds (and reset it
  on failure) so isReady() can't report ready while the Lua SHAs are null.
- connection: apply the presence-bearing filter to the manager-removed set too
  (symmetry with the fallback path).
- http.ts: validate workflowId on the four workflow endpoints (matching the files one).
- client: clear a pending join-retry timer before rescheduling (reconnect churn no
  longer orphans a stray extra join); clear caret fade timers on plugin destroy;
  seed-effect cleanup reports NOT-ready (safe direction).
- Tests: bounded seeder recovery, cell-selection strip-junk, TABLE round-trip,
  presenceEventName.

Gates: tsc (sim/realtime/packages) 0, 208 realtime + 12 protocol tests, biome,
api-validation, boundaries, prune.

* fix(realtime): gate isReady() on loaded script SHAs, not just connection

Follow-up to the prior isConnected change, which was incomplete: the redis client's
'ready' event flips isConnected=true on connect — before initialize() loads the Lua
scripts — so a bare isConnected check reports ready while removeUserFromRoom /
updateUserActivity would silently no-op on a null SHA. Gate isReady() on the SHAs
too, so the POST endpoints return a retryable 503 during that startup window instead
of proceeding against unloaded scripts. Standard readiness-probe discipline.

* fix(files): offline read-only fallback when the realtime doc never syncs

When the realtime server is unreachable (offline, server down, socket never connects),
the collaborative editor would sit blank and read-only forever — content only arrives
via provider sync events that never fire. Add a bounded connect-deadline to the Yjs
provider: if no first sync lands within CONNECT_DEADLINE_MS, latch fatal and emit a
synthetic non-retryable join-error — the exact path a real fatal rejection already uses,
which seeds the file's stored content read-only. Latching fatal also stops a late
reconnect from syncing server state in and merge-duplicating the locally-seeded content
(the documented Yjs 'non-empty doc ignores initial value' gotcha).

Deliberately NOT adding durable Yjs snapshot persistence / server-side seeding: TipTap
can't run the markdown->Yjs conversion server-side (Collaboration extension errors under
jsdom), and a durable binary snapshot would create a dual source of truth with the
markdown file (edited by copilot / PUT / download). The client-seeder + bounded
re-election is the correct architecture for a markdown-is-truth model; this closes its
one real user-facing gap without persistence, a migration, or dual-truth.

Timer cleared on first sync, on a real fatal rejection, and on destroy. +2 tests.

Gates: sim tsc 0, 496 editor tests, biome. Needs live offline->reconnect verification.

* fix(realtime): validate workflow join id before generation bump; scope file-doc join rollback to its target

Two Cursor findings:
- join-workflow bumped joinGeneration before validating workflowId (unlike tables /
  workspace-files, which I'd already fixed). A malformed/empty join could advance the
  counter and cancel a legitimate in-flight workflow switch. Validate the id first. +test.
- The file-doc join catch called cleanupFileDocForSocket unconditionally, which keys off
  socketToRoomName. During a document SWITCH that fails before rebinding (e.g. a throw in
  client-id reclaim), that binding still points at the socket's PRIOR, valid document —
  so the rollback tore down a document the socket was validly in. Only run that cleanup
  when the binding already points at THIS join's target; otherwise the socket never
  registered as an owner here and the only leftover is a freshly-created empty room,
  dropped by destroyRoomIfIdle.

Gates: realtime tsc 0, 209 tests, biome, boundaries, prune.

* fix(files): drop late sync frames once fatal; file-doc join error suppression + retry-budget reset

Final safety-audit findings:
- CRITICAL: FileDocProvider.handleMessage had no fatal guard. After the connect
  deadline latched fatal and the editor fell back to a read-only local seed, a
  late SyncStep2 (slow server / flaky network / deploy) was still applied — merging
  server state into the seeded doc (content duplication) and flipping synced=true,
  which un-gated autosave and would persist the duplicate to the real file. fatal
  guarded (re)join but not inbound sync. Now handleMessage returns early when fatal.
  +test (late SyncStep2 after the deadline is ignored, doc stays empty + gated).
- file-doc join catch now suppresses the client-facing error when superseded
  (matches workflow/tables/workspace-files) so a retryable error for an abandoned
  file can't make a client re-join and cancel the newer one.
- table/workspace-files room hooks reset the retry budget on (re)connect so a prior
  full exhaustion doesn't block retries after a reconnect.
- presence-visibility: corrected a stale TTL comment.

Gates: tsc (sim/realtime) 0, 209 realtime + collab/hooks suites, biome, boundaries, prune.

* feat(collab-doc): server-authoritative Yjs seeding (#6008)

Server-authoritative Yjs seeding for collaborative file documents: the realtime relay
fetches a Yjs seed built from the file's markdown (via the shared TipTap engine) and
applies it once per room, replacing the client-seeder election/handshake entirely.

- DOM-free markdown<->Yjs conversion core (markdownToYDoc / yDocToMarkdown /
  applyMarkdownToYDoc) reusing the client markdown engine for parity by construction
- Internal x-api-key seed endpoint + realtime fetch; single attempt bounded under the
  client readiness deadline, guard-release for join-driven retry, read-only fallback on
  persistent failure
- Client readiness gate = synced && server seed flag; jsdom wired for the Next standalone
  build (serverExternalPackages + outputFileTracingIncludes)

Foundation only — copilot-into-doc + markdown projection + durable persistence are Stage C.

* feat(collab-doc): Sim merge endpoint for copilot-into-doc (Stage C foundation)

buildFileDocMergeUpdate(docState, markdown) computes the minimal Yjs diff that turns a live
document into target markdown, via applyMarkdownToYDoc (a real updateYFragment diff, not a
replace) — so a copilot rewrite merges with concurrent user edits instead of clobbering them.
Exposed over the internal x-api-key /api/internal/file-doc/merge endpoint the realtime relay
will call: the relay owns the doc, the app owns the conversion engine, so the relay ships the
current state and applies the returned diff. Tested incl. concurrent-edit no-clobber.

* feat(collab-doc): realtime apply-edit — merge copilot markdown into a live doc

The relay can now stream a copilot edit into open editors: applyMarkdownToLiveFileDoc finds
the seeded live room, ships its state to the app's /merge endpoint for a minimal CRDT diff,
applies it (relaying to every editor, reconciled with concurrent user edits), and reports
'no-live-room' so the caller falls back to a direct file write when nothing is open.

- Generalize the realtime->app request module (file-doc-seed.ts -> file-doc-app.ts) with a
  shared POST helper + fetchFileDocSeed/fetchFileDocMerge
- POST /api/file-doc/apply-edit on the internal x-api-key HTTP surface, returning { applied }
- Tests for the seeded-room merge relay and the no-live-room fallback

* feat(collab-doc): stream copilot edits into open editors (Stage C)

edit_content now, after its durable file write, best-effort merges the same markdown into the
file's live collaborative document (markdown files only). If a collaborator has it open, the
edit streams into their editor as a CRDT merge — reconciled with their concurrent typing —
instead of the file silently changing under them; the editor's existing autosave mirrors the
merged doc back to the file. No-op when nothing is open. Never blocks or fails the edit.

* fix(collab-doc): strip frontmatter on merge; gate live-merge to markdown

- buildFileDocMergeUpdate now strips YAML frontmatter (splitFrontmatter().body) exactly as
  the seed does. Copilot passes full-file content, so without this the frontmatter merged
  into the doc as editor content and autosave wrote it back over the file (corruption).
- Gate the live-doc merge on isMarkdownFileName (new server-safe helper) instead of the
  over-broad !isDoc, so code/text edits don't pay the realtime round-trip for a format the
  collaborative editor never renders.

* fix(collab-doc): make frontmatter collaborative so a merge can't revert it

The editor re-attaches its open-time frontmatter on every autosave, so the Stage C merge
(which triggers an autosave with no user action) could write stale YAML back over a copilot
frontmatter change — silently dropping it.

Carry the file's frontmatter in the doc's config map instead of locking it at open: the seed
stores it, the merge updates it (only when it actually changed, preserving the no-op diff),
and the editor re-attaches THAT value on save — falling back to the locked copy before the
seed lands and for non-collaborative docs. A server-side frontmatter change is now reflected
rather than reverted. New FILE_DOC_SEED.frontmatterKey; seed/merge tests cover it.

* fix(collab-doc): re-sync draft on a frontmatter-only merge

A server edit that changes only the frontmatter updates the config map but not the body
fragment, so TipTap's onUpdate never fires — the autosave draft kept the stale open-time
frontmatter, and an explicit save could revert the live change. Observe the config map and,
on a frontmatter-only change, re-attach the new frontmatter to the current body and push a
fresh draft (guarded on a synced body so it never races the seed's own onUpdate).

* fix(collab-doc): order the apply-edit/merge timeouts (outer > inner)

The Sim->realtime apply-edit timeout (4s) was shorter than the nested realtime->Sim merge
timeout (8s), so the outer call could abort while the relay was still merging — the relay
then applied the merge after edit_content had returned, racing a follow-on edit.

Split the shared realtime->app timeout: the seed keeps 8s (it reads a cold blob), the merge
gets a tight 3s (it is a pure conversion, no I/O), and the outer apply-edit is raised to 6s
so it always outlives the inner merge. Cross-referenced in comments to prevent drift.

* fix(collab-doc): address deep-audit findings (jsdom trace, timeouts, gate, races)

A 4-agent LOC audit + precedent research (TipTap/Hocuspocus/Yjs docs confirm the core
patterns are idiomatic) surfaced these real issues:

- The /merge route also lazy-requires jsdom but was missing its outputFileTracingIncludes
  entry — a Docker/standalone build would 500 with MODULE_NOT_FOUND. Add it.
- The four conversion timeouts encoded an ordering invariant (merge<applyEdit, seed<readiness)
  living only in prose across two apps. Hoist to a shared FILE_DOC_TIMEOUTS in
  @sim/realtime-protocol with a test asserting the ordering; both apps import it.
- The copilot live-merge gate used an extension-only check, but the editor treats any
  text/markdown-MIME file as markdown. Replace isMarkdownFileName with a MIME-aware
  isMarkdownFile mirroring the client, so those files stream too.
- applyMarkdownToLiveFileDoc had no per-file serialization; overlapping merges could each
  diff the same stale snapshot and apply out of order. Serialize per file via a promise chain.
- Editor hardcoded 'config'/'initialContentLoaded' instead of FILE_DOC_SEED constants (drift).
- Add .max() bounds to the merge contract body; document the best-effort-merge failure window
  honestly (open editor + merge failure can drop a copilot edit until reload — closed by the
  deferred durable-doc work).

* feat(collab-doc): multi-replica shared Yjs backend + server-side markdown persistence

Make collaborative file-doc editing correct across multiple ECS tasks (the
per-process Y.Doc previously assumed one replica per file).

- Shared Yjs backend over Redis Streams (apps/realtime file-doc-store): each
  file's stream is the ordered, replayable log of updates; a multiplexed XREAD
  tailer converges every task's in-memory doc. Coordinated single-seeder
  election (SET NX + empty-stream recheck) fixes split-brain seeding.
- Doc-sync fans out to local clients + the stream; awareness stays on the
  Socket.IO adapter. Snapshot+XTRIM compaction trims only integrated entries.
- Server-side persistence: project the live doc back to markdown via a new
  /api/internal/file-doc/persist endpoint, debounced during editing and flushed
  on last-disconnect, from the authoritative stream state. Collaborative editors
  no longer client-autosave, closing the copilot clobber-window.
- Copilot merges apply through the stream (reach the live doc on any task) and
  serialize cross-task via a Redis merge lock.
- Degrades to the original single-replica behavior when REDIS_URL is unset.

* fix(collab-doc): harden distributed locks and durability from review

Address Greptile + Cursor review of the multi-replica backend:

- Merge lock: retry LONGER than the lock TTL (guaranteed acquisition, never
  merges against a shared base while a peer holds the lock) and AWAIT the stream
  write before releasing, so the next task never diffs a stale base.
- Distributed locks (seed/merge/compact) now use ownership tokens + a
  compare-and-delete release (Lua), so a lock that expired and was re-acquired by
  another task is never stolen; acquisition fails CLOSED on Redis error.
- Seed: publish the seed to the stream AWAITED under the lock before releasing,
  so a later seeder's empty-stream fence always sees it (closes the fence's
  publish-after-release gap); TTL kept at the readiness deadline.
- Persist: persist the AUTHORITATIVE stream state even when this task's local doc
  was never seeded, and capture the local fallback synchronously so a
  last-disconnect flush never encodes an already-destroyed doc.
- Publish: retry a transient xAdd failure so a Redis blip can't silently drop an
  edit from the shared log.

* fix(collab-doc): only persist a doc a user actually edited

Cursor review: a copilot durable write landing while a doc is being seeded could
have the stale seed projected back over it on last-disconnect, clobbering the
copilot edit even with no user changes.

Gate server-side persistence on a genuine user edit (socket-origin update): a
seed-only or merge-only doc is never projected back to the file (copilot writes
the file durably itself), so it can't clobber a concurrent external write.

* fix(collab-doc): close review-round race/durability gaps

Address Greptile P1 + Cursor findings:

- Seed publishes to the shared stream AWAITED *before* seeding the local doc, so
  a publish failure leaves the doc unseeded and the stream empty for a clean
  retry rather than serving an unpublished local seed a peer would re-seed over
  (split-brain).
- flushPersist falls back to the synchronously-captured local snapshot when
  getStreamState throws (not only when it returns null), so a transient Redis
  read no longer drops the final durable write as the room is torn down.
- streamHasContent fails CLOSED (returns true on xLen error): a Redis blip can no
  longer let the seed fence pass and double-seed.
- Client collabReady initializes from the collaborative prop, so a collaborative
  editor never has a mount-window where client autosave could clobber the server
  write.

* fix(collab-doc): unstick seed retry and persist tailed edits

Cursor review:

- ensureServerSeed clears serverSeedStarted when it aborts at the streamHasContent
  fence, so a fail-closed Redis xLen (or a genuine peer-seed) no longer strands the
  room unseeded with no retry.
- Persistence dirty-tracking now marks a doc edited on any post-seed update,
  including a peer's edit relayed via the tailer (REDIS_ORIGIN), tracked via a
  seededObserved flag. The last task to leave persists real edits even if it only
  tailed them; the seed transition itself is still never counted, so a
  seeded-but-unedited doc is never projected back over the file.

* fix(collab-doc): match client markdown post-processing on server persist

Cursor review: yDocToFileMarkdown serialized the body with yDocToMarkdown only,
but the editor save path runs postProcessSerializedMarkdown before applyFrontmatter.
Server persist could therefore write markdown differing from a client save (empty
list markers, callout un-escaping) — spurious blob churn / round-trip drift despite
the byte-identical claim.

Apply postProcessSerializedMarkdown in yDocToFileMarkdown so a server persist is
byte-identical to a client save and the client's dirty-check baseline. Add a
regression test guarding the composition.

* fix(collab-doc): close durability gaps at deploy boundaries + audit polish

From a comprehensive from-scratch audit (correctness, SOTA, cleanliness,
feature-completeness):

- Persist max-wait: a continuous edit burst kept resetting the 5s debounce and
  never persisted; cap it so a burst flushes at least every 20s, bounding
  unpersisted edits.
- Graceful-shutdown flush: flushAllFileDocRooms awaited in shutdown so a rolling
  deploy / scale-in secures open edited rooms to durable markdown before exit,
  instead of relying on the stream + a surviving task.
- Compacted-snapshot catch-up now marks the doc edited (REDIS_SNAPSHOT_ORIGIN): a
  snapshot folds seed+edits into one frame, so a task catching up purely from it
  no longer treats real edits as an unedited seed and skips persisting.
- Polish: delete dead __setFileDocStoreForTest; bounded retry loop; rename
  acquirePersistSlot -> tryClaimPersistWindow with accurate docs; tailer
  object-identity guard; fix stale comments (seed route, edit-content autosave).

* improvement(realtime): post-review fixes for collab dirty-state + relay lifecycle

- collab editor no longer latches a spurious 'Unsaved changes' prompt: report
  dirty only when the client owns durability (canAutosave), since in a
  collaborative session the relay persists the doc server-side
- guard shutdown against a double SIGINT/SIGTERM running teardown twice
- disconnect local sockets before httpServer.close so shutdown exits gracefully
  instead of hitting the forced-exit timer (local-only, deploy-safe)
- return 400 (not silent 200) on an invalid workspaceId in the files-changed fanout
- clear a pending join-retry timer on reconnect so it can't fire a duplicate join

* fix(realtime): make collab-doc seeding atomic to close split-brain window

An adversarial concurrency audit found a split-brain double-seed vector: the
seed used an advisory SET NX PX lock + a SEPARATE xLen fence + an unconditional
xAdd (a non-atomic check-then-append). If the seed lock's TTL expired mid-seed
(a >4s stall after the 8s seed fetch), a second task could acquire the freed
lock over a still-empty stream, both fences read empty, and both append seeds
with distinct Yjs client ids -> duplicated document content.

- add an atomic SEED_IF_EMPTY_SCRIPT (append-iff-empty in one Redis step) +
  store.seedIfEmpty(); the emptiness check and append are now inseparable, so
  two tasks racing (even both past an expired lock) can never both seed
- ensureServerSeed uses seedIfEmpty instead of streamHasContent-fence +
  publishAndWait; the seed lock is now purely an efficiency optimization
  (avoid a duplicate fetch), not a correctness dependency
- fix the misleading comment that claimed a copilot merge is not counted as an
  edit in the multi-replica path (it round-trips as REDIS_ORIGIN and does count;
  a safe idempotent over-persist, never a lost edit)
- add interleaving tests: seedIfEmpty atomicity/fence, the split-brain
  regression under an expired lock, a peer edit during attach catch-up, and
  concurrent two-task compaction

* docs(realtime): align seed comments with the atomic-append correctness model

Greptile flagged lingering doc drift: the module + shouldSeed comments still
credited the seed lock + empty-stream check as the split-brain fix. Reframe them
so the atomic seedIfEmpty is the exactly-once guarantee and shouldSeed is an
efficiency gate only.

* feat(realtime): live workspace tables list, sharing one invalidation-room impl (#6053)

* feat(realtime): live workspace tables list, sharing one invalidation-room impl

Bring the tables list to parity with the files list: a create/rename/move/delete/
restore now propagates to every viewer live instead of waiting out the 30s
staleTime. Following the files pattern, but factoring the two into one shared
implementation rather than copy-pasting.

- add ROOM_TYPES.WORKSPACE_TABLES + its authz resolver (workspace-id-addressed,
  reuses the workspace resolver like workspace-files)
- extract setupWorkspaceInvalidationRoom (server) and useWorkspaceInvalidationRoom
  (client) — the presence-free, workspace-scoped live-list room; files and tables
  now both bind to it, so they can never drift. Event/room names derive from the
  room type. Replaces the standalone workspace-files handler + hook
- notifyWorkspaceTablesChanged fanout fired from the table service (createTable,
  renameTable, moveTableToFolder, deleteTable, restoreTable) so it covers both the
  HTTP routes AND copilot, which call the service directly
- relay /api/workspace-tables-changed endpoint; wire the hook into the tables page
- consolidate the handler test into one suite run against both room types

* feat(realtime): live tables list also covers table-folder mutations

Fold in the follow-up: a table folder create/rename/move/delete/restore now
propagates to the tables list live too, so the browser is fully consistent.

- generic notifyFolderResourceChanged(resourceType, workspaceId) dispatches the
  workspace live-list signal by resource type (a map, not a special-case if), so
  file/knowledge_base/workflow are no-ops today and gain liveness by adding a map
  entry when they adopt an invalidation room
- fired from the shared folder lifecycle (createFolder/updateFolder/deleteFolder/
  restoreFolder), covering routes AND copilot
- the tables room hook now invalidates the table folders query too, not just the
  tables list, since the page renders both

* fix(realtime): skip per-table live-list notify during a folder cascade

A folder delete/restore already fires one folder-level notifyFolderResourceChanged
for the whole subtree, but the cascade also calls deleteTable/restoreTable per
table — each awaiting its own notifyWorkspaceTablesChanged. A folder with many
tables would run N+1 sequential relay calls (each bounded by NOTIFY_TIMEOUT_MS),
blocking the mutation. Add a skipNotify option the cascade passes so only the one
folder-level notify fires.

* feat(collab-doc): Hocuspocus binary persistence + Next 16 seed/persist fixes (#6059)

* fix(collab-doc): make server-side seed conversion work under Next 16 / Turbopack

Opening a file left both collaborators read-only and stalled ~12s: the server-side
seed (markdown -> Yjs, run through the headless editor engine) was failing, so the
doc never seeded and the editor never left its readiness gate. Two root causes,
both latent until a real build/runtime (typecheck + unit tests don't exercise
either), surfaced by the Next 16 upgrade:

1. Build boundary: the server seed route imported the shared editor schema
   (`createMarkdownContentExtensions`), which pulled in the React node-view
   components (`useEffect`) -> 'client component in a Server Component'. Split each
   node's React-free schema into its own `*-schema.ts` (code-block, image,
   raw-markdown-snippet); the client editor still injects the React node views via
   the existing `nodeViews` param, unchanged.

2. Runtime DOM: the converter installs a jsdom `window` on `globalThis`, but
   Turbopack's server bundle gives bundled `@tiptap/core` a `window` that does NOT
   read `globalThis`, so `elementFromString` threw 'no window object available'.
   Externalize the `@tiptap/*` packages the converter uses (native Node require, so
   their `window` reads the real global) and fix the converter's DOM guard to gate
   on `window` (what TipTap checks) with no sticky flag.

Verified: seed route returns 200 with the Yjs update; 514 collab-doc + editor tests
pass; schema byte-identical after the split.

* feat(collab-doc): persist the Yjs binary and load it on cold-start (Hocuspocus pattern)

Adopt the industry-standard Hocuspocus store/load-document pattern so a cold room
open loads the file's last-persisted Yjs binary directly instead of re-converting
markdown -> Yjs on every open. Rebuilding the CRDT from markdown on each connect is
the exact anti-pattern Tiptap/Yjs warn against (fresh client ids -> duplicated
content); it also forced the fragile server-side headless-editor conversion on every
open. Now conversion runs only on a genuine first open or an external markdown edit.

- new table workspace_file_collab_state(file_id PK->workspace_files cascade,
  doc_state bytea, source_hash, updated_at): the Yjs binary + a hash of the markdown
  it was derived from (bounded <=~1MB by the 256KB round-trip gate). Mirrors
  Hocuspocus's extension-database (binary in a DB column). Migration 0275.
- persist upserts the binary (tagged with the exact markdown just written)
- cold-start seed returns the cached binary when its source_hash matches the file's
  current markdown; otherwise converts (and the next persist refreshes the cache)
- also externalize yjs / y-protocols / lib0 alongside @tiptap: bundling loaded a
  second yjs copy, so @tiptap/y-tiptap's 'instanceof Y.XmlElement' failed on
  app-created nodes ('Unexpected case') during Yjs -> markdown

Verified end-to-end: seed -> persist -> seed returns the exact persisted binary (a
cache hit, no re-conversion). 18 collab-doc + 51 realtime file-doc tests pass.

* fix(collab-doc): best-effort cache read + drop dead barrel

- seed: a cache-read failure (transient DB error, not-yet-migrated cache table)
  no longer aborts a cold room open — the durable markdown is already in hand, so
  fall through to conversion. Symmetric with persist's best-effort cache write.
  Addresses the Cursor Bugbot finding on the read/write asymmetry.
- remove the collab-doc index.ts barrel: nothing imported it (every consumer uses
  direct ./seed / ./merge / ./converter imports), so it was dead re-export surface.
  De-export COLLAB_DOC_FIELD accordingly — it is used only inside converter.ts.

* fix(collab-doc): stream every external file write into open editors, not just edit_content (#6070)

* fix(collab-doc): stream every external file write into open editors, not just edit_content

A copilot/mothership edit to an open markdown file did not appear live in another
user's editor: the live-doc merge bridge (mergeEditIntoLiveFileDoc) was wired into the
edit_content tool ONLY. Every other server-side write — the file tool
(/api/tools/file/manage), function_execute (/api/function/execute via
writeWorkspaceFileByPath), create_file overwrite, and the PUT /content route — went
straight to updateWorkspaceFileContent and skipped the merge, so the durable file
changed but the open editor never updated. Confirmed from live logs (the mothership
'Prepend sentence' ran read + file + function_execute — zero apply-edit calls) and
Redis (the prepended text was absent from the doc stream).

Centralize the merge at the one chokepoint every external writer shares:
- updateWorkspaceFileContent gains an opt-out "syncLiveDoc" (default on) and, after
  the durable write, merges markdown writes into any open collaborative doc (best-effort;
  no-op when nobody has it open). Any current OR future writer is covered automatically.
- persist.ts opts out (syncLiveDoc:false) — it IS the doc→markdown projection, so merging
  it back would be a persist→merge→persist self-loop.
- create_file opts its empty shell out (real content arrives via a later write) so an open
  editor never flickers to empty on overwrite; threaded through writeWorkspaceFileByPath.
- edit_content drops its now-redundant explicit merge call (the chokepoint handles it).
- binary writers (image/video/audio/ffmpeg/download) are naturally excluded — the merge is
  gated to markdown, the only format the collaborative editor renders.

Also bump the api-validation route baseline 994→996 to match the true route count already
on this branch (pre-existing ratchet drift from an earlier merge; NOT added by this PR).

* fix(collab-doc): defer setEditable out of the render phase (flushSync warning)

The collab editability-reapply effect called editor.setEditable synchronously. In collab
mode isEditable flips from readiness (synced + seeded), which is driven by a Yjs
config.observe firing synchronously inside Y.applyUpdate — so the effect can run while React
is mid-render. TipTap's React binding commits setEditable's transaction with flushSync, which
throws "flushSync was called from inside a lifecycle method. React cannot flush when React is
already rendering." Defer the setEditable to a microtask (runs right after the current commit,
before paint), guarding against a destroyed editor or a stale value before it fires.

Only the collab path (this effect) hit the warning; the streaming/settle effect's setEditable
calls run on the non-collab path where isEditable isn't driven by a mid-render Yjs observer.

* fix(rich-markdown-editor): defer non-collab settle/stream mutations off the render phase (flushSync) (#6073)

* fix(rich-markdown-editor): defer non-collab settle/stream mutations off the render phase (flushSync)

The non-collaborative streaming/settle effect called editor.setContent / setEditable /
setTextSelection / focus directly in the effect body. setContent mounts the custom node views
synchronously through the @tiptap/react flushSync path (tiptap#3764), so when this effect runs
while React is mid-render it throws "flushSync was called from inside a lifecycle method." This
is the second flushSync source (the collab editability effect was the first, fixed separately);
it fires on the agent-streaming-into-a-non-collab-editor surface.

Defer the effect-body view mutations to a microtask via a small runOffRender helper (runs right
after the current commit, before paint; no-ops if the editor was torn down). The settle block is
deferred as ONE microtask so setContent -> collapse selection -> setEditable -> focus keep their
order. The streaming rAF tick is left untouched — it already runs off-render, so it keeps writing
content directly. queueMicrotask is TipTap's own documented remedy for this warning.

497 rich-markdown-editor tests (incl. stream-settle-selection) pass; tsc + lint + api-validation
+ boundary + prune green. Needs a live check: stream an agent into a non-collab markdown file and
confirm it still renders smoothly.

* chore(rich-markdown-editor): trim verbose flushSync-defer comments

* fix(rich-markdown-editor): drop superseded settle/stream microtasks via a run token

runOffRender previously only guarded editor.isDestroyed, so if React ran the next reconcile
pass (a newer stream or settle) before a queued microtask flushed, the stale microtask could
still apply setContent/setEditable/setTextSelection over the newer state. Tag each effect run
with an incrementing token; a deferred mutation applies only when its run is still the latest
(and the editor is alive). A run token fits this effect's several early-return exits better
than a per-exit cleanup flag. Addresses Greptile/Cursor review.

* fix(rich-markdown-editor): never drop the settle selection-collapse under a superseded run

The run token drops a superseded settle's microtask, but the settle had already flipped its state
flags synchronously — so a pre-empting steady-sync run took the non-settle path and never collapsed
the selection, leaving a post-stream select-all painting the leaf-in-selection decoration. Track the
collapse as a debt (pendingCollapseRef): whichever deferred run ultimately applies — settle or the
steady-sync path — clears it, so the collapse runs exactly once on the latest content. Addresses the
Cursor review finding.

* feat(tables): show live cell-selection carets in the embedded chat panel (#6081)

The table cell-selection presence room was joined only on the dedicated /tables/[id] page
(useTableRoom was passed an empty id in embedded mode). Join it in embedded too, so the
mothership chat resource panel shows collaborators' live cell selections and broadcasts the
local one. tableId is already resolved from props in embedded (the data event stream already
uses it un-gated), and authz runs on join, so this is safe. Avatars are unaffected — they
render only in the !embedded Resource.Header, so the panel gets carets without avatars.

* feat(collab-doc): If-Match optimistic concurrency so persist never clobbers an out-of-band edit (#6085)

* feat(collab-doc): optimistic-concurrency guard so persist never clobbers an out-of-band edit

The relay projected the live Yjs doc back to durable markdown unconditionally (last-write-wins), so
a persist already in flight when an external write landed could overwrite it. Add RFC 7232 If-Match
optimistic concurrency end to end, reconciling through the CRDT (never rejecting user work):

- updateWorkspaceFileContent gains an expectedUpdatedAt guard: the write commits only if the file is
  still at that version (checked against the SELECT ... FOR UPDATE-locked row, so it is atomic with
  the write), else it throws the new ContentVersionConflictError without clobbering.
- persistFileDoc takes expectedVersion and returns a discriminated result (persisted | missing |
  conflict). On conflict it returns the current durable content + version instead of writing.
- The relay tracks the durable version its live doc is synced to — set on seed, advanced when a
  durable write is merged in (apply-edit carries the version), and on each successful persist. It is
  held cluster-wide in Redis (filedoc:syncver:{name}) so whichever task persists reads the same
  version, with the per-room value as the single-pod fallback.
- flushPersist sends that version as If-Match. On a conflict it merges the current durable content
  into the live doc (so the out-of-band edit AND the live edits converge) and retries (bounded), so
  even a last-leave flush racing an external write persists the reconciled result rather than losing
  the session's edits.

Threads the version through the seed + persist contracts and the apply-edit payload. No schema change
(reuses workspace_files.updatedAt as the version token). Tests: app-side CAS (match writes, mismatch
throws + cleans up the orphan upload), relay conflict handled gracefully without clobber/loop; 236
realtime + 76 sim collab/uploads tests, tsc x2, lint, api-validation, boundaries, prune all green.

* chore(collab-doc): heartbeat-refresh the synced-version key TTL alongside its stream

Keep filedoc:syncver:{name} alive as long as the room's stream (it was only re-set on
seed/merge/persist), so an open-but-idle doc's persist If-Match token can't expire and force a
needless reconcile.

* fix(collab-doc): stop persist-conflict retries when there is no live doc to reconcile

On an If-Match conflict with no live doc to reconcile into (last collaborator gone, no shared
stream), applyMarkdownToLiveFileDoc returns no-live-room; re-projecting the same pre-teardown
snapshot would only re-conflict, so break the retry loop immediately and leave the out-of-band
(durable) content authoritative — the intended conflict policy. Addresses Greptile review.

* fix(collab-doc): close three optimistic-concurrency edge cases from review

- Single-pod persist retry projected the pre-reconcile snapshot (captureState always returned the
  initial localState), while the synced version had been advanced by the reconcile — so the If-Match
  could pass and clobber the reconciled edit. captureState now re-reads the live doc on each attempt
  (falling back to the pre-teardown snapshot only once the room is gone).
- The synced version was recorded from this task's own seed FETCH before knowing whether this task's
  seed actually won; a peer winning with a different version could leave a newer token than the stream
  content. Record it only inside the didSeed branch (the task whose seed won); peer-seeded tasks read
  the winner's cluster value.
- Persist wrote UNCONDITIONALLY when no version was available (relay version momentarily missing), which
  could clobber non-empty durable content. It now returns conflict for a non-empty file with no version
  (reconcile/retry once the version is re-established); an empty file's first write stays unconditional.

* fix(collab-doc): defer (not reconcile) on missing version, and use the freshest version token

- Missing-version persist now returns 'deferred' instead of 'conflict'. A missing version token (a
  Redis blip on a peer-seeded task) is NOT a genuine out-of-band change, so triggering a reconcile
  would wipe live edits (incoming-wins) even though nothing changed durably. Deferred means: don't
  write, don't reconcile — leave the edits in the stream and let a later persist write them once the
  version is re-established.
- currentVersion now takes the MAX of the cluster (Redis) and local room versions rather than always
  preferring Redis, so a lagged/failed fire-and-forget Redis set can't shadow a newer local value and
  cause spurious If-Match conflicts. Versions are monotonic epoch-ms, so the larger is the later sync.

* fix(collab-doc): make persist If-Match teardown-race-immune and recover missing version on final flush

Close two last-leave concurrency holes Cursor flagged:

- Thread the reconciled version LOCALLY through the persist retry loop. After a
  conflict+reconcile the correct next If-Match is exactly result.version, so carry
  it in a local var instead of re-deriving from room.syncedVersion/Redis. On a
  last-leave flush destroyRoomIfIdle removes the room from the map before the async
  flush finishes, so mergeMarkdownIntoRoom's recordVersion can no longer update
  room.syncedVersion — threading makes each retry's precondition correct by
  construction, immune to that dropped mutation and to a best-effort Redis re-read.

- Cache the resolved version back into room.syncedVersion in currentVersion() so a
  peer-seeded/tail-only task (which never sets it locally) or a later transient
  Redis read failure still resolves it from the last value seen (monotonic max,
  never regresses).

- On a FINAL flush, briefly retry resolving the If-Match when the version read
  momentarily fails, rather than deferring and stranding the session's edits in the
  TTL'd stream — the version is cluster-wide and heartbeat-refreshed.

* fix(collab-doc): stamp cluster sync version the moment the seed wins, before the liveness guard

The winning seeder set the If-Match token (room + Redis filedoc:syncver) only after the
liveness/seeded guard that follows seedIfEmpty. But the tailer can integrate the just-appended
seed DURING the seedIfEmpty await, so isDocSeeded(room.doc) is already true when the guard runs
and it returns early — leaving the stream holding seed content with no cluster version. Later
persists then send no If-Match, the app returns `deferred`, and session edits stay only in the
TTL'd stream (the exact stranding this PR prevents elsewhere).

Move the version stamp to immediately after seedIfEmpty wins, before the guard. Recording it only
once our seed won (not from the fetch) is preserved, so it still can't shadow a peer's winning
seed.

* fix(collab-doc): make the synced-version token monotonic at every write site

The If-Match token is written fire-and-forget from the seed stamp, merges, and persists, both
locally and to Redis. An out-of-order write (e.g. a seed's lagged setSyncedVersion landing after a
later merge's) could regress it below the version the live doc already incorporates, causing
spurious If-Match conflicts — and on a last-leave flush with no live room to reconcile into, a
spurious conflict leaves durable authoritative and drops the session's edits.

- setSyncedVersion now writes via SET_VERSION_IF_NEWER_SCRIPT (Redis-side compare-and-set): it
  overwrites only when the new value is greater, refreshing the TTL either way.
- recordVersion / the persisted branch / the seed stamp all take Math.max instead of assigning
  room.syncedVersion directly.

Versions are monotonic epoch-ms, so "newer" is a plain numeric compare, exact within a Lua double.

* fix(collab-doc): close three last-leave persist edge cases from review

- Stale snapshot after reconcile (High): the multi-task captureState fell back to the pre-await
  localState snapshot even after a reconcile advanced ifMatch, so a failed stream re-read could
  persist the pre-reconcile state against the new version and clobber the out-of-band edit the
  reconcile just incorporated. NULL localState after a reconcile so a failed read aborts instead.

- Lock miss aborts reconcile (Medium): a merge-lock acquisition failure returned 'no-live-room',
  indistinguishable from an absent stream, so flushPersist treated transient contention as
  terminal. Return a distinct 'merge-unavailable' and handle it as retry-later (edits stay in the
  stream), never as "nothing to reconcile into".

- Peer syncver never recovers (Medium): the winner's setSyncedVersion was fire-and-forget with
  swallowed errors — the only way a peer-seeded task learns the durable version — so a dropped
  write left that peer deferring forever. Make it retry (bounded) like appendUpdate/seedIfEmpty;
  the monotonic script keeps a racing retry a no-op.

* fix(collab-doc): scope the persist If-Match to a content version so metadata bumps can't clobber edits

The optimistic-concurrency validator was `updatedAt`, which rename/move/delete/restore also bump
with no content change. A racing live-doc persist then saw a stale token, got `conflict`,
reconciled the pre-edit durable body via updateYFragment (incoming-wins on overlap), and wiped the
user's in-flight edits.

Scope the validator to content (RFC 7232 semantics — validate the representation, not the row):
- New `workspace_files.content_updated_at` (NOT NULL, `now()` fast-default — no table rewrite).
  Advances ONLY on content writes (upload / overwrite / create); metadata writes never touch it.
- The FOR UPDATE CAS, the merge-notify version, and the seed version all use `content_updated_at`.
  A rename now leaves it unchanged, so the persist If-Match still matches -> no spurious conflict,
  no reconcile, no lost edits. Genuine out-of-band content writes still conflict and reconcile.
- Consolidated the collab schema into one migration (the collab-state table + the new column) per
  request, rather than a separate follow-up migration.

Relay/store/contracts unchanged (still a numeric monotonic version).

* chore(collab-doc): condense the densest persist comments (no behavior change)

Cleanup pass: tighten the three longest comment blocks added while hardening the persist path
(currentVersion cache, ifMatch threading, final-flush version retry) without dropping any invariant.
No dead code found (biome lint clean; all new symbols referenced).

* fix(collab-doc): persist must return the content version, not updatedAt

Follow-up to the content-scoped If-Match: persistFileDoc still returned `updatedAt` as the version
in both the persisted and conflict results, while the CAS/seed/merge all guard on
`content_updated_at`. A content write sets both to the same instant, so it was coincidentally
correct — until they diverge: if a metadata write bumps `updatedAt` past `content_updated_at`, the
conflict path returned the larger `updatedAt`, so the relay's re-persist sent an If-Match the CAS
(which checks `content_updated_at`) could never match → perpetual conflict → dropped reconciled
edits. Return `contentUpdatedAt` in both paths so the relay's token always matches what it's checked
against.

* fix(collab-doc): defer persist whenever the version is missing; guard the content-version test

- Empty-file CAS race (Medium): the unconditional-write carve-out for size===0 read `record.size`
  outside the write transaction, so a concurrent first content write could land after the check and
  be clobbered. With content_updated_at NOT NULL every existing file always has a real version, so a
  missing expectedVersion is always transient — always defer, never write unconditionally. Removes
  the TOCTOU hole.
- Content-version test (Low): the merge-chokepoint test kept updatedAt == contentUpdatedAt, so it
  passed even if wired to the wrong field. Mock distinct values and assert contentUpdatedAt, so a
  regression to updatedAt now fails the test.

* fix(collab-doc): don't reconcile a conflict the live doc already reflects (would wipe newer edits)

flushPersist reconciled the durable body into the live doc on every conflict. But when the conflict
comes from a racing self-persist (or an apply-edit the chokepoint already merged), the durable body
is a STALE SUBSET of the live stream, and the incoming-wins updateYFragment merge moves the doc
backward — wiping newer in-flight edits, which the retry then persists.

Before reconciling, re-check the freshest synced version. If it already covers the conflict version,
the live doc has already incorporated that content (or is ahead), so skip the reconcile and just retry
with the freshest version as If-Match — the re-projection captures the current live stream, preserving
every edit. Only a genuine out-of-band change the live doc hasn't incorporated (freshest < conflict
version) is reconciled in. freshest never exceeds the durable version, so this can't loop.

* fix(collab-doc): make content_updated_at monotonic per file; skip-reconcile can't loop

The If-Match token was stamped with app-local new Date() on each content write, so cross-instance
clock skew could stamp a later write with an EARLIER content_updated_at — breaking the version
ordering the whole optimistic-concurrency scheme (and the skip-reconcile branch's freshest>=version
assumption) depends on. Under skew the relay's monotonic syncedVersion could exceed the durable
version, sticking the If-Match: persist conflicts forever, exhausts retries, drops the session's edits.

- Stamp content_updated_at strictly after the current committed value (we hold the row's FOR UPDATE
  lock): new Date(max(now, currentFile.contentUpdatedAt + 1ms)). Monotonic per file regardless of
  clocks; also removes same-millisecond collisions. updatedAt stays plain wall-clock (display/sort).
- Skip-reconcile branch retries with result.version (the durable value the CAS will match), never
  freshest (which could exceed it and loop). Belt-and-suspenders now that the version is monotonic.

* refactor(collab-doc): drop the destructive in-persist reconcile; adopt-version-and-retry on conflict

The in-persist reconcile projected the durable body back over the live doc via updateYFragment
("make the doc match"). That is destructive: when the live stream is already ahead — the common case,
because the write chokepoint (mergeEditIntoLiveFileDoc) already merged the out-of-band change into the
stream — it moved the doc backward and wiped newer in-flight edits. This produced a run of races
(stale snapshot, wipe-newer-edits, version-lag skip miss) that a full-document reconcile fundamentally
can't avoid, since deciding when it's safe relies on a laggy cross-task version token.

Remove it. On conflict, adopt the durable version as the new If-Match and retry: captureState re-reads
the current stream (which holds the out-of-band change AND the live edits), so the re-projection
persists the converged result. The durable change reaches the live doc via the chokepoint, never here.
Trade-off: the only unmerged out-of-band write is one whose chokepoint merge itself failed (rare,
logged), which we accept over the frequent reconcile-wipes-edits race.

- flushPersist: conflict -> ifMatch = result.version, retry (bounded). No applyMarkdownToLiveFileDoc.
- conflict response drops `markdown` (contract + relay type + persist) — no body needed, saves a blob
  fetch. applyMarkdownToLiveFileDoc stays (still used by the apply-edit route / the chokepoint).

* fix(collab-doc): don't let a last-leave conflict retry clobber via the stale local snapshot

Regression from dropping the reconcile: on conflict the retry adopts result.version and re-reads
captureState. But after single-pod last-leave teardown the room is already destroyed, so captureState
falls back to the pre-teardown localState (which lacks the out-of-band change); the retry then CAS-passes
and overwrites the committed external write — undoing the external-wins last-leave policy.

Null localState on the first conflict, so the retry can only use freshly-read authoritative state
(stream / live doc). When none is available (single-pod room gone, or a transient stream-read failure)
captureState returns null and the retry stops, leaving durable content authoritative. Covers both the
single-pod and multi-task-stream-unavailable variants of the stale-snapshot clobber.

* fix(collab-doc): stop (don't re-persist) on a persist conflict — closes the commit-window clobber

The conflict retry adopted the durable version and immediately re-persisted the current stream,
assuming the stream already held the out-of-band change. But an external write commits durable BEFORE
its chokepoint merge (mergeEditIntoLiveFileDoc) reaches the stream, so a persist landing in that window
CAS-passed with a stream that still lacked the external content and clobbered the committed write — not
just the rare merge-failed path, but a race on every external write, worst at last-leave flushes.

Make persist a single attempt: on conflict, STOP and leave durable authoritative. The chokepoint merges
the change into the stream and — only once it is actually there — advances the synced version via its own
recordVersion; a later flush (debounced or final) then projects the converged stream with a matching
token. The session's edits stay in the stream meanwhile. The conflict handler deliberately does NOT
advance the synced version, or the next flush would clobber with a still-behind stream. Removes the retry
loop and PERSIST_CONFLICT_RETRIES.

* improvement(tables): fire the live-rows signal on async delete, run cancel, and column run (#6094)

* improvement(tables): fire the live-rows signal on async delete, run cancel, and column run

These three table operations mutate row data but emitted no `rows` change signal, so open editors'
grids stayed stale until a manual refresh (enrichment *results* already stream live via `cell` events;
these are the bulk paths that don't emit per-cell events):

- Async row delete (`runTableDelete`): signal as rows drop out (throttled with the existing progress
  event) and once more on completion — the `job` progress event only drives the delete meter, not the
  rows query. Covers the delete-async route and the copilot bulk-delete, since both share the runner.
- Cancel runs (`cancel-runs` route): cancelling clears each affected row's exec state; the
  `dispatch: cancelled` events drop the run overlay but the client then renders authoritative DB state,
  so refetch. Only when something was actually cancelled.
- Run column (`columns/run` route): starting a run bulk-clears the target group's cells to pending;
  refetch so the cleared cells show. Only when a dispatch was actually created.

Guarded so no signal fires on a no-op/failure. Adds a delete-runner test asserting the completion signal.

* fix(tables): guarantee the live-rows signal on every mutating path (review)

- Delete runner (Greptile P1): a batch could commit and the job then cancel/supersede before the next
  throttled progress signal or `markJobReady`, bypassing both signals and leaving deleted rows on
  screen. Track `deletedAny` and fire the grid refetch in a `finally`, so it runs on EVERY exit —
  completion, cancel/supersede, mid-batch lock, or a rethrown error after a partial delete.
- cancel-runs / columns/run routes (Cursor): the `cancelled > 0` / `if (dispatchId)` guards don't
  always reflect DB row changes — cancel tombstones exec state even when 0 dispatches were active, and
  a run bulk-clears cells then can return a null dispatchId. Signal unconditionally; a stale-but-harmless
  refetch beats a missed one.
- Tests: assert the delete signal fires on the mid-run-cancel-after-delete path and NOT when nothing
  was deleted.

* fix(tables): mark deletedAny before the page delete so a mid-page lock still refreshes the grid

`deletePageByIds` commits in internal batches, so a delete lock landing mid-page can persist earlier
batches and THEN throw TableLockedError — the catch returns without a count, so setting `deletedAny`
from the return value missed it and the finally skipped the grid refetch. Set `deletedAny = true` before
the call (any attempt may commit rows); an attempt that commits nothing only over-refetches (harmless).
Adds a test asserting the signal fires when a page throws a mid-page lock.

* fix(files): make embedded resource file view collaborative (#6095)

* fix(files): make embedded resource file view collaborative

The /chat resource panel rendered saved files through FileViewer without
the collaborative opt-in, so a file open on the Files page and the same
file open in the embedded panel never joined the same file-doc room —
no live carets and no live content sync between the two surfaces.

Pass collaborative on the EmbeddedFile FileViewer. Collaboration still
self-gates on canEdit + non-streaming + workspace doc, so the agent
token-stream preview (the dedicated streaming-file path, canEdit=false)
is untouched.

* fix(files): refcount file-doc room membership per shared socket

Two collaborative surfaces in one tab (the Files editor and the embedded
chat resource panel) share one Socket.IO connection, so both providers for
the same file JOIN the same room over that socket. The server's LEAVE does
socket.leave(name) with no membership refcount, so the first provider's
destroy() would strand the second still-mounted one — no more live content
or presence.

Count live providers per file per socket (keyed by the stable Socket object,
so it survives reconnects) and emit LEAVE only when the last provider for a
file tears down. The single-provider path is unchanged (0->1->0).

* feat(tables): propagate shared saved-view changes to collaborators live (#6100)

Table views (named filter/sort/layout presets) are table-wide shared state —
every reader sees every view — but view create/update/delete had no realtime
signal, so a collaborator only saw another user's view changes on their own
staleTime/focus refetch.

Add a 'views' table event kind + signalTableViewsChanged, emitted from the
views service (createTableView/updateTableView/deleteTableView, on real
success only), and a client handler that invalidates the views query alone
(no rows/definition refetch — a view is presentation state on the loaded
table). Mirrors how row/schema/metadata changes already propagate.

* test(tables): cover the views realtime signal (emit + on-success-only) (#6101)

- events.test.ts: signalTableViewsChanged appends a single 'views' event
  carrying the tableId (through the real memory buffer).
- views/service.test.ts: create/update/delete emit signalTableViewsChanged
  on real success, and DON'T on a no-op (a PATCH/DELETE targeting a missing
  view changes nothing, so it must not signal). Mirrors delete-runner's
  signal-path coverage; drives the DB via the shared dbChainMock.
- Add tableViews to the comprehensive @sim/db/schema test mock so the
  service tests can queue the in-transaction existence row.

* chore(ci): reconcile api-validation baselines after the staging merge

The staging merge unioned realtime-rooms's own `as unknown as` cast
(lib/collab-doc/converter.ts) with staging's zod-recursive-type cast
(lib/api/contracts/tables.ts), so the non-test double-cast count is 9 —
both casts pre-existed and were individually accepted on their branches.
Also tighten rawJsonReads 6->5 to the true current count. Fixes the strict
API contract boundary audit on realtime-rooms.

* feat(copilot): stream file edits into the live collaborative Y.Doc (keep embedded view collaborative) (#6108)

* feat(copilot): stream file edits into the live collaborative Y.Doc

Copilot's file edits previously only reached the live doc once, at the final
edit_content write, so a collaborative editor watching the file saw nothing
until completion (streaming looked broken) and the client-side preview path
was suppressed in collab mode.

Make copilot a CRDT peer: as it streams append/update/patch content, merge the
growing markdown into the file's live Y.Doc via the existing apply-edit path
(a minimal updateYFragment diff, concurrent-edit-safe), throttled to ~250ms.
version is omitted for these intermediate merges — they advance the live doc
for viewers but are not durable checkpoints; the final edit_content write
carries the real contentUpdatedAt and reconciles the durable file. Per the
relay's persist gating, server-internal merges never schedule a persist, so a
copilot-only stream produces zero intermediate file writes.

- notify.ts: mergeEditIntoLiveFileDoc version is now optional (streaming omits it).
- file-preview-adapter.ts: throttled live-doc merge at the edit_content stream hook.

* fix(copilot): order + gate streaming live-doc merges; fast collab first render

Harden the streaming merge (adversarial review):
- Order + bound: dispatch through a per-file in-flight guard (drop-while-in-flight)
  so a stale out-of-order snapshot can never land after a newer one and regress
  the doc, and relay load is capped at one request per file regardless of rate.
- No wipe: gate append/patch on the base file content having loaded — a base-less
  snapshot would diff to a delete-everything wipe of the seeded doc; update streams
  a full rewrite from scratch and needs no base.
- Markdown-only gate: non-markdown files have no collaborative room, so skip the
  wasted relay round-trip.

Fast collab first render (Issue 2): render the already-fetched markdown read-only
via generateHTML while the collaborative doc seeds, with the editor mounted-but-
hidden in the same layout box for a seamless swap on collabReady. Pure HTML — it
never touches the Y.Doc (client seeding duplicates the doc), and generateHTML
escapes text (raw-HTML snippets render escaped), so no XSS.

* test(copilot): cover streaming file edits into the live collaborative Y.Doc

Drives edit_content args_delta stream events through processFilePreviewStreamEvent
and asserts the live-doc merge: fires with the growing FULL previewText and no
version arg; is throttled (~250ms per file); is skipped for non-markdown files
and for a base-less append (the delete-everything wipe guard); and runs at most
one-in-flight per file. Verified to fail if any gate/guard is removed.

* fix(collab-doc): coordinate live-doc merge ordering in one place; close durable-clobber race

The second review found a residual: the durable edit_content write went through a
different path than the adapter's in-flight guard, so a late straggler streaming
merge could land after it and, via a persist, clobber the durable file's tail.

Move the per-file coordination into mergeEditIntoLiveFileDoc (the one place both the
streaming and durable paths call): a streaming (versionless) merge is dropped while
one is in flight for the file; a durable (versioned) write instead WAITS for the
in-flight streaming merge, so the final content is always the last merge applied and
can't be regressed by a straggler. Simplifies the adapter (drops its Set + helper).

Relocate the one-in-flight test to notify.test.ts (streaming-drops-while-busy +
durable-waits-then-applies-last); the adapter test keeps throttle/gates/previewText.

* fix(copilot): address review — order merges, exclude update, gate throttle, unhide stream

Review round on #6108:
- Greptile P1 (durable merges lose ordering): serialize ALL merges per file on one chain in
  mergeEditIntoLiveFileDoc (each chains after the current tail), so concurrent durable writes
  can't resume-and-fire out of order. notify now exposes isLiveDocMergeInFlight.
- Cursor High (update stream blanks the doc): only append/patch stream — they build on the loaded
  base; update is a from-scratch rewrite whose partial snapshot would diff the full doc toward a
  fragment, so it applies atomically at the durable write.
- Cursor Medium (throttle advances on a dropped merge): the adapter gates on !isLiveDocMergeInFlight,
  so the send throttle advances only on an actual dispatch — no lag, no backlog behind a slow relay.
- Cursor Medium (placeholder hides a live stream): show the fast-render placeholder only when not
  streaming, so a stream that starts before the doc seeds shows through the editor.
- Soften merge.ts/notify.ts comments per the lifecycle audit: only UNTOUCHED regions are preserved;
  a region the merge rewrites reconciles toward copilot's content.

Tests updated: notify covers chain ordering + isLiveDocMergeInFlight; adapter covers append streaming,
throttle, non-markdown/base-less/update skips, and the in-flight skip.

* fix(collab-doc): reject stale durable merges at the relay (cross-process ordering)

The in-process merge chain only orders merges within one apps/sim process. Two durable
writes for the same file on DIFFERENT processes could reach the relay out of dispatch
order; the relay recorded the version monotonically but still APPLIED the older markdown,
regressing the live doc while the token stayed high (a later persist could then write the
stale content back over the durable file).

Enforce ordering at the relay — the single cross-process coordination point — using the
existing Redis primitives: under the per-file Redis merge lock, read the cluster-wide
synced version and SKIP a versioned merge that is not newer (a newer durable write already
landed). Make recordVersion await setSyncedVersion so it is durable before the lock
releases, so the next holder's staleness check reads a consistent value. Streaming
(versionless) merges are unaffected — they carry no durable version and are ordered
per-process by the caller.

Adds a relay test asserting a stale/idempotent versioned merge returns 'stale' and never
computes or publishes a diff.

* fix(copilot): match durable path — detect markdown by MIME type + name at the stream gate

The streaming gate checked isMarkdownFile with only the filename, while the durable merge
uses type + name — so a text/markdown file without a .md extension was skipped mid-stream
(it self-corrected at the durable write). Pass editIntent.contentType so streaming detects
the same set of markdown files as the durable path.

* test(copilot): assert throttle follow-through after an in-flight merge clears

* fix(collab-doc): order streaming merges by streamedAt so a late snapshot can't regress a newer durable write

* refactor(collab-doc): tidy merge-order docs + relay order object; cover multi-replica streaming stale-check

* fix(collab-doc): order streaming merges by causal base version, not wall-clock

A streaming snapshot now carries baseVersion (the durable contentUpdatedAt it was
built from) instead of a wall-clock streamedAt. The relay drops the snapshot when a
newer durable write landed since that base, so a concurrent human save can no longer
be clobbered in the live doc and then persisted over the durable file. Skew-immune:
both keys are DB-monotonic contentUpdatedAt values.

* fix(collab-doc): derive streaming baseVersion as contentUpdatedAt ?? updatedAt

Match the version line the seed/persist use so a legacy file with no content
version still ships an ordered streaming snapshot instead of an unordered one.

* fix(collab-doc): fail-closed on a streaming snapshot with no baseVersion

The live-merge gate now requires a numeric baseVersion, not just loaded base
content. A rare base with no file record (hence no version) would otherwise ship
an unordered snapshot the relay can't stale-check, risking a clobber of a
concurrent durable write. Skip the live merge instead; the durable write reconciles.

* docs(collab-doc): document the accepted concurrent-independent-streams limitation

* chore(ci): reconcile api-validation baseline after the staging merge

The merge commit auto-merged the baseline at 1000; bump totalRoutes/zodRoutes to
1003 for staging's three new contract-bound routes (nonZodRoutes still 0).

* test(files): update storage-accounting assertion to the mergeEditIntoLiveFileDoc options object

* fix(collab-doc): trace the full yjs/tiptap external stack into the file-doc route bundles

The seed/merge/persist internal routes run the collab-doc converter (markdown <-> Yjs
via headless TipTap) server-side. Those deps are serverExternalPackages, and the
standalone tracer only force-included jsdom — it does NOT follow yjs's ESM subpath
imports of lib0 (lib0/logging, ...), so Docker/standalone builds shipped node_modules
without them and the seed route 500'd (Cannot find module 'lib0/logging'). That left
every collaborative document unseeded and permanently read-only on deployed envs.
Force yjs, lib0, y-protocols, and @tiptap into the trace for all three routes.

* fix(collab-doc): copy the full yjs/lib0 stack into the app image

The seed/merge/persist routes run the converter (markdown <-> Yjs) server-side. yjs is a
serverExternalPackage and the Next standalone tracer copies lib0 only partially — it drops the
ESM subpath file lib0/logging.js that yjs.mjs imports via lib0's exports map, so the seed 500s
('Cannot find module lib0/logging') and every collaborative doc is stuck read-only. Verified in
the running dev container: /app/node_modules/lib0 had 37/38 files, logging.js missing.

outputFileTracingIncludes can't fix it — its globs resolve against apps/sim, but these deps hoist
to the monorepo-root node_modules, so the glob matches nothing (my prior next.config attempt was a
no-op; reverted). Instead COPY the complete lib0/yjs/y-protocols from the deps stage in the runner,
overwriting the partial trace — the same pattern already used for isolated-vm.

* feat(files): stream copilot edits into the collaborative doc smoothly (#6122)

* feat(files): stream copilot edits into the collaborative doc smoothly

- apply the agent stream client-side into the live Yjs binding as minimal
  updateYFragment diffs (like main's setContent, but incremental) so it renders
  smoothly AND broadcasts to every peer via CRDT — a collaborator on /files sees
  the stream for free
- gate the apply on collabReady so diffs never land on an unseeded doc; keep the
  read-only placeholder visible until the seed swaps in
- run streamed ops under a dedicated tx origin so they stay out of the user's
  undo stack
- delete the throttled server-side streaming merge and the baseVersion ordering
  machinery it needed (relay + notify + session contract); the durable final
  write still reconciles open editors and seeds late joiners

* fix(files): apply agent stream as a true CRDT peer + guard base-less snapshots

Review round 1 (Greptile P1s):
- apply the stream against a private shadow replica (seeded from the live doc at
  stream start) and relay only the agent's own delta into the shared doc, so a
  concurrent peer edit to a region the agent snapshot didn't include is no longer
  reverted (previously the whole-body reconcile deleted it)
- gate append snapshots on "must extend the base": a base-less append fragment
  (emitted before the base loads) can no longer reconcile the seeded doc to a wipe;
  patch still legitimately replaces a mid-region
- gate the apply on collabReady so diffs never land on an unseeded doc; keep the
  placeholder visible until the seed swaps in
- plumb streamOperation through the preview surfaces to drive the append gate
- add a peer-edit-preservation test (fails under whole-body reconcile) and refresh
  the undo-isolation + broadcast tests for the session API

* fix(files): destroy the agent shadow deterministically on settle

Cursor round 1 (Low): endAgentStream ran inside runOffRender, whose microtask is
dropped when a rapid follow-up stream bumps the run token — leaking the shadow
Y.Doc. Split it out into an unguarded microtask queued after the (droppable) final
apply, so the shadow is always destroyed.

* fix(files): agent stream frames skip the relay's durable persist

Cursor round 1 (High): client-applied stream frames broadcast over the sync
channel, so the relay stamped a socket origin and ran schedulePersist — durably
writing partial agent content mid-stream, attributed to the watching user (the old
server-merge applied with no origin and never did). Restore that behavior:

- new FILE_DOC_MESSAGE_TYPE.SYNC_NO_PERSIST wire tag; the provider tags
  AGENT_STREAM_ORIGIN updates with it (normal user edits stay SYNC)
- the relay applies it under an AgentSyncOrigin (carries the socket id for
  broadcast exclusion, but is not a plain string) so originSocketId() is null →
  no edited/schedulePersist/lastEditorUserId; excludeSocketId() still excludes the
  sender, and the update still publishes to the stream so peers converge
- the copilot's final edit_content write remains the authoritative durable persist
- tests: relay applies+fans-out but never persists a SYNC_NO_PERSIST frame
  (verified it fails if applied as a socket edit); provider tags agent edits

* fix(files): open the stream shadow at start + private extend baseline

Cursor round 2:
- High (settle skips apply without session): the stream shadow is now opened on
  the first ready frame, BEFORE the extend gate — so an `update` rewrite (whose
  every frame is gated out until settle) and a stream that finishes before seed
  still get a session, and settle applies the final body via the reused-or-on-demand
  shadow instead of leaving the doc stale until the durable reconcile.
- Medium (peer edits stall the stream): the extend gate now reads a private
  `lastStreamedBodyRef` (the agent's own last frame), snapshotted at stream start,
  not `lastSyncedBodyRef` which `onUpdate` clobbers on peer edits — so a collaborator
  typing can't make the growing snapshot stop prefixing the shown body and freeze it.
- Medium (multi-replica over-persist): pre-existing, documented "safe over-persist"
  (a peer task tails the frame as REDIS_ORIGIN and marks edited) — refreshed the
  stale comment to describe the SYNC_NO_PERSIST source; copilot's edit_content write
  remains the authoritative durable persist.

* fix(files): fail-close base-less previews + operation-based stream hold

Cursor/Greptile round 3 (High + Medium) — remove the fragile string-prefix
"extend gate", which was the root of both findings:

- Server: `buildFilePreviewText` now fails closed for an `append` whose base
  content hasn't loaded (returns undefined, like patch/update), so a base-less
  fragment never reaches the client. This eliminates the base-less wipe at
  settle (Greptile P1) at the source; an empty file (existingContent === '')
  still previews normally.
- Client: the collab streaming tick no longer string-prefixes the raw preview
  against the editor's canonical markdown (the '*' vs '-' / emphasis mismatch
  that froze every append frame — Cursor). The mid-stream hold is now purely
  operation-based: `update` waits for settle; append/patch/create apply each
  frame via the (peer-safe) shadow reconcile. lastStreamedBodyRef is now a plain
  dedup guard, not a prefix baseline.

Keeps the shadow, durable write, and SYNC_NO_PERSIST unchanged.

* fix(files): elect a single agent-stream writer across tabs

Cursor round 4 (High): with the stream applied client-side, two tabs/windows on
the same chat could each derive streamingContent (the reconnect/resume path
re-consumes preview events) and each independently insert the stream under a
different Yjs clientID, duplicating content until the durable reconcile.

Fix — single-writer election via the file-doc awareness (new agent-stream-leader):
- a client applying an agent stream announces `agentApplying` on its own awareness
- only the leader (min clientID among announcers) applies mid-stream AND at settle;
  a non-leader renders the leader's ops via Yjs and does not apply (a non-leader
  applying the final body would re-insert the whole doc as a duplicate)
- re-checked each frame, so it converges to one writer the moment awareness
  propagates; the sub-frame startup race is reconciled by the durable write
- single-client (the common case) is unaffected: it is the only announcer, so it
  always leads

* fix(files): gate the settle apply locally, not on a settle-time re-election

Cursor round 5 (High): the settle recomputed leadership from live awareness and
the leader cleared its announcement immediately, so a straggler peer that settled
afterward became the sole announcer, self-elected, and applied finalBody through
its base-seeded shadow — re-inserting the whole doc as a duplicate.

Fix: gate the settle apply on a LOCAL didApplyStreamRef (set only when this client
actually applied a mid-stream frame — i.e. it was the mid-stream leader whose
shadow is up to date), not on a settle-time re-election. A client that never
applied (non-leader, a held `update`, or a pre-seed stream) skips the final apply
and converges via Yjs + the durable write. The mid-stream leader election
(isAgentStreamLeader) is unchanged, so exactly one client's didApplyStreamRef is
ever true.

* fix(files): open the agent-stream shadow lazily on lead (no stale handoff)

Greptile round 6 (P1): the leader race — (a) a mid-stream leadership handoff
could apply from a stale pre-stream shadow, and (b) two tabs starting the same
stream before awareness converges could both lead briefly.

- (a) fixed: the shadow is now opened LAZILY in the tick, only when this client
  actually leads, seeded from the CURRENT doc — so a handoff successor diffs
  against the prior leader's ops (never a stale base) and a non-leader builds no
  shadow at all. Announce candidacy via a dedicated ref (decoupled from the
  shadow); settle still gates the final apply on didApplyStreamRef (leader-only).
- (b) the pure startup race is inherent to eventually-consistent election. It is
  now the only residual: bounded to two tabs starting the SAME stream within the
  awareness-propagation window, transient (converges in a frame or two), and
  never persisted (SYNC_NO_PERSIST + the durable edit_content reconcile). Resumes
  are sequential, so the common multi-tab case elects cleanly. Documented inline;
  a server-granted lease would close it fully but at a round-trip cost on the
  common single-tab path, which isn't worth it.

* fix(files): idempotent settle apply (update lands client-side; no straggler dup)

Cursor round 6 (Medium): a lone client's `update` never applied client-side —
held mid-stream, then skipped by the didApplyStreamRef settle gate — so the
rewrite depended entirely on the durable merge (stale if delayed/failed).

Root cause was over-correcting round 5. Now that the shadow is opened lazily in
the tick (current-seeded), the round-5 base-shadow duplication is already gone,
so didApplyStreamRef is unnecessary. Replaced it: settle applies the final body
via `agentStreamSessionRef.current ?? beginAgentStream(editor)` — the leader
reuses its up-to-date shadow (last throttled frame), while a client that never
applied (non-leader, held `update`, pre-seed) opens a FRESH current-seeded shadow.
Reconciling current->final is idempotent: a straggler that settles after another
wrote the final reconciles to a noop. So a lone `update` applies at settle (no
wait on the merge), and there's still no settle-time election or base-shadow dup.

* fix(files): broadcast agent frames to the whole room (same-socket siblings)

Cursor round 7 (Medium): SYNC_NO_PERSIST frames applied under an origin carrying
the sender socket id, and excludeSocketId dropped that whole socket from the
relay fan-out. A second FileDocProvider on the same socket (chat preview + Files
editor) then missed all mid-stream ops and stayed stale until the durable
reconcile — a regression from the old no-origin server merge, which reached both.

Fix: the agent origin is now a plain AGENT_SYNC_ORIGIN symbol, and agent frames
broadcast to the WHOLE room (no socket excluded), matching the old behavior — so a
same-socket sibling provider stays live; the emitting provider no-ops on its own
echo (the ops are already applied locally). originSocketId still returns null for
the symbol, so it keeps skipping edited/schedulePersist. Removed excludeSocketId
and the socket-carrying origin object. Updated the relay test to assert the
whole-room broadcast (verified it fails if the sender is excluded).

* fix(files): tag agent stream frames no-persist across replicas

A peer task tailing an agent-streamed preview frame previously applied it
as REDIS_ORIGIN, marking the seeded room edited and making a transient
startup-race duplicate eligible for that task's last-disconnect flush. Mark
agent frames with a stream field so peers apply them as REDIS_AGENT_ORIGIN,
excluded from the edited/persist gate. The copilot's durable edit_content
write stays the sole authority over file bytes.

* fix(files): reseed agent shadow on lead regain + agent-only compaction

Two multi-writer edge cases surfaced in review:

- rich-markdown-editor: a client that led, lost leadership, then regained it
  reused its stale shadow (which never saw the interim leader's ops), re-emitting
  ops for content already present. Tear the shadow down when a client observes it
  is not the leader, so a regain rebuilds fresh from the current doc.

- file-doc-store: compaction always stamped its snapshot REDIS_SNAPSHOT_ORIGIN
  (marks peers edited). A long agent-only stream crossing the threshold could
  fold preview content into a persist-eligible snapshot. Track whether a room
  integrated any real edit and stamp an agent-only snapshot REDIS_AGENT_ORIGIN
  so it stays no-persist.

Both covered by falsification-verified tests.

* fix(files): close realEdited data-loss race + elect a settle writer

Independent audit surfaced two real gaps:

- file-doc-store: realEdited was latched AFTER appendUpdate's awaits, but the
  edit already sits in room.doc synchronously. A concurrent agent-frame
  compaction could read realEdited=false, snapshot that real content, and stamp
  it a no-persist agent frame — a lost edit. Latch it synchronously (same tick
  as the doc mutation) before any await. Deterministic falsifiable test added.

- rich-markdown-editor: at settle every tab applied the final body, and a
  non-leader's local microtask runs before the leader's final propagates, so
  both insert the tail (Yjs keeps both) -> duplicated tail. Elect a single
  settle writer (reliable — awareness is long converged by settle), reading
  leadership before clearing the announcement. Corrects the overclaiming
  idempotency comment and the handoff pick-up comment.

Adds a y-tiptap internals upgrade-guardrail test.

* fix(files): own presence per client id, not one-per-socket

The shared workspace socket hosts one collaborative provider per mounted view,
so the chat file preview and the standalone Files editor for the same file each
bind their own Yjs client id over ONE socket. The relay owned a single client id
per socket, so the later JOIN overwrote the earlier and dropped its awareness —
which silently broke the single-writer agent-stream election (a peer stopped
seeing the streaming provider's announcement and could self-elect, duplicating
streamed text for the whole stream).

Track ownership per (socket, client id): a socket owns a set of client ids; the
awareness gate accepts a frame only if every id it carries is owned; cleanup
drops all of a socket's ids; the roster stays one-entry-per-session. Reclaim and
the same-user reconnect path evict just the reclaimed id, dropping the old socket
only if it empties. Falsification-verified test added.

* fix(files): make streamed file-preview accumulation replay-safe

Guard deriveFilePreviewSession against re-delivered/replayed content events:
apply a delta/snapshot only when previewVersion strictly advances, so a client
re-render or stream replay can't double-append the tail (the duplicated-content
bug) or regress on an older snapshot.

* fix(files): fix new-file collab streaming latch and agent-edit duplication

- Latch collab readiness so a new file's post-seed `synced` flap can no longer
  re-gate agent streaming (the stream previously showed only the seed and the
  rest appeared only on reload)
- Relay defers the durable edit_content merge to an actively-streaming client:
  the client shadow stream and the server merge were both writing the same
  content into the live doc, duplicating it when the server ran ahead
- Render the collaborator caret bar out of flow so a peer caret never nudges
  the surrounding text by ~1px
- Remove dead code: unused FileDocMessageType alias, unnecessary
  LiveFileDocMergeOrder export

Covered by tests: readiness latch (flap/offline/latch cases), relay merge
deferral (single- and multi-replica), plus verified-failing guards.

* test(copilot): fix loadWorkspaceFileTextForPreview mock to return { text } not a bare string

The adapter reads previewBase.text to seed an append/patch base; the mock returned
a bare '' so previewBase.text was undefined, making a base-less append fail closed
(no file_preview_content). My PR's fail-close change exposed the wrong-shaped mock.

---------

Co-authored-by: mzxchandra <129460234+mzxchandra@users.noreply.github.com>
2026-07-31 18:48:12 -07:00
Waleed e98715da01 fix(realtime): noindex the socket server's 404 responses (#6129)
* fix(realtime): noindex the socket server's 404 responses

The sockets.* hostnames are served by this server and return a plain JSON
404, which Google Search Console reports as crawl errors. Mark unmatched
routes noindex so crawlers drop the hostnames instead of retrying them.

* fix(realtime): noindex every response, not just the 404

Setting the header only on the 404 fallback covered the one response that
crawlers already drop on status code alone, while /health — the sole route
returning 200 with a body, and so the only indexable surface on the socket
hostnames — stayed uncovered, with a test pinning it that way.

Set it once on the handler instead. Node merges setHeader values into
writeHead and no branch sets X-Robots-Tag, so it reaches every response.
2026-07-31 12:55:48 -07:00
Waleed 02311cafb9 perf(db): stop scanning every workspace file on workflow archive, cover the billing period aggregate (#6015)
* perf(files): stop loading every workspace file to archive a workflow's backing files

`cleanupWorkflowAliasBacking` runs on every workflow delete. It loaded every
`context='workspace'` row for the workspace — active and soft-deleted, all
columns — then discarded >99% of them in JS to find the handful of
`.changelogs/<workflowId>.md` and `.plans/<workflowId>/**` rows it owns.

In production this was the single worst query at 37.83% of total database
runtime: p50 6s, p95 9s, max 13.1s, 641 calls/day, 34.1M rows read. It is
index-served, so the cost is not a missing index — it is 59,061 rows and
~630MB of cold buffers read to archive a few files.

A file's `folderPath` is derived solely from its `folderId`, so matching on
folder membership is equivalent to the path comparison it replaces. The load
and JS filter become one targeted UPDATE keyed on a handful of folder ids.

Folders that are soft-deleted are still included when resolving which files a
workflow owns: path resolution ignores `deletedAt`, so a live file parented to
an archived folder previously matched and must continue to.

Also:
- Add `getWorkspaceShares`, replacing an id-list `IN` clause that grew with the
  file count (59,061 elements on the worst workspace) with one indexed lookup
  on `workspace_id`. Callers read the map by id, so a superset is equivalent.
- Drop `all` from the client-reachable file scope enum. It drops the
  `deleted_at` predicate and so cannot use the partial index serving the other
  two. No client requests it; server callers reach that scope directly.
- Set `fetch_types: false` on the app and realtime pools. postgres.js otherwise
  runs a blocking `pg_catalog.pg_type` roundtrip before each new connection's
  first query — 95,722 of them per day. It builds array parsers only; Drizzle
  already parses this schema's two `text[]` columns itself.

* perf(nav): add route loading boundaries and cover the billing period aggregate

Dynamic routes prefetch only down to the nearest loading boundary, and the
server stops prefetching at the first one. With no loading.tsx anywhere in the
workspace tree and a dynamic layout, Link prefetch was yielding almost nothing
and every navigation waited on a full server round trip with no feedback.

Add loading.tsx only where the fallback is provably what renders today, so
perceived speed improves without changing what users see:

- home, chat/[chatId]: reuse HomeFallback, already each page's own Suspense
  fallback.
- integrations, skills: reuse the tab-header chrome, byte-identical to each
  page's own Suspense fallback.

Deliberately not added to workspace root, settings, or w, whose pages are
redirect-only or already self-fallbacking — a boundary there would paint a
skeleton that does not match the destination.

Billing:

- Add usage_log_billing_period_cost_idx, trailing the remaining predicate
  columns and `cost` so the period aggregates resolve index-only. Confirmed
  against production: the aggregate currently runs as an Index Scan touching
  478,559 buffers because `cost` is absent from the existing index. That index
  is superseded but left in place; dropping it is a separate migration so a
  planner regression costs nothing to revert.
- Collapse the two aggregates behind /api/billing into one scan using
  SUM(...) FILTER. Besides halving the work on a nav-path query, it removes a
  latent inconsistency: as separate statements the two sums could observe
  different snapshots, making the copilot subset exceed the total.

Caching was considered and rejected for the usage aggregate. Its callers
include usage enforcement, threshold billing, and overage calculation, where a
stale-low read permits overspend and a stale-high read double-charges.

* test(files): cover cleanupWorkflowAliasBacking and correct a stale share mock

cleanupWorkflowAliasBacking had no test coverage, and the rewrite that replaced
its load-everything-then-filter body rests on a subtle equivalence: a file's
folderPath is derived solely from its folderId, and path resolution ignores
deletedAt. The second half is the easy part to get wrong — a live file parented
to an archived folder still resolves to a backing path and must still be
archived, so folders are filtered by deletedAt only when choosing which folders
to archive, never when deciding which files the workflow owns.

These tests pin that distinction: they fail if the archived-folder case is
dropped from file ownership, and separately assert that archived folders stay
out of the folder update and that unrelated workflows are never touched.

Also point the workspace files route test at getWorkspaceShares. Its mock still
named getSharesForResources, which the route no longer imports. The suite passed
regardless because all three cases exercise the upload path, so the GET listing
has no coverage — but the stale name would have handed the first GET test an
undefined function.

* fix(perf): drop the loading boundaries and correct the usage_log index order

Adversarial review found real regressions in both.

Route loading boundaries — all four removed:

The premise in their TSDoc was wrong. Each page's existing `<Suspense>` exists so
nuqs can prerender; `useSearchParams` only suspends during SSR, so on a client
navigation those fallbacks never painted. Hoisting them to loading.tsx did not
"show the same frame" — it made a previously invisible blank frame visible.

For chat, Next keys the Suspense boundary by cache key, so a chatId change mounts
a fresh suspended boundary and always commits its fallback. Switching chats would
have gone from "previous chat stays on screen" to a blank surface for the whole
RSC round trip, on the highest-frequency navigation in the product. Partial
prefetch only warms the fallback, so no latency was saved to offset it.

The integrations and skills fallbacks were correct for their own pages but also
became the fallback for four detail routes that render different chrome, inserting
a wrong intermediate frame. Fixing that needs per-child boundaries and new skeleton
UI that cannot be verified without a browser, for pages whose only server work is
`await params`. Not worth it.

Every remaining loading.tsx in this app paints real chrome. A blank one was against
the grain, and the measurable win was zero.

usage_log index:

Column order was wrong. The daily-refresh rollup filters entity type, id and
period_start but NOT period_end, so putting period_end fourth ended the usable
prefix at column three and left user_id and created_at as in-index filters rather
than scan boundaries — turning a ~2.2k-entry bitmap scan into a ~266k-entry scan.
Verified in production that period_start functionally determines period_end
(13,612 groups, zero with more than one end), so the slot bought no selectivity.
user_id and created_at now follow the shared prefix; period_end rides as payload.

Also corrected the claim that this supersedes usage_log_billing_entity_period_idx.
That index deduplicates to 17 MB across 1.42M entries because it has no
high-cardinality key column, which is what keeps prefix-only bitmap scans cheap.
It is retained deliberately, not pending a drop.

Harden cleanupWorkflowAliasBacking: gate the UPDATE on the ownership filter list
itself. `and()` and `or()` both drop undefined, so a clause that resolved to
nothing would have left a WHERE of workspace + context + not-deleted and archived
every file in the workspace. Tests now assert no UPDATE is issued when the
workflow owns nothing, plus the filename and context predicates.

Note fetch_types also disables array serializers, not just parsers; documented.

* docs(db): correct the fetch_types constraint note after differential testing

Ran both settings against a real Postgres with the schema's actual text[] columns.
Drizzle-typed selects and .returning() are byte-identical either way; only a raw
db.execute projecting an array column differs, yielding the wire form.

The previous note also claimed a raw JS-array bind fails because the serializers
come from the same catalog fetch. It does fail — but under both settings, because
Drizzle expands an array into a row constructor before postgres.js ever sees it.
That is unrelated to this flag, so the claim is removed rather than left implying
a constraint this change introduces.

* chore(db): generate the drizzle snapshot for the usage_log index migration

0271 was hand-written, so drizzle-kit's state never learned about the new index —
the next `generate` would have re-emitted it as a fresh migration against an
already-migrated database.

Ran `drizzle-kit generate` to produce the snapshot, then restored the
CONCURRENTLY form: drizzle emits a plain CREATE INDEX, which takes an ACCESS
EXCLUSIVE lock and would block writes on a 4M-row table for the duration of the
build. The generated column order matched the hand-written SQL exactly, which
also confirms schema.ts and the migration agree.

`generate` is now a no-op, and check:migrations still passes.
2026-07-28 12:39:10 -07:00
Vikhyath MondretiandClaude Fable 5 f43b52c569 fix(realtime): evict revoked collaborators from live workflow rooms (#5917)
* fix(realtime): evict revoked collaborators from live workflow rooms via periodic read-access re-validation

* fix

* fix(realtime): close join/eviction race and make sweep cleanups independent

Re-authorize immediately before socket.join so an in-flight join cannot
reverse a sweep eviction, and run the sweep's best-effort cleanups
independently so a room-state failure cannot skip the presence broadcast.

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

* fix(realtime): single-flight role resolution and retry failed eviction cleanup

Coalesce concurrent role resolutions per (user, workflow) so a slow stale
read can never overwrite a recorded revocation, and defer failed eviction
room-state cleanups into a per-sweep retry queue so collaborators are not
left with a stale presence entry.

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

* fix(realtime): scope room removal to the target workflow and detect swallowed cleanup failures

Honor the workflowIdHint as the target room in both room managers so
removing a stale room cannot clobber the mapping of a room the socket has
since moved to, and confirm eviction cleanup via the returned workflowId
plus an unswallowed mapping read so Redis failures actually defer into the
retry queue.

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

* fix(realtime): treat unconfirmed removals as failures and refresh role cache on fresh verify

Treat any null removal result as a failed cleanup (the sweep always passes
the target room, so null only means failure — including with expired
mapping keys), move the same-room rejoin guard to a synchronous check
immediately before the removal, and record verifyWorkflowAccess's fresh
decision into the role cache so a re-granted user is not blocked by a
stale cached revocation.

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

* fix(realtime): prefer a mid-flight recorded role decision over the in-flight query result

If a fresh authoritative read (join-time verify) records a decision while
a single-flighted resolution's query is in flight, keep the recorded
decision instead of overwriting it with the potentially stale result.

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

* fix(realtime): isolate the security scan from the Redis cleanup lane

Run the revocation scan (local sockets + DB only) and the best-effort
room-state cleanup as independently-guarded lanes so a hanging Redis
command can stall only presence cleanup, never revocation enforcement.
Evictions now enqueue cleanup instead of awaiting it, and the scan no
longer reads presence for a fallback role.

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

* fix(realtime): bound authorization waits in the revocation scan

Race each socket's authorization check against a per-socket timeout and
cap the whole pass with a budget below the sweep interval, so a hanging
DB query skips that socket for the pass (never evicting on uncertainty)
instead of wedging the scan lane and starving subsequent ticks.

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

* fix(realtime): round-robin the revocation scan so hung checks cannot starve later sockets

Resume each scan pass after the last target the previous pass processed,
so a fixed prefix of hanging authorization checks can never repeatedly
consume the pass budget and leave sockets behind it unexamined.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 16:45:40 -07:00
d24bc7eccb feat(agent-stream): thinking and tool streaming (#5671)
* feat(agent-stream): add agent-events thinking/tool streaming for chat and canvas

Ship the agent-events-v1 protocol with provider tool loops, dual-gated chat thinking, DeepSeek/Groq/OpenAI reasoning wiring, and ChatGPT-like thinking chrome.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(agent-stream): clear stuck streaming UI and format db snapshot

Biome was failing CI on migrations/meta/0261_snapshot.json. Also settle
assistant streaming/tool flags when SSE ends without a terminal frame,
without clobbering Stop's finalized content.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(agent-stream): satisfy biome format and import order

Auto-format the sim package for CI lint:check, and repair the Anthropic
streaming tool-loop payload after an unsafe delete-to-undefined rewrite.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(agent-stream): keep drained answer on abort and update migration journal test

Treat AbortError from reader.cancel as a cancelled pump result so soft-complete
retains answerText. Point the workspace storage migration journal assertion at
0261_chat_include_thinking.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(chat): keep Stop notice when server emits cancel error

Ignore terminal SSE error frames after the user aborts so
"Client cancelled request" cannot overwrite "Response stopped by user".

Co-authored-by: Cursor <cursoragent@cursor.com>

* improvement(chat): ChatGPT-style thinking shimmer and stick-to-bottom scroll

Add left-to-right shimmer on live thinking label/body, keep scroll working by
shimmering an inner node, and follow the answer only while near the bottom.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(agent-stream): stop pump on client disconnect; soft-complete agents only

Abort the agent stream pump when the projected HTTP body is cancelled so
provider work does not continue after disconnect. Limit AbortError soft-success
to Agent blocks so Function/HTTP cancels still fail in logs.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(agent-stream): persist includeThinking across pause snapshots

Paused chat runs with Include thinking enabled were dropping the flag when
serializing the pause snapshot, so resume always rebuilt streams without
thinking/tool SSE frames.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(agent-stream): keep drained answer text when stream times out

Persist pump answerText onto the streaming execution before throwing on
timeout, and carry that partial content into the failed block output so
logs match what the client already saw.

Co-authored-by: Cursor <cursoragent@cursor.com>

* improvement(chat): auto-collapse tools chrome when tool streaming ends

Match thinking UX: open while tools run, collapse when finished, and keep
the panel open only if the user manually reopens it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(agent-stream): settle canvas stream chrome on failure paths

Clear agentStreamActive and settle running tool chips when blocks error,
timeouts cancel runs, or execution ends without stream:done so the output
panel does not stay on live Thinking/Using tools chrome.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lint): organize imports in terminal console store

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(agent-stream): mark open tools cancelled on HITL pause

Pause can interrupt a tool loop without tool end events; settling those
chips as success incorrectly showed unfinished tools as complete.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(db): drop branch-local 0261 migration ahead of staging merge

* chore(db): regenerate include_thinking migration as 0266 post staging merge

* fix(providers): resolve type errors in streaming tool loop call sites

* fix(agent-stream): gate agent events opt-in and correct provider loop behavior

- streamToolCalls and provider thinking requests now require run-level
  agentEvents opt-in (canvas on, chat dual-gated, API off) so existing
  runs keep pre-agent-events behavior exactly
- OpenAI reasoning summaries opt-in + strip-and-retry on unverified-org 400
- streaming loops run tool postProcess again (firecrawl/exa async results)
- bedrock live loop falls back to silent path for responseFormat
- deepseek: reasoning_content pass-back unconditional, 'none' sends disabled
- groq: x_groq.usage fallback, reasoning params gated, qwen none disables
- gemini: functionCall parts echoed verbatim, local ids only for events
- truncated turns (max_tokens/length) no longer execute partial tool calls
- MAX_TOOL_ITERATIONS exit flushes last turn text as final answer
- iterations reports actual model calls; shared loop plumbing extracted

* refactor(agent-stream): consolidate protocol, dedupe client/server plumbing, hygiene

- canonical ChatStreamFrame union + type guards consumed by server emitters
  and the chat client; stream_error restored to legacy log-only handling
- strip thinking/tool args from providerTiming on public final envelopes
- shared tool-chip lifecycle module for chat, canvas, and console store
- shared sink-to-execution-events forwarder replaces the copy-pasted
  adapter in the execute route and HITL manager; LIVE_ONLY event set shared
- stream:thinking payload field renamed data->text; canvas thinking batched
- abort reasons carried as AbortError DOMExceptions so raw fetch consumers
  classify correctly; thinking cap renamed to chars and scope-documented
- kimi wired for agent events like the other compat providers
- deleted dead exports/step-N comments; fixtures match real wire shapes;
  loop tests use explicit mocks instead of importOriginal

* test(agent-stream): cover the dual-gated execution path and typed abort reasons

- chat route tests assert agentEvents reaches executeWorkflow only when
  policy and protocol header agree
- execution-limits tests assert AbortError-typed reasons
- executor metadata type carries agentEvents

* fix(deploy-modal): align include-thinking spacing with the modal's 6.5px rhythm

* docs(agent-stream): autogenerate per-model thinking/tool stream support on the Agent block page

- capabilities.thinking.streamed ('full' | 'summary' | 'none') on models.ts,
  explicit for the Anthropic family where visibility varies per generation;
  getThinkingStreamVisibility exposes the derivation for docs and UI alike
- scripts/sync-agent-stream-docs.ts regenerates the support tables between
  markers in workflows/blocks/agent.mdx from the model registry and
  STREAMING_TOOL_CALL_PROVIDERS; --check fails on drift or missing metadata
- wired agent-stream-docs:check into CI next to the other sync gates

* feat(anthropic): request summarized thinking display for omitted-default Claude models

The newest Claude generations (Fable 5, Sonnet 5, Opus 4.8/4.7) default
thinking.display to omitted — empty thinking blocks, no deltas. On
agent-events runs Sim now opts back in with display: 'summarized', driven
by the registry's streamed metadata; legacy runs keep the exact
pre-agent-events request shape. Registry, generated docs, and the family
capability table updated accordingly.

* docs(skills): cover thinking.streamed and agent-stream docs sync in model skills

* chore(deps): upgrade @anthropic-ai/sdk to 0.114.0 and adopt official types

- adaptive thinking, display, and output_config are now SDK-typed; the only
  remaining custom payload field is output_format (beta-header structured
  outputs, which the SDK models as output_config.format instead)
- anthropic stream events narrow on the SDK's discriminated unions instead
  of anonymous casts; compat deltas type content/tool_calls from the OpenAI
  SDK with vendor reasoning fields as an explicit optional extension
- @sim/auth exposes an explicit VerifyAuth contract so its declarations no
  longer reference better-auth's nested zod instance (TS2883 under fresh
  install layouts); realtime consumer aligned
- docs app zod pinned to the repo's exact 4.3.6 so ai SDK types bind the
  same zod instance (docs type-check was latently broken)
- knowledge embedding tests made hermetic against local .env keys and
  hosted rotation fallback

* refactor(providers): replace legacy as-any stream casts with annotated typed casts

* refactor(providers): finish provider audit — remove dead byte-stream helper, annotate remaining legacy casts

Audit of all 26 providers for the agent-events feature confirmed every
streaming execution declares agent-events-v1 and every adapter emits
AgentStreamEvent objects. Cleanup from the audit: the unconsumed legacy
createOpenAICompatibleStream byte helper is deleted, and the remaining
streamResponse-as-any casts (xai, nvidia, kimi, meta, zai, sakana) are
annotated typed casts matching the groq/deepseek fix.

* feat(streaming): stream answer text live during tool loops via turn_end protocol

The live tool loops buffered all answer text per model turn (classification
of intermediate vs final is only known at turn end), so gated surfaces saw
thinking stream, then dead air with the thinking chrome stuck open, then the
whole answer at once.

Loops now emit text deltas live as `turn: 'pending'` plus a `turn_end`
event per turn. The pump buffers pending text and projects it to the byte
path (answerText/logs/memory/legacy clients) only on a final turn_end, so
all settled semantics are unchanged. Gated surfaces render the pending text
as it streams and reconcile with a reset when a turn resolves to tools:

- public chat: live `chunk` frames from the sink + dual-gated `chunk_reset`;
  byte-path frame emission is suppressed to avoid duplicates (kept for
  response-format transformed streams via clientStreamTransformed)
- canvas: forwarder emits live `stream:chunk` + `stream:chunk_reset`; the
  execute route and HITL resume readers stop re-emitting byte chunks; panel
  chat tracks per-block segments and replaces content on flush
- chat client: per-block text segments, chunk_reset handling, and thinking
  chrome now settles on tool start as well as first answer chunk

* fix(streaming): address validated review findings across provider gating and reset reconciliation

Three-reviewer pass over the branch, findings validated against staging:

- agent-handler forwards agentEvents to executeProviderRequest — the flag was
  computed but dropped in the field-by-field copy, so provider-side thinking
  requests (OpenAI summaries, Gemini includeThoughts, Anthropic summarized
  display) never activated on opted-in runs
- openai: restore summary:'auto' alongside explicit reasoning effort — staging
  always paired them; gating summary purely on agentEvents changed legacy
  payloads
- gemini: Gemini 2 + tools + responseFormat falls back to the silent path;
  the live loop never applied the deferred responseSchema for AUTO tools
- openai-compat loop: malformed tool-argument JSON fails the call instead of
  executing with defaulted {} args (staging parsed inside the execution try)
- openai-compat parser: a vendor id arriving after a synthesized start no
  longer renames the call (start/end ids stayed consistent)
- stream-pump: abort closes the byte projection so a drain blocked on
  backpressure cannot deadlock teardown
- chunk_reset removes the block from the client text order (deployed chat +
  panel chat) so a reset block re-registers at arrival position — fixes
  separator/order corruption when parallel blocks stream around a reset
- resume route echoes the negotiated X-Sim-Stream-Protocol response header
  (parity with the chat route); docs: [DONE] wire shape + final-vs-error
  terminal semantics corrected

* chore(deps): exempt pinned @anthropic-ai/sdk 0.114.0 from the release-age gate

CI's bun install --frozen-lockfile blocks 0.114.0 (published 2026-07-23,
younger than the 7-day supply-chain gate). The pin is exact and was vetted
for the agent-events streaming work; following the existing bunfig pattern,
the exclusion ages out on 2026-07-30 and should be dropped then.

* chore(providers): fix double-cast-allowed annotation placement for the strict boundary audit

The audit only recognizes the annotation on the line directly above the cast;
two annotations had drifted behind intervening code lines (groq stream params,
deepseek loop messages) and the OpenAI reasoning-summary widening cast was
never annotated. No behavior change.

* fix(chat): settle straggler tool chips as error when final reports failure

A failed run can still terminate with a `final` frame carrying success: false;
running chips previously settled green regardless of the outcome.

* fix(canvas): wire agent stream chrome into run-from-block

Run-from-block executions emit the same live stream:thinking/stream:tool
events as full runs but registered none of the handlers, so the terminal
never showed thinking or tool chips on that path. The per-run chrome
(batched thinking writes + tool chip lifecycle + settlement on stream done,
block error, and every terminal execution state) is extracted into a shared
createAgentStreamChrome factory consumed by both paths.

---------

Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2026-07-23 19:39:03 -07:00
Waleed fac8ec0c51 fix(workflow-edges): enforce edge/block validation server-side, not just client-side (#5571)
* fix(workflow-edges): enforce edge/block validation server-side, not just client-side

Dragging a connection that creates a cycle correctly refused to render
client-side, but the cyclic edge was still queued for realtime persistence
and written to the DB, so it reappeared after refresh. Root cause: cycle
detection (and several other edge/block rules) only lived in the client
Zustand store and was never enforced by the realtime persistence layer,
which is the actual source of truth on reload.

- Move wouldCreateCycle, edge scope-boundary, annotation-only-block,
  duplicate-edge, and block-name-conflict checks into @sim/workflow-types
  so the client store, the collaborative queueing layer, and
  apps/realtime's DB write path all share one implementation
- Wire these into apps/realtime/database/operations.ts's edge-add and
  block-rename handlers as the authoritative gate
- Client-side behavior is unchanged (same call sites, same error messages,
  same rule ordering) — verified via existing + new test coverage

* fix(workflow-edges): address review findings on realtime edge validation

- Select triggerMode when fetching blocks for edge-add validation —
  isKnownWorkflowTriggerBlock checked block.triggerMode but the column
  was never fetched from the DB, so trigger-mode blocks could still
  receive an incoming edge (Cursor Bugbot)
- Make filterUniqueWorkflowEdges incremental, so two duplicate edges
  within the same BATCH_ADD_EDGES payload are also deduped instead of
  both surviving (Greptile)

* fix(workflow-edges): normalize empty-string handles in duplicate-edge check

filterUniqueWorkflowEdges compared handles with ??, so a `sourceHandle: ''`
edge wasn't recognized as a duplicate of an existing null-handle edge —
even though both get persisted as the same null value at insert time
(edge.sourceHandle || null). Falsy-coalesce in the comparison so '' and
null/undefined are treated as the same "no handle" state everywhere.
(Greptile)

* improvement(workflow-edges): dedup realtime edge-add validation, reuse block-name-conflict helper

/simplify pass on the already-merged-quality PR before sign-off:

- Extract filterEdgesForPersist in apps/realtime/src/database/operations.ts:
  the single-edge ADD and batch BATCH_ADD_EDGES handlers hand-inlined the
  same six-step validation pipeline (missing block, protected target,
  annotation-only, trigger-target, scope boundary, duplicate, cycle) and
  each independently re-fetched blocksById/existingEdgesForCycleCheck. Two
  copies of one rule in the same file was exactly the drift risk this PR
  otherwise closes across client/server. One shared helper now backs both.
  Net -63 lines despite the new shared function.
- Fix a real bug this surfaced: droppedCounts was keyed by the free-text,
  block-id-bearing scope-boundary message, so it could never aggregate
  across edges/runs. Now keyed by a stable 'scope boundary' reason.
- use-collaborative-workflow.ts's collaborativeUpdateBlockName still
  hand-rolled the empty/reserved/duplicate block-name-conflict checks this
  PR centralized as getWorkflowBlockNameConflict (already adopted by
  store.ts). Switched it to the shared helper, which also fixes a latent
  check-order mismatch between the two (this pre-check ran reserved before
  duplicate; the store's real gate — after this PR's own store.ts change —
  runs duplicate before reserved, so they could disagree on which toast a
  name that was both reserved and duplicate would surface).

* docs(workflow-edges): document the per-workflow write-serialization invariant

No behavior change. Address Greptile P1 on filterEdgesForPersist ('concurrent
duplicate writes can persist without a per-workflow write guard') by
documenting, at the actual mechanism, why the concern doesn't apply here:
persistWorkflowOperation's leading 'UPDATE workflow SET updatedAt ... WHERE
id = workflowId' already takes a row lock that serializes every operation
(including edge adds) for a given workflowId — a second concurrent call
blocks on that UPDATE until the first transaction commits or rolls back, so
the validate-then-insert sequence in filterEdgesForPersist can never
interleave across two writers on the same workflow.

Verified empirically, not just by reading: ran two concurrent transactions
against a throwaway local Postgres against the exact statement shape used
here (UPDATE the parent row, sleep to simulate the read/validate window,
insert, commit). The second transaction's UPDATE blocked for the full
duration of the first's transaction and only proceeded once the first
committed — confirming the row lock, not any application-level guard,
already provides the serialization Greptile flagged as missing.

Added a comment at the lock site (not a second, redundant advisory lock)
so a future change can't silently break this invariant by making the
UPDATE conditional/skippable as a perceived no-op optimization.

* fix(workflow-edges): validate edges in BATCH_ADD_BLOCKS before persisting

Real gap Cursor's PR summary flagged ('BATCH_ADD_BLOCKS edge inserts... not
fully covered by the new server pipeline'), confirmed by reading the code:
this handler bulk-inserted the edges from a block-paste/duplicate/import
payload directly into workflowEdges with zero validation — no missing-block,
protected-target, annotation-only, trigger-target, scope-boundary,
duplicate, or cycle check. A client sending edges through this operation
instead of BATCH_ADD_EDGES could bypass every rule this PR otherwise
enforces server-side, exactly the class of gap the PR exists to close.

Routes it through the same filterEdgesForPersist used by the other two
edge-add handlers. Runs after the block insert in this same handler, so the
shared helper's blocksById lookup also sees the blocks this same batch just
inserted (a transaction observes its own prior writes).
2026-07-10 15:25:25 -07:00
Waleed b117758eaa fix(agent): scope nested tool canonical-mode overrides by instance, not type (#5534)
Two tool entries of the same type inside an Agent block's tool-input array
(e.g. two Table tools) shared a single canonical-mode override keyed by
${toolType}:${canonicalId}, so switching basic/advanced mode on one field
silently switched it on every other instance of the same tool type -
including at execution time, where the wrong basic/advanced value could be
resolved for the second tool.

Rescope the override key to the tool's position in its tool-input array
(${toolIndex}:${canonicalId}) instead of its type, and thread that index
through every consumer: the editor (read + write), execution
(agent-handler/providers), search-index, and fork/promote remapping.
2026-07-09 11:21:05 -07:00
Waleed 5db62b8bdc feat(observability): extend audit log, PostHog, and storage metering coverage (#5269)
* feat(observability): extend audit log, PostHog, and storage metering coverage

Instruments previously-uncaptured resources and actions across the three
observability layers (audit log, PostHog product analytics, usage metering):

- Exfiltration audit: file downloads (workspace/public-share/v1), table,
  workflow, and workspace exports.
- Credential access audited at the token-issuance boundary (success-only).
- Full revenue trail: invoice paid/failed, overage billed, disputes, credit
  fulfillment, subscription lifecycle, plan/seat changes.
- Session/login lifecycle audit via Better Auth hooks (login, blocked sign-in,
  logout, session revoke, account delete).
- v1/admin programmatic surface and copilot tool handlers instrumented.
- Dead audit/PostHog constants wired (lock/unlock, table, custom tool, etc.).
- Storage metering extended to KB documents and copilot files.
- PostHog person identify + workspace/organization group hygiene.

Audit package hardened to null the actor FK for system actors (admin-api) and
adds an awaitable recordAuditNow for pre-delete hooks. All instrumentation is
fire-and-forget and never blocks or breaks the primary operation.

* fix(observability): audit file/credential egress only on success

Address Cursor Bugbot review:
- GET OAuth token: emit CREDENTIAL_ACCESSED/credential_used after
  refreshTokenIfNeeded succeeds (matches POST), not before.
- File export: audit on each actual success exit via a shared helper —
  including the non-markdown serve redirect (previously unaudited) and
  after the zip is generated (previously before asset fetch).

* fix(audit): record null actor for anonymous public-share downloads

Address Greptile P1: setting actorId to the file owner made every anonymous
external download read as a self-download, undermining the exfiltration trail.
recordAudit now accepts a null actor; the public content/inline routes record
actorId=null with the owner in metadata.sharedByUserId and rely on ip/user-agent
for the forensic trail. The misleading owner-attributed file_downloaded PostHog
event is dropped on these anonymous paths.

* fix(observability): audit admin exports after zip; tidy comments

- Admin workflow/workspace ZIP exports now audit only after the archive is
  built (via a local helper), so a zip/build failure no longer logs an export.
- Remove redundant 'success-only placement' inline comments and tighten the
  remaining design-rationale notes (export helper now TSDoc).

* fix(billing): complete the chargeback + overage financial trail

Address Cursor review:
- handleDisputeClosed now records CHARGE_DISPUTE_CLOSED for every closed
  dispute (won/lost/warning_closed), unblocking only on favorable outcomes.
  dispute.status in the metadata distinguishes the outcome, so lost
  chargebacks are no longer missing from the trail.
- Threshold overage now emits OVERAGE_BILLED + overage_billed even when credits
  fully cover the overage (settledVia: 'credits' vs 'stripe'), so credit-settled
  overages are audited instead of silently returning null.

* fix(storage): decrement copilot quota before deleting metadata

Address Greptile P1: deleting the metadata row before the decrement meant a
decrement failure left the quota permanently inflated with no record to retry
from. Decrement first; only remove the metadata row once it succeeds.

* fix(observability): atomic copilot storage release; signup-blocked action

- Copilot file delete now releases storage via a single transaction
  (releaseDeletedFileStorage): the soft-delete is the idempotency claim and the
  decrement shares the transaction, so neither a partial failure (inflated
  counter) nor a retry (double-decrement) can desync the quota. Resolves the
  conflicting Greptile/Cursor ordering findings.
- Policy-blocked sign-ups now record USER_SIGNUP_BLOCKED instead of
  USER_SIGNIN_BLOCKED, so account-lifecycle events aren't mislabeled.

* fix(storage): meter copilot ingest centrally for path symmetry

Address Cursor review: only uploadCopilotFile incremented storage, but copilot
files also enter via the generic upload route and presigned uploads — all of
which persist metadata through insertFileMetadata. Move the increment into
insertFileMetadata (scoped to context='copilot', on genuine insert/restore) so
every ingest path is symmetric with the delete-time decrement, and drop the now
redundant per-path increment in uploadCopilotFile. Other contexts are metered by
their own managers and remain unaffected.

* fix(audit): skip WORKFLOW_EXPORTED when admin export is empty

Address Cursor review: when every requested workflow fails to load, the admin
export still returned 200 but recorded a successful export of zero workflows.
Guard auditExport so an empty result records nothing.

* fix(storage): settle copilot accounting before deleting the blob

Address Cursor review: the blob was removed before releaseDeletedFileStorage, so
a release failure left the counter inflated and the metadata active with the blob
gone. Now the atomic soft-delete + decrement runs first and the blob is deleted
only if it succeeds, so a failure leaves the file fully intact and retryable.

* fix(storage): decrement KB document storage atomically with deletion

Address Cursor review: hardDeleteDocuments deleted the rows then decremented
best-effort, so a decrement failure left billed storage inflated with no row to
reconcile. Resolve each owner's subscription up front, then decrement inside the
same transaction that deletes the embeddings/documents (decrementStorageUsageInTx,
also now shared by releaseDeletedFileStorage), so the counter and the content
commit or roll back together. Connector docs remain excluded.

* fix(audit): don't treat email verification as a login

Address Cursor review: /verify-email could emit USER_LOGIN when verification
mints or refreshes a session, mislabeling (or double-counting) a non-sign-in as
a login. Restrict isLoginPath to genuine sign-in entrypoints.

* fix(audit): null actor FK when the user lookup throws

Address Greptile P1: the catch branch left the original actorId, so a system
actor like 'admin-api' (or a since-deleted user) would FK-violate the insert and
lose the row when the existence lookup errored. Mirror the not-found branch —
null the FK with a readable label — so the audit row always persists.

* revert(audit): drop session/account-lifecycle auth instrumentation

Remove the Better Auth login/logout/session-revoke/account-delete/blocked-signin
audit hooks from auth.ts — they touch sensitive auth paths and are noisy. Also
removes the now-unused taxonomy (USER_LOGIN/_FAILED, USER_SIGNIN_BLOCKED,
USER_SIGNUP_BLOCKED, USER_LOGOUT, SESSION_REVOKED, ACCOUNT_DELETED,
ACCOUNT_EMAIL_CHANGED actions; SESSION/USER resource types) and the awaitable
recordAuditNow helper that only the pre-delete hook used. auth.ts is back to the
staging baseline.

* fix(storage): harden copilot+KB delete accounting against read errors and concurrency

Final-audit follow-ups:
- deleteCopilotFile: a failed metadata *read* (vs a genuine missing row) now
  blocks the blob delete too, so a transient read error can't leave an active
  row un-decremented with the blob gone.
- hardDeleteDocuments: drive the per-user decrement from the delete's
  returning() (the rows this tx actually removed), so two concurrent deletes of
  the same ids can't both decrement.

* chore(observability): drop two unused definitions

Final-audit cleanup: remove the orphaned knowledge_base_searched PostHog event
(never wired; KB search analytics already flow through the OpenTelemetry channel)
and the redundant AuditAction.SUBSCRIPTION_UPDATED (subscription/plan changes are
audited via ORG_PLAN_CONVERTED). Every remaining new action/event has a real
emit site.

* fix(knowledge): key hard-delete result off rows actually deleted

Address Cursor review: hardDeleteDocuments returned existingIds.length (the
requested count) and cleaned storage for the full pre-tx set, even though the
decrement was driven by the rows the transaction actually deleted. Under a
concurrent delete that claimed some ids first, that overstated the result and
re-touched storage for rows this call didn't delete. Drive the storage cleanup,
log, and return value from deletedDocs (the returning() rows) so all four are
consistent.

* fix(storage): gate copilot quota on all ingest paths; tidy inline comments

- Copilot uploads via the generic /api/files/upload route and presigned URLs now
  run checkStorageQuota before writing (matching uploadCopilotFile), so no copilot
  ingest path can grow usage past the plan limit. The central increment stays in
  insertFileMetadata.
- Trim/remove redundant inline comments across the diff; keep only concise notes
  on non-obvious decisions (left pre-existing comments untouched).

* fix(analytics): omit empty workspace_id on workspace-less file downloads

Address Greptile P1: the generic key-based /api/files/download route and the
no-workspace markdown-export path emitted file_downloaded with workspace_id:''
creating a phantom '' bucket in PostHog. Make workspace_id optional on the event
and omit it when there is no workspace (workspace-scoped routes still pass it).

* fix(analytics): omit empty workspace_id/workflow_id on copilot_chat_sent

Final-sweep nit: copilot_chat_sent sent '' for workspace_id/workflow_id in the
agent (workspace-less) branch, same phantom-bucket issue as file_downloaded.
Make both properties optional and omit them when absent.

* fix(analytics): clear stale org PostHog group on personal workspaces

Address Cursor review: switching from a team workspace to a personal one left
the previous organization group set, so later events kept rolling up under it.
When the active workspace has no organizationId, resetGroups() to drop the stale
org group, then re-apply the workspace group.

* fix: address review — export audit timing, copilot delete signal, group reset

- Table export (Cursor MED): audit fires before streaming begins, not after
  controller.close(), so a mid-stream failure still records the partial export.
- deleteCopilotFile (Greptile P1): throw when storage accounting can't be settled
  instead of silently returning, so callers can detect the file was not deleted.
- workspace-scope-sync (Cursor MED): only resetGroups() once workspace metadata is
  loaded (activeWorkspace present), so a team workspace doesn't transiently lose
  its org group while organizationId is still null during load.

* refactor(observability): final line-justification cleanup

Address final audit flags:
- Table import: move the 'columns added' audit OUT of the import transaction
  into a post-commit auditTableColumnsAdded() helper called by the three
  tx-owning callers, so a mid-import row-batch rollback no longer logs a false
  'added N columns' (matches the PR's success-only discipline).
- Omit empty-string analytics dimensions on workflow_lock_toggled (workspace_id)
  and organization_created (name), consistent with file_downloaded/copilot_chat_sent.
- Restore an unrelated capacity-check comment removed incidentally.
- Move copilot_chat_sent emit above the traceparent comment so the comment sits
  with the code it documents.

* fix(table): attribute import column audit to the importing user

Address Cursor review: addImportColumns (async createColumns import path) now
threads the importing userId into auditTableColumnsAdded instead of falling back
to table.createdBy, so column additions are attributed to the actual member who
ran the import rather than the table creator.

* feat(observability): drop copilot from storage accounting

Per design decision: copilot files are working/conversational artifacts, so
gating their materialization on storage quota would fail an agent operation
mid-flow when a user is over limit, and metering them would inflate usage enough
to block KB/workspace uploads indirectly. Remove copilot quota gates + copilot
ingest metering entirely (revert copilot-file-manager, metadata, and the upload
route's copilot branch to baseline) and drop the now-unused releaseDeletedFileStorage.
KB document metering + quota enforcement (the deliberate, persistent storage path)
is unchanged.

* fix(analytics): switch workspace + org PostHog groups together

Address Cursor review: gate the group-sync effect on workspace metadata being
loaded (activeWorkspace present) so the workspace and organization groups always
update atomically. Acting during the load window paired the new workspace group
with the previous workspace's org group; until metadata loads, events stay
consistently attributed to the previous workspace.

* fix(table): audit async export at authorization, not job completion

Address Cursor review: async exports only emitted TABLE_EXPORTED when the
background job reached 'ready', so an authorized export whose job later failed or
was abandoned left no audit trail — inconsistent with the sync export route,
which audits before streaming. Move the audit + analytics to the async route's
authorization point (after the job is claimed/dispatched) and remove it from the
runner. Drop the now-unused userId from TableExportPayload.

* fix(billing): never let payment_failed instrumentation skip user blocking

Address Greptile P1: the payment_failed audit hoisted an unguarded
isSubscriptionOrgScoped DB read (for entity_type) directly before the
attempt-count user-blocking block. A transient failure of that read would throw
out of the handler and skip blocking. Wrap the whole audit/analytics block in
try/catch (best-effort), and let the blocking compute its own isSubscriptionOrgScoped
as it did originally — instrumentation can no longer abort payment processing.

* chore(observability): trim verbose inline comments to concise notes

* fix(billing): wrap new dispute/enterprise/subscription instrumentation in Stripe webhook idempotency

recordAudit/captureServerEvent calls added for charge disputes, enterprise
subscription provisioning, and free->paid subscription creation ran
unconditionally with no idempotency guard, unlike their sibling handlers
in the same files. Stripe redelivers webhooks at-least-once, so a retry
would double-record the audit row and PostHog event even though the
underlying DB writes were already idempotent.

* fix(observability): tag org-scoped audit metadata with organizationId, guard concurrent table delete

The org-scoped self-service audit-log endpoint matches org-level rows via
metadata.organizationId (or resourceType=organization). Six recordAudit
call sites (subscription create/cancel, admin credit issuance, threshold
overage billing, charge disputes, credit purchase, invoice payment
succeeded/failed) tagged org-scoped events with a differently-named key
(referenceId/entityId/targetOrgId), making them invisible to org admins
querying their own audit trail despite being stored in the DB. Add the
missing organizationId key everywhere the pattern was missed.

Also guard deleteTable's archive UPDATE with isNull(archivedAt) so a
concurrent duplicate delete request is a no-op instead of re-archiving
and re-firing a duplicate TABLE_DELETED audit row.

Adds test coverage for the ORG_MEMBER_ADDED audit/analytics emission in
acceptInvitation, which previously had none.

* fix(test): queue resolveBillingActorId's owner lookup in payment-failure email test

handleInvoicePaymentFailed's new payment_failed audit instrumentation
resolves the billing actor via an extra db.select before
sendPaymentFailureEmails runs. The test's fixed select-response queue
didn't account for it, so the org-admin lookup consumed the wrong
queued row and the assertion saw zero email sends. Production behavior
is unaffected — each query is independent; this was a mock-queue
ordering issue only.

* fix(billing): don't let a post-commit usage-limit sync failure suppress the seat audit

Cursor Bugbot flagged: reconcileOrganizationSeats committed the seat
change and Stripe outbox enqueue in a transaction, then called
syncSubscriptionUsageLimits outside it before recording the audit/
PostHog events. A thrown sync left a genuinely-changed seat count with
no ORG_SEAT_PROVISIONED/DEPROVISIONED trail. Wrap the sync in its own
try/catch (log + continue) so the events always fire for a committed
change, matching the fire-and-forget instrumentation pattern used
elsewhere in this PR.

* fix(billing): guard the new subscription-created instrumentation block

Greptile P1: the org-scope check I added for the audit-metadata fix
(isSubscriptionOrgScoped) was a raw DB call with no error guard, unlike
resolveSubscriptionActorId next to it. Since it ran inside the
idempotency lambda with an unconditional rethrow, a transient DB error
would abort and retry the whole webhook after the free -> paid usage
reset had already committed. Wrap the actor/org-scope resolution +
audit + analytics block in try/catch, matching the same guarded
instrumentation pattern already used in handleInvoicePaymentFailed.
2026-07-08 23:23:59 -07:00
Theodore Li 0613cebfab feat(db): resolve DATABASE_URL per role (DATABASE_URL_<ROLE> with fallback) (#5276)
* feat(db): resolve DATABASE_URL per role (DATABASE_URL_<ROLE> with fallback)

* fix(db): pin realtime process to SIM_DB_ROLE=realtime so both pools share the role

Without it, the realtime process left SIM_DB_ROLE unset: the shared @sim/db
client defaulted role to 'web' (web pool profile + DATABASE_URL_WEB) while
socketDb used 'realtime', so the two pools diverged after cutover. Set it at the
process level (bootstrap + dev/start scripts), mirroring DB_APP_NAME, so the
shared client and socketDb both resolve the realtime profile and URL.
2026-06-30 15:17:55 -04:00
Theodore Li 845a6276d9 perf(db): per-role Postgres connection-pool profiles (#5232)
* perf(db): drive Postgres pool size + application_name from per-role profiles

Replace ad-hoc DB_APP_NAME sizing with a per-role profile map keyed by
SIM_DB_ROLE (web/trigger/realtime), defaulting to web. Trigger machines
open a small pool instead of 15 to avoid PgBouncer connection exhaustion.
Also size realtime's separate socketDb pool down to 10.

* fix(db): throw on invalid SIM_DB_ROLE instead of silently using web pools

* fix(db): use Object.hasOwn for SIM_DB_ROLE validation to avoid prototype keys
2026-06-27 15:15:05 -04:00
Theodore Li a68d38ae75 feat(db): attribute Postgres connections by runtime via application_name (#5211)
* feat(db): attribute Postgres connections by runtime via application_name

* improvement(db): label migration-runner connection sim-migrate; trim DB_APP_NAME comment

* fix(db): label realtime's shared @sim/db connections sim-realtime too

The realtime process uses both its own socketDb pool and the shared @sim/db
client (handlers, preflight, permissions). Only socketDb was labeled, so the
shared client defaulted to sim-app, mislabeling much of realtime's DB traffic.
Set DB_APP_NAME=sim-realtime at the process level (bootstrap before the dynamic
@/index import for prod; dev/start scripts for local) so both clients report it.
2026-06-25 17:11:23 -04:00
Theodore Li e1c3c7f6c9 feat(secrets): ingest env secrets at container runtime instead of fanning into ECS taskdef (#5189)
* feat(secrets): ingest env secrets at container runtime instead of fanning into ECS taskdef

The app/socket ECS taskdefs were ~42KB, ~93% of which was the secrets[] array:
268 pointer entries each restating the full ~78-char secret ARN, marching toward
the 64KB taskdef limit and growing ~150 bytes per hosted key added. The secret
blob itself is only ~18KB/268 keys.

Move secret delivery to container boot: new @sim/runtime-secrets loadRuntimeSecrets()
reads SIM_ENV_SECRET_ID, fetches the combined secret once, and hydrates process.env
(no-clobber, no-op when unset, fail-fast). Bootstrap entrypoints for app + realtime
await it before importing the real server (env-flags reads env at module load). The
app bootstrap is bun-bundled in the Dockerfile builder stage since it runs outside
the Next standalone bundle; realtime keeps full node_modules and runs the TS entry.

Backward-compatible: with the current fan-out taskdef the loader no-ops and the app
reads the injected env vars unchanged. The matching infra change (empty secrets[] +
SIM_ENV_SECRET_ID) ships separately, after this image is live.

* fix(runtime-secrets): address review feedback

- Move the binary-secret guard outside the retry loop (sendWithRetry) so a
  missing SecretString throws immediately instead of burning 3 attempts + backoff.
- Bound each Secrets Manager request with AbortSignal.timeout(5s) so a stalled
  response can't hang boot indefinitely.
- Drop the redundant @aws-sdk/client-secrets-manager pin from apps/realtime; it
  resolves transitively via @sim/runtime-secrets.
- Add a test for the non-retriable binary-secret path.
2026-06-24 16:23:37 -04:00
Vikhyath Mondreti 91f9dfdaec improvement(governance): derived access (#5134)
* improvement(governance): org-ws-credential roles clarity

* revert isHosted

* improvement(credentials): code cleanup

* address comments

* make kb cascade delete on user hard delete

* revert env flags

* chore(db): drop local 0242 migration to regenerate after merging staging

Our 0242 collides with staging's 0242. Remove it (and its snapshot +
journal entry) so the KB-cascade migration can be regenerated with the
correct number on top of the merged staging migrations.

* chore(db): regenerate kb→workspace cascade migration as 0243

Regenerated via drizzle-kit generate on top of the merged staging
migrations (staging took 0242). Re-applied the safety edits: NOT VALID
+ separate VALIDATE on the FK re-add, and the -- migration-safe note on
the DROP. check:migrations passes.

* improve copy

* update docs
2026-06-19 12:47:09 -07:00
Vikhyath Mondreti d14bc78d03 fix(realtime): re-check workspace role on mutating socket events (#5080)
* fix(realtime): re-check workspace role on mutating socket events

* address comments
2026-06-16 10:50:36 -07:00
Waleed ae075f871f Revert "fix(realtime): re-validate socket role and evict revoked collaborator…" (#5051)
This reverts commit 4ab8760ae0.
2026-06-14 22:20:12 -07:00
Waleed 4ab8760ae0 fix(realtime): re-validate socket role and evict revoked collaborators (#5050)
Socket.io authorized workflow access only at join and cached the workspace
role in presence, so a removed or downgraded collaborator kept live read/
write access until they disconnected.

- Re-validate the cached role against the permissions table on mutating
  events, bounded by a short TTL; refresh or evict on change
- Add /api/permissions-updated so the app reconciles active rooms, evicting
  revoked users (cross-pod) and refreshing downgraded roles
- Notify realtime on workspace member removal and permission changes
2026-06-14 21:19:32 -07:00
Vikhyath Mondreti f7b40fe4a4 fix(db-part-1): eliminate pool self-deadlock from nested checkouts inside transactions (#4975)
* fix(db-part-1): eliminate pool self-deadlock from nested checkouts inside transactions

* update docs
2026-06-11 17:29:13 -07:00
Vikhyath Mondreti d9f78c0f56 improvement(sockets): make offline mode recoverable and stop transient races tripping it (#4980)
* improvement(sockets): make offline mode recoverable and stop transient races tripping it

* data persistence issues should trigger offline mode and force refresh

* code cleanup
2026-06-11 16:52:59 -07:00
Waleed 272bad9718 feat(realtime): preflight schema-compatibility check on startup (#4940)
* feat(realtime): preflight schema-compatibility check on startup

The socket service authorizes every connection with a full-row query against
the workflow table. When a deploy ships a realtime image whose compiled schema
is ahead of/behind the live DB (e.g. a column dropped by a migration the image
predates), that query fails on every request and silently breaks persistence —
yet the process stays up and the shallow /health probe keeps returning 200, so
the deploy looks healthy while serving nothing.

Run one representative workflow query before listen(): a schema mismatch throws,
propagates to the entrypoint, and the task exits non-zero and never goes healthy,
so CodeDeploy auto-rolls-back instead of shifting traffic onto broken tasks.

Schema-class errors (undefined column/table/function) fail fast; connection-class
errors retry with backoff so a cold DB at boot does not flap. Runs once at
startup, never on the per-probe LB health check, to avoid a DB blip mass-
terminating the fleet (cascading failure).

* fix(realtime): unwrap cause for schema codes, drop sleep after final attempt

- isSchemaMismatch now walks the error.cause chain — drizzle wraps the driver
  error, so the SQLSTATE often lives on the inner cause, not the outer throw.
  Without this a wrapped 42703/42P01 was retried 5x and mis-reported as
  "database unreachable" instead of failing fast.
- No longer sleeps after the final failed attempt (~6-10s of dead wait that
  undermined the fail-fast contract); sleep now only happens between attempts.
- Tests: assert sleep is called exactly 4 times on exhaustion, and add a
  wrapped-cause fail-fast case.
2026-06-09 22:03:03 -07:00
+1 39418f9615 improvement(mothership): v0.2 (#4923)
* CONTRACTS

* updates

* Prompt caching

* Fix regression

* updates to prompt caching

* Prompt caching trace

* VFS updates

* Changelog and plan

* VFS update

* Improvement/mothership (#4775)

* ff

* System role in cache

* Fixes

* Add dynamic user info

* Improve tool search

* improvement(platform): workspace UI/UX overhaul + integrations catalog

Rework the workspace around the AI-workspace model: a Mothership home, a
top-level Skills route, connected-credential and integration-detail pages,
and a polished sidebar/settings surface. Replace the notifications store
with a unified toast system (provider-level dismiss/pause, countdown ring).

Integrations & catalog:
- Add a BlockMeta layer (tags + catalog templates) scoped to catalog-visible
  integrations; every catalog integration carries >=7 grounded templates.
- Rework the taxonomy: each block declares category tools|blocks|triggers.
  3rd-party services are 'tools'; first-party primitives (postgres, mysql,
  knowledge, file, search, stt/tts, image/video generators, thinking, etc.)
  are 'blocks'. Versioned blocks follow the upgrade paradigm (old hidden,
  latest in toolbar/docs).
- Generate integrations.json + tool docs canonically from block configs.

Architecture & cleanup:
- Consolidate block data extraction behind a single latest-version strategy
  (getCanonicalBlocksByCategory; version-consistent getBlockMeta).
- Unify version-suffix handling in @sim/utils/string (stripVersionSuffix /
  isVersionedType, with tests); registry, generate-docs, tools/utils, and
  integrations all route through it.
- Repair latent broken barrels, remove dead code, fix BlockMeta-related type
  errors and 5 broken docs links.

Behavior-preserving for block execution and the toolbar's tool/block listing.

* refactor(platform): remove forms, templates, and creators features

Remove three standalone features and their supporting code:
- Forms: form-deployment pages, API routes, execution path, and docs.
- Templates: the template gallery (landing + workspace) and template APIs.
- Creators: creator-profile routes and contracts.

Add a super-user permissions module (lib/permissions/super-user) and an
organizations API contract; update the audit/db/testing packages, billing,
and the session/theme providers accordingly.

* New doc setup

* Fixes

* Fixes

* Logs tools

* test(workflows): update archiveWorkflow update count after forms removal

The forms feature was removed, dropping the form-table update from
archiveWorkflow. Update the stale assertion from 8 to 7 tx.update calls.

* Add finalization on error

* Fixes

* change dev CI to bun run db:push

* Updates

Please enter the commit message for your changes. Lines starting

* Contrat update

* improvement(nested): subagents

* updates

* upgrade

* improvement(knowledge): polish tag filter dropdowns (#4816)

* improvement(logs): object storage backed tracespans (#4787)

* improvement(logs): obj storage backed tracespans

* fix storage write context

* fix tests

* address comments

* address comments

* chore(db): remove migration 0219 to regenerate after staging merge

Drops the 0219_robust_shard SQL, its snapshot, and the journal entry so the
trace-spans/cost schema migration can be regenerated on top of the latest
staging migration chain (avoids a number collision with staging's migrations).

Co-authored-by: Cursor <cursoragent@cursor.com>

* improvement(billing): accurate per-member usage via shared ledger helper

Per-member/per-user usage in the org-member routes now adds the usage_log
ledger to the currentPeriodCost baseline (which is no longer incremented),
via a shared getOrgMemberLedgerByUser helper to avoid repeating the
subscription→period→ledger lookup across the admin and member-facing routes.

Co-authored-by: Cursor <cursoragent@cursor.com>

* regen migrations

* update migration

* address comments

* more code cleanup

* incorrect type cast

---------

Co-authored-by: Cursor <cursoragent@cursor.com>

* improvement(providers): harden OpenAI-compatible providers + add tests (#4796)

* improvement(providers): harden OpenAI-compatible providers + add tests

* fix(vllm): let tool-loop errors propagate instead of returning silent partial success

* fix(litellm): force tool_choice 'none' on final structured-output call

The deferred final call used tool_choice 'auto', so the model could emit
another tool_calls round instead of the structured answer, leaving content
stale. Use 'none' (matching vLLM/Fireworks) on both the streaming and
non-streaming final calls so the model must return the structured response.

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

* fix(providers/ollama): drop tools from post-tool streaming call

Ollama ignores tool_choice (not in its supported fields), so vLLM/Fireworks'
tool_choice:'none' guard is a no-op here. Omit tools from the final streaming
payload instead so the summarization turn can't emit dropped tool calls.

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

* fix(litellm): spread payload into deferred final call so reasoning_effort carries over

The non-streaming deferred finalPayload hand-picked fields and dropped
reasoning_effort (and any future payload field), diverging from the streaming
path which spreads ...payload. Spread payload here too for consistency.

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

* chore(providers/ollama): restore enrichment TSDoc block

Keeps parity with sibling Chat Completions providers (cerebras/mistral/xai).

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

* docs(fireworks): restore TSDoc on utils helpers

Restore the TSDoc blocks on supportsNativeStructuredOutputs,
createReadableStreamFromOpenAIStream, and checkForForcedToolUsage —
TSDoc is the codebase documentation standard and should not have been
stripped.

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

* chore(litellm): remove inline rationale comments (codebase uses TSDoc)

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

* chore(providers/ollama): drop orphaned enrichment TSDoc

The block documented a function that now lives in trace-enrichment.ts, so it
documents nothing in this file.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* chore(copilot): deprecate mcp server (#4797)

* chore(copilot): deprecate mcp

* update error codes

* deprecate copilot api v1 route

* feat(integrations): hosted API keys for Findymail, Prospeo, and Wiza (#4777)

* feat(integrations): hosted API keys for Findymail, Prospeo, and Wiza

Add hosted-key support across all credit-consuming Findymail, Prospeo, and Wiza operations so Sim provides the key when a workspace has not brought its own. Register the three BYOK providers, consolidate Wiza's two-step reveal into a single polling wiza_individual_reveal op, and hide the API key field on hosted Sim for hosted operations.

* fix(integrations): harden Wiza reveal polling, soften enrichment getCost guards

Address Greptile + Cursor Bugbot review on #4777: return explicit failures from the Wiza individual_reveal poller instead of throwing (thrown errors were swallowed into a false queued success), short-circuit when the initial reveal is already terminal, tolerate transient 5xx/429 during polling, and return 0 (not throw) from Findymail getCost when the contacts/employees array is absent.

* chore(integrations): biome formatting after wiza merge resolution

* fix(wiza): type isTerminalReveal param structurally for next build typecheck

* feat(enrichments): add Findymail, Prospeo, Wiza to work-email waterfall

* feat(enrichments): add Wiza + Prospeo phone reveal to phone-number waterfall

* feat(enrichments): opportunistic identifiers + LinkedIn URL input across work-email & phone cascades

* fix(tables): reduce column header chevron size and fix sidebar shadow bleed (#4800)

* feat(slack): add install + privacy section to integration landing page (#4799)

* feat(slack): add install + privacy section to integration landing page

Adds a hand-authored, slug-keyed landing-content module (separate from the generated integrations.json so it survives regeneration) and renders an install walkthrough + privacy-policy link on integration pages when present. Also refreshes generated docs (data-enrichment entry, icon mappings, tool mdx).

* fix(landing): render privacy section independently, align CTA analytics label

* docs(landing): clarify the Slack install button is behind sign-in

* refactor(landing): bake integration landing content into generated json via docs-gen

Moves landing content (install walkthrough + privacy) out of a render-time augment and into the generation pipeline: generate-docs reads the pure-data content map and writes landingContent into integrations.json, so the page reads a single source (integration.landingContent). Canonical types live in integrations/data/types.ts.

* improvement(enrichments): align enrichments sidebar with design system (#4801)

* improvement(enrichments): align enrichments sidebar with design system

* fix(enrichments): consistent close button pattern and fix url link hover

* fix(misc): upgrade path change for new better-auth version, billing issue for workflow block agent usage (#4803)

* fix(misc): upgrade path change for new better-auth version, double-billing for workflow block agent usage

* fail loudly if stripe sub id missing

* fix(copilot): seq migration (#4804)

* chore(db): drop redundant idx_webhook_on_workflow_id_block_id index (#4809)

Removed because (workflow_id, block_id) is a left-prefix of idx_webhook_on_workflow_id_block_id_updated_at_desc, which fully covers it. The dropped index was non-unique and enforced no constraint.

* perf(copilot): read chat transcripts from copilot_messages (R+1 cutover) (#4808)

* perf(copilot): read chat transcripts from copilot_messages, not JSONB

Flip user-facing chat reads from the legacy copilot_chats.messages JSONB
array (5.7GB, 99% TOAST) to the normalized copilot_messages table via a
new loadCopilotChatMessages helper ordered by seq NULLS LAST, created_at,
id — the verified canonical order. Both chat-detail getters
(getAccessibleCopilotChat, getAccessibleCopilotChatWithMessages) now drop
the messages column from their metadata select (no more whole-array
detoast on every load) and assemble the transcript from the table after
authorization. This cascades to the copilot + mothership GET endpoints
and to resolveOrCreateChat's conversationHistory (the LLM payload).

The normalize/effective-transcript pipeline is source-agnostic
(copilot_messages.content == a JSONB array element), so transcripts are
byte-identical. Dual-write and the JSONB column stay in place as the
internal-logic source and fallback; removing JSONB writes is a later step.

Prod integrity verified before cutover: 0 messages missing, 0 NULL-seq,
0 dup keys/seq, 0 orphans, order-parity vs JSONB = 0 mismatches.

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

* test(copilot): cover auth-deny on a found row skips the messages query

Address PR review: exercise the `if (!authorized) return null` contract —
when the chat row exists but authorization fails, the getter returns null
and never issues the copilot_messages read.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tables): right-align run/stop in embedded toolbar; workflow cells format like normal cells (#4806)

* fix(tables): right-align run/stop in the embedded table toolbar

Add a right-aligned `trailing` slot to ResourceOptionsBar and move the embedded
mothership table's run/stop control into it, so Filter + Sort stay left-aligned
and run/stop sits opposite on the right. No-op for the search-bearing consumers
(logs, resource list), which don't pass `trailing`.

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

* fix(tables): workflow-output cells format values like normal cells

Workflow-output columns short-circuited in resolveCellRender and rendered their
value as plain text, so a sim-resource URL / external URL / JSON / date produced
by a workflow never got the chip, favicon link, or typed formatting a normal
cell gets. Factor value formatting into a shared `resolveValueKind` helper used
by both the workflow-value branch and the plain-cell branch; the workflow branch
keeps the typewriter reveal for plain streaming text via a `typewriter` flag.

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

* fix(tables): detect resource/URL links on workflow output regardless of column type

Workflow output columns default to `json` (columnTypeForLeaf), so routing their
values through the type-based formatter (a) gated chip/URL promotion behind
`column.type === 'string'` — a URL produced by a json-typed output never became
a chip — and (b) JSON.stringify'd plain string values, adding quotes and losing
the typewriter reveal. Detect links (sim-resource chip / favicon URL) on the
value string directly for workflow outputs, falling back to the plain `value`
kind; plain cells keep the type-based formatting. Addresses Greptile P2 on #4806.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(icons): repair broken integration icon rendering (#4810)

* fix(icons): repair broken integration icon rendering

Two distinct bugs left integration icons broken on the /integrations page
(visible at 32-40px, hidden at the toolbar's 16px):

1. Corrupted SVG paths (Notion, Greptile, Granola, Calendly, Grafana, Bedrock):
   over-minified data dropped elliptical-arc flag digits (e.g. `A1 1 0 5.9 7`
   instead of `A1 1 0 0 0 5.9 7`); Granola's cubic stream was truncated. Browsers
   abort path parsing at the first invalid arc flag, so each rendered as a fragment
   or blank. Replaced with correct path data from canonical sources, preserving each
   icon's existing fill/gradient and bgColor.

2. Invisible glyph (Bright Data): its icon uses fill='currentColor' but bgColor was
   '#FFFFFF', and every surface forces text-white on the glyph - white-on-white.
   Changed bgColor to Bright Data's brand blue (#3d7ffc) so the white glyph reads,
   matching the white-glyph-on-brand-chip convention.

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

* fix(icons): restore Calendly dual-tone brand colors

Addresses review feedback: the previous fix replaced the broken Calendly icon
with a monochrome #006BFF path, dropping the cyan #0ae8f0 accent from the
original dual-tone mark. Restored the two-tone logo (blue + cyan) using clean,
valid path data, cropped to a tight square viewBox so it fills the chip.

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

* improvement(icons): enlarge icons, fix Zoom contrast and Quiver chip

- Zoom: glyph was blue-on-blue (#0B5CFF on #2D8CFF chip); switched to
  currentColor so it renders as a white glyph on the blue chip.
- Quiver: chip bgColor #000000 -> #FFFFFF to match the icon's near-white box,
  and enlarged the mark slightly (viewBox crop).
- Enlarged (tightened viewBox, verified no clipping): RevenueCat, Prospeo,
  Granola, Firecrawl, Enrich.so, and the AWS icons (RDS, DynamoDB, SQS,
  CloudFormation, Athena, CloudWatch, SES, Bedrock, S3).
- ZoomInfo left unchanged: it is a full red rounded-square logo that already
  fills its frame, so a crop would clip it.

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

* fix(icons): use Bright Data wordmark on white chip; repair Circleback

- Bright Data: replaced the flame glyph with the official two-tone 'bright data'
  wordmark (provided asset), centered in a symmetric viewBox. Reverted the chip
  bgColor from #3d7ffc to #FFFFFF since the blue wordmark is invisible on a blue
  chip (the wordmark is designed for a light background).
- Circleback: a minifier had rounded the pattern's image scale to scale(0),
  collapsing the embedded logo to zero size (invisible). Restored the correct
  scale (1/280 = 0.00357142857) so the C. mark renders.

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

* fix(docs): sync Quiver block color card to white chip

Reflects the Quiver bgColor change (#000000 -> #FFFFFF) in the docs block info card.

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

* improvement(icons): enlarge AWS/Cloudflare/Dagster icons, fully white Zoom

- Enlarged (tighter viewBox, render-verified, no clipping): Cloudflare, Dagster,
  and the red AWS icons AWS IAM, Identity Center, Secrets Manager, SES, STS.
  Identity Center was anomalously small (filled ~32% of its frame); the group is
  now sized consistently (~80% fill).
- Zoom: the camera lens triangle was still #0B5CFF (blue-on-blue); switched it to
  currentColor so the whole camera renders white on the blue chip.

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

* docs(wiza): consolidate individual reveal into a single operation

Merges the separate Start/Get Individual Reveal operations into one Individual
Reveal operation in the Wiza docs and integrations data (operationCount 5 -> 4).

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

* improvement(icons): size remaining AWS icons to match the set (~80% fill)

Bring RDS, DynamoDB, SQS, CloudFormation, Athena, CloudWatch and S3 up to the
same ~80% fill as the AWS IAM/Identity Center/Secrets Manager/SES/STS group, so
all AWS icons are visually consistent. Bedrock left as-is (already ~92% fill).

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

* fix(icons): use Bright Data flame mark, enlarge ZoomInfo

- Bright Data: the full 'bright data' wordmark was illegible at chip size.
  Replaced with just the flame-'i' brand mark (blue #4280f6 on the white chip),
  centered.
- ZoomInfo: cropped the viewBox toward the white 'Zi' so it's larger; the red
  rounded-square background still fills the chip.

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

* improvement(icons): enlarge CrowdStrike icon

The falcon mark sat small in its chip because the icon used a wide 768x500
viewBox (letterboxed in the square chip). Switched to a square viewBox centered
on the mark so it fills ~80%, consistent with the other icons.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tables): serialize schema mutations to prevent parallel column clobber (#4812)

* Make workflow description nullable

* fix(tables): serialize schema mutations to prevent parallel column clobber

* fix(tables): load workflow outside schema lock; use DbOrTx for getTableById

* fix(tables): scale idle timeout in updateColumnType to avoid aborting large type changes

* fix(tables): skip stale remap types when workflowId changes concurrently

* fix(tables): scale idle timeout in updateColumnConstraints for large tables

* fix(wait): resume live/draft async waits and preserve cell context on chained waits (#4814)

* Make workflow description nullable

* fix(wait): resume live/draft async waits and preserve cell context on chained waits

* improvement(knowledge): polish tag filter dropdowns

* improvement(knowledge): soften filter section labels

* improvement(knowledge): soften list filter labels

* fix(security): harden SSO domain registration, webhook path isolation, and CSV export (#4813)

* fix(security): harden KB file access, SSO domain registration, webhook path isolation, env secrets, and CSV export

* fix(sso): scope domain conflict query with indexed lower(domain) filter

Address PR review: avoid a full-table scan on every SSO provider
registration by filtering candidate rows in SQL with
lower(domain) = <normalized>, keeping the in-memory ownership check.
Also tighten the normalizeSSODomain TSDoc.

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

* chore: condense env route security comments

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

* icons update

* chore(security): tighten inline comments in CSV export and KB file authorization

Condense verbose comment blocks to concise TSDoc/single-line form; no behavior change.

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

* fix(security): validate internal serve origin in KB file authorization

Replace the bypassable isInternalFileUrl substring check in resolveInternalKbKey
with an origin allow-list (base URL, internal API base URL, TRUSTED_ORIGINS).
A crafted external host whose path is /api/files/serve/<victim-key> no longer
resolves to the victim key. Relative same-origin URLs are unaffected.

* style(sso): use idiomatic sql lower() comparison for domain conflict query

Match the repo's prevailing `sql`lower(col) = value`` idiom for the
case-insensitive SSO domain conflict lookup.

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

* fix(security): align workspace env admin gate with hasWorkspaceAdminAccess

Use the same admin check the secrets UI uses (owner, admin permission, or
org-admin) so owners and org-admins are not wrongly denied their own decrypted
workspace secrets, while read-only members remain restricted to names only.

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

* fix(sso): rely on lower(domain) match for conflict detection, drop dead in-memory recheck

Address PR review: the SQL `lower(domain) = <normalized>` predicate already
excludes rows that the in-memory `normalizeSSODomain(...) === domain` recheck
claimed to catch, making that recheck dead/misleading code. Match on the
canonical lower-cased domain and filter purely by ownership. Malformed legacy
values (wildcards, schemes, ports) never match an email domain at sign-in, so
excluding them is not a gap. Test DB mock now applies the lower() predicate so
the casing-variant case is genuinely exercised.

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

* fix(security): scope webhook deploy path conflict to active webhooks

findConflictingWebhookPathOwner omitted the isActive filter that the
runtime dispatcher (findAllWebhooksForPath) applies, so an inactive but
non-archived webhook from another workflow (e.g. after undeploy or
failure auto-disable) would permanently block any new deployment on that
path even though it never receives deliveries. Align the guard with the
runtime isActive + archivedAt filter; the earliest-owner runtime check
remains the authoritative cross-tenant protection. Also trims verbose
TSDoc on the webhook path-isolation helpers.

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

* fix(security): exclude archived workflows from webhook deploy path conflict

findConflictingWebhookPathOwner now joins workflow and filters
isNull(workflow.archivedAt), matching the runtime dispatcher
(findAllWebhooksForPath). A webhook on an archived workflow can never
receive deliveries at runtime, so it must not block legitimate path reuse
with a 409.

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

* fix(security): anchor KB file ownership to earliest document in any state

A KB file's owner is now the earliest document referencing its key regardless of
state (active/archived/deleted/excluded); access is granted only when that owning
document is still active. Closes the residual where an attacker could plant an
active document to claim a file whose original document was archived or deleted.

* updated greptile icon

* revert(security): drop KB file authorization changes

Reverts the knowledge-base file-access work (origin-pinning / owner-pinning /
origin allow-list in verifyKBFileAccess) and its test. The other hardening fixes
(SSO domain registration, webhook path isolation, workspace env secrets, CSV
export) are unchanged. apps/sim/app/api/files/authorization.ts is restored to its
origin/staging baseline.

* fix(sso): treat caller's own user-scoped provider as owned during conflict check

Self-hosters often register SSO user-scoped via the CLI script (no
SSO_ORGANIZATION_ID). If they later enable organizations and reconfigure the
same domain org-scoped through the UI, the conflict check previously treated
their own user-scoped row as another tenant's and returned a misleading 409.
Recognize the caller's own user-scoped provider as owned so that migration is
allowed, while still blocking another user's or another org's domain.

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

* revert(security): remove workspace-env admin gate

Defer to a credential-based access model (separate change). Restores
GET /api/workspaces/[id]/environment to main behavior and removes the test.

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

* refactor(security): consolidate webhook path-collision check into one helper

Extract findConflictingWebhookPathOwner to lib/webhooks/utils.server.ts as
the single source of truth for cross-tenant path-collision detection, used by
both webhook creation paths (deploy sync and the manual /api/webhooks route).

This also repairs two latent issues in the manual route's previous inline
check, which queried with limit(1) and only webhook.archivedAt:
- limit(1) inspected one arbitrary row, so a same-workflow row could mask a
  foreign collision (false negative). The shared helper scans all matching
  rows.
- It omitted isActive/workflow.archivedAt, so inactive or archived-workflow
  webhooks (which never receive deliveries) permanently blocked path reuse.
  The helper mirrors the runtime dispatcher's filter.

Same-workflow webhook reuse for upsert is now a separate, explicit lookup.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(security): block private/reserved IPs for hosted 1Password Connect SSRF (#4818)

* fix(security): block private/reserved IPs for hosted 1Password Connect SSRF

* test(security): use real isPrivateOrReservedIP and cover IPv6 edge cases

* improvement(integrations): validate and expand devin, cursor, and greptile (#4820)

* improvement(integrations): validate and expand devin, cursor, and greptile

- devin: fix missing org_id path segment on all session endpoints, add 7 session sub-resource tools (list messages/attachments, get/append/replace tags, archive, terminate), pagination, and is_archived output
- cursor: add get_api_key_info, list_models, list_repositories tools
- greptile: align block and docs
- normalize array outputs to default [] and tighten types

* refactor(cursor): simplify list_repositories v2 array normalization

Collapse the redundant `?? []` + `Array.isArray` double-guard into a
single Array.isArray check, per PR review feedback.

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

* fix(devin): scope session-tag mapping to tag ops and normalize array tag inputs

- Only map sessionTags into the tools tags param for append/replace operations,
  preventing stale sessionTags state from clobbering create_session tags
- Fall back to a wired tags value when sessionTags is empty for tag operations
- Normalize tag inputs (string or wired string[]) via normalizeTags so array
  values from other blocks no longer throw on .split

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

* fix(cursor): restore base64 file data in legacy download_artifact metadata

The legacy CursorBlock exposes only content + metadata (no v2 file
output), so metadata.data was the only way legacy-block workflows could
access downloaded artifact bytes. Restore the base64 data field and
document it in the outputs/type instead of dropping it.

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

* fix(devin): coerce terminateArchive to archive flag for boolean-wired input

* docs(integrations): regenerate tool docs for new devin and cursor operations

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(search-replace): don't auto-navigate when content edits invalidate the active match (#4819)

* fix(search-replace): don't auto-navigate when content edits invalidate the active match

* fix(search-replace): clear afterReplaceIndexRef on apply failure and zero matches

* fix(search-replace): remove duplicate setActiveSearchTarget(null) on close

* fix(search-replace): move afterReplaceIndexRef write inside handleApply past the guard

* fix(search-replace): auto-navigate when hydration resolves with no prior active match

* chore(search-replace): remove inline comments

* fix(search-replace): revert !activeMatchId guard that caused immediate re-navigation after deselect

* improvement(enrichments): limit company-info to fields both providers return (#4817)

Hunter's company dataset returns null industry/foundedYear for many large companies (verified against the live API for Microsoft, Amazon, Google), so under the first-non-empty-wins cascade those columns appeared inconsistently across rows. Limit company-info outputs to employee count and description — the fields Hunter and PDL both reliably return — so every row is consistent. employeeCount is a string so Hunter's range bucket and PDL's exact count share the column.

* fix(files): don't reject external URLs containing '..' in file parse validation (#4821)

* fix(files): don't reject external URLs containing '..' in file parse validation

The file block's file_fetch operation rejected any external URL whose path
contained '..' (e.g. Slack files-pri slugs with a literal '...') with
'Access denied: path traversal detected'. Traversal checks only apply to
local paths — external http(s) URLs are fetched with SSRF protection
downstream and are never resolved against the filesystem, so they now
short-circuit as valid. Internal /api/files/serve/ URLs keep full traversal
protection.

* test(files): fix external-URL assertion to handle undefined error

* test(files): assert success explicitly in external-URL traversal test

* fix(files): keep traversal protection for https URLs matching internal serve paths

* feat(google-sheets): add row filtering to read with numeric operators (#4822)

* feat(google-sheets): add row filtering to read with numeric operators

Adds client-side row filtering to the Google Sheets read (v2) operation.
Filter the returned rows by a header column using text operators
(contains, not_contains, exact, not_equals, starts_with, ends_with) and
numeric/ordering operators (gt, gte, lt, lte). Filtering lives in a pure,
unit-tested helper (filterSheetRows) and runs over the fetched read range;
an optional `filter` output reports whether the column was found and how
many rows matched.

Also hardens the surrounding tools:
- trim spreadsheetId in write/update/append URL builders (matches read)
- URL-encode the v1 read default range
- expose valueInputOption for the update operation in the block

Backwards compatible: with no filter requested, read output is byte-
identical and the `filter` field is omitted. The filterMatchType union is
widened additively (4 -> 10 values).

* fix(google-sheets): correct filter metadata for missing column and header-only sheets

- matchedRows is now 0 (not totalRows) when the filter column is not found,
  so it no longer contradicts applied=false / columnFound=false
- columnFound now reflects an actual header lookup for empty/header-only
  sheets instead of being hardcoded true
- add tests covering header-only and empty sheets with present/absent columns

* fix(selectors): fetch all pages for paginated dropdown list routes (#4823)

* fix(selectors): fetch all pages for paginated dropdown list routes

Dropdown selectors fetched only the first page of paginated provider
APIs, silently hiding results past page one. Add bounded server-side
draining to the list routes across Microsoft Graph, Google, Notion,
Atlassian, Linear, AWS CloudWatch, and offset/token REST APIs, plus a
shared client-side drain cap in the selector hook. Response shapes,
stored values, and tool execution are unchanged; CloudWatch list tools
still honor a caller-supplied limit. Also fixes the Word file picker
that was searching for .xlsx files.

* fix(selectors): harden JSM and Monday pagination draining

- JSM service-desk/request-type drains advance `start` by the actual row
  count returned (not the fixed page size) and stop on an empty page, so a
  short non-final page can't skip items.
- Monday boards drain now checks `response.ok` per page, surfacing a
  mid-drain HTTP failure instead of treating it as an empty final page and
  returning a partial 200.

* docs(selectors): clarify JSM drain advances start by actual row count

The offset-advancement fix (advance `start` by the rows returned, not the
fixed page size) landed in 7b19788a8; update the TSDoc to match so it no
longer reads as advancing by `limit`.

* fix(selectors): drain fetchPage in direct fetchList callers

Making `fetchList` optional left three direct callers (outside the
useSelectorOptions hook) calling it unguarded, which broke the build's
type check. Route them through a shared `loadAllSelectorOptions` helper
that uses `fetchList` when present and otherwise drains `fetchPage`.
This also prevents a regression: `confluence.spaces` / `knowledge.documents`
now paginate via `fetchPage` only, and these callers (search/replace,
value resolution) would otherwise have silently returned no options.

* chore(selectors): rename MAX_PAGE_PAGES to MAX_NOTION_PAGES for readability

* fix(sso): re-check domain conflict before write and reject IP-address domains (#4825)

* improvement(copilot): make copilot_messages the sole transcript store, remove JSONB dual-write (#4826)

Stop writing/reading the legacy copilot_chats.messages JSONB column now that
reads are cut over to copilot_messages. Make appendCopilotChatMessages the
primary write (throws on failure instead of swallowing), repoint peripheral
readers (workspace VFS, chat cleanup, data drains, fork, superuser import) to
copilot_messages, and persist the assistant turn inside finalizeAssistantTurn's
transaction so it commits atomically with the stream-marker clear. The column
itself is dropped in a follow-up migration after this bakes.

* feat(tables): expand filter operators (not-contains, starts/ends-with, not-in, empty) (#4827)

Add does-not-contain ($ncontains), starts-with ($startsWith), ends-with
($endsWith), not-in-array ($nin, previously executed server-side but unexposed
in the UI), and is-empty/is-not-empty ($empty) filter operators end-to-end —
SQL builder, condition types, query-builder converters/constants, the filter
UI, the Table tools/block descriptions, and docs.

Also fix correctness bugs in the filter builder surfaced by the wider operator
set:
- Same-column AND rules (e.g. age > 18 AND age < 65, or name startsWith 'A'
  AND name endsWith 'Z') silently overwrote each other because the AND group
  was keyed by column name. They now merge into one operator object, which
  also makes Filter -> rules -> Filter round-trip losslessly for multi-operator
  columns.
- $nin values were not split into an array like $in, and textual-match values
  like "123" were numeric-coerced (breaking the ILIKE path).
- A non-boolean $empty operand from the raw API silently inverted the check; it
  now coerces 'true'/'false' strings and otherwise returns a 400.

* improvement(copilot): stop persisting tool-call result outputs in transcripts (#4829)

Opening a Mothership task could take many seconds because a single persisted
assistant message in copilot_messages.content can reach hundreds of MB, almost
entirely inside contentBlocks[].toolCall.result.output (e.g. a get_workflow_logs
or run_workflow result). The DB query is ~2ms; the cost is detoasting that
payload, shipping it to the browser, and parsing it.

These outputs are dead weight on the Sim side: they are never rendered (the
thread shows only tool name/title/status) and never replayed to the model (the
upstream copilot service owns conversation memory). So drop result.output before
it is persisted, keeping result.success/error plus the tool metadata.

- add stripToolResultOutput() in persisted-message.ts
- apply it in messages-store toRow (covers every write path) and in
  loadCopilotChatMessages (existing rows render fast on read)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(providers): add Together AI, Baseten, and Ollama Cloud model providers (#4830)

* feat(providers): add Together AI, Baseten, and Ollama Cloud model providers

* fix(providers): guard Ollama streaming fast-path with hasActiveTools

Match Together/Baseten/Fireworks: when tools are supplied but all are
filtered out (usageControl 'none'), take the single streaming call instead
of an extra non-streaming round-trip.

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

* fix(providers): filter non-chat model types from Together model list

* refactor(providers): dedupe Ollama Cloud upstream schema

ollamaCloudUpstreamResponseSchema was byte-for-byte identical to
ollamaUpstreamResponseSchema (both /api/tags endpoints return the same
{ models: [{ name }] } shape). Drop the duplicate and reuse the shared schema.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(knowledge): calendar view sync, deduplicate popover animation classes, type-safe filter cast

* cleanup(knowledge): remove TRIGGER_BORDER_CLASS duplication, inline displayLabel, drop enabledFilterParam alias

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Waleed <walif6@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Theodore Li <theo@sim.ai>
Co-authored-by: andresdjasso <andresdjasso@users.noreply.github.com>

* feat(blocks): add BlockMeta to Quiver and Linq; fix invalid block config fields; update skills

Block fixes:
- Add QuiverBlockMeta (tags + 3 templates: icon generator, diagram creator, vectorizer)
- Fix QuiverBlock: remove invalid tags field from BlockConfig, IntegrationType.Design →
  IntegrationType.AI (Design doesn't exist in the enum)
- Fix GreptileBlock: remove invalid tags field from BlockConfig,
  IntegrationType.DeveloperTools → IntegrationType.DevOps
- Fix LinqBlock: remove invalid tags field from BlockConfig (tags belong only in BlockMeta)

Skills:
- add-block: add dedicated BlockMeta section with structure, rules, and registration
  pattern; add BlockMeta checklist items
- add-integration: add BlockMeta to block structure template, add rules clarifying
  that tags must NOT appear on BlockConfig and integrationType must be a valid enum
  value; update registry snippet to include blocksMeta; add checklist items

* fix(integrations): fix category dropdown by defining missing LANDING_INTEGRATIONS_DATA_PATH and regenerating integrations.json

The staging merge introduced landing-content.ts but forgot to define
LANDING_INTEGRATIONS_DATA_PATH in generate-docs.ts, causing the script
to crash before writing integrations.json.

The stale JSON had integrationTypes (plural array) from an older script
version, while the Integration type and workspace UI both read
integrationType (singular string) — so ALL_CATEGORY_SECTIONS bucketed
to undefined and the category filters never appeared in the dropdown.

Fixed by adding the missing path constant and re-running the generator.
integrations.json now has 192 entries with the correct integrationType field.

* fix(sidebar): restore resize handle on all pages

commit 3109104582 wrapped the resize handle in {(isCollapsed ||
isOnWorkflowPage) && ...} and added a useEffect that resets sidebar
width to SIDEBAR_WIDTH.MIN whenever the user navigates away from a
workflow page. Together these made the sidebar non-resizable on Tasks,
Tables, Knowledge Base, and every other non-workflow page.

Restore the staging behavior: always render the resize handle and
remove the effect that forced the width reset on page transitions.

* fix(sidebar): match staging onKeyDown and tabIndex on resize handle

The resize handle was still conditionalizing onKeyDown and tabIndex
on isCollapsed, blocking keyboard accessibility of the separator role
when expanded. Staging always attaches both unconditionally.

onKeyDown={isCollapsed ? handleEdgeKeyDown : undefined} → onKeyDown={handleEdgeKeyDown}
tabIndex={isCollapsed ? 0 : undefined}                 → tabIndex={0}

* feat(integrations): show connected credentials on integration detail page

When navigating to /integrations/google-docs (or any integration), a
Connected section now appears above the templates listing all workspace
credentials tied to that provider. Each row links back to the credential
detail page (/integrations/connected/${id}) for management actions.

Pairs with the earlier change that routes connected items from the
integrations list to the provider detail page instead of directly to
the credential detail page.

* fix(integrations): rename Add in chat to Add to Sim

* fix(skills): rename Add button to Add to Sim

* fix(platform): restore M1/M2/M3 regressions and LazyMotion on landing page

M1 — Invitation guard: re-introduce usePermissionConfig().isInvitationsDisabled
alongside the workspace inviteDisabledReason check. The flag now also
respects NEXT_PUBLIC_DISABLE_INVITATIONS and EE permission-group
disableInvitations, not just billing policy.

M2 — Settings redirects: /settings/integrations and /settings/skills
now server-redirect to /integrations and /skills respectively so old
bookmarks and emails don't silently land on General.

M3 — Starter block search exclusion: restore block.type !== 'starter'
guard in the search store so users cannot add a duplicate Starter block
via the command palette.

LazyMotion: restore LazyMotion + domMax/domAnimation wrappers and m.*
components in landing-preview-panel and landing-preview-home. The
removal was accidental (the full motion bundle was left after an
import cleanup), which caused the entire framer-motion feature set to
load eagerly on the landing page.

* fix(integrations): revert connected list to credential detail; remove settings redirects

* feat(sidebar): restore workspace switcher search with updated styling

Shows a search input in the workspace dropdown when the user has more
than 3 workspaces (WORKSPACE_SEARCH_THRESHOLD). Keyboard navigation:
ArrowDown/Up to move through results, Enter to switch, resets on close.

Styled to match the current branch (border-1/surface-5 tokens, sm text,
11px Search icon) rather than the old staging styles. Highlight state
is wired through chipVariants active prop so it follows the same active
appearance as clicked/hovered items.

* fix(sidebar): clean up workspace search — layout, memo, and effect guard

* fix(sidebar): align workspace rename input selection style with workflow rename

* perf(sidebar): eliminate React re-renders during sidebar drag resize

Previously, every mousemove during resize called setSidebarWidth(), which
both updated the --sidebar-width CSS variable (sync) and set Zustand state
(async). This caused:
  1. A 1-frame transition flash on mousedown — isResizing state had to
     round-trip through React before the is-resizing CSS class was applied,
     so the width transition fired for the first pixel of movement.
  2. A React re-render per pixel dragged — components reading sidebarWidth
     from the store (avatars, usage-indicator) lagged one frame behind the
     container, making the + and ... buttons appear to jump ahead.

New approach:
  - handleMouseDown adds is-resizing directly to the sidebar DOM node before
    any React involvement (synchronous, no frame lag).
  - mousemove writes only to the CSS custom property (zero React renders).
  - mouseup persists the final width to Zustand exactly once.
  - isResizing / setIsResizing state removed from the store and hook — they
    are no longer needed since the class is managed via direct DOM mutation.

* perf(sidebar): add requestAnimationFrame throttle to resize mousemove handler

* fix(sidebar): fix drag-right lag caused by WorkspaceChrome overflow-hidden transition

The sidebar-container's is-resizing class correctly suppressed its own width
transition, but the two wrapper divs in WorkspaceChrome both have
transition-[width]/transition-transform with a 175ms ease. The outer wrapper
also has overflow-hidden, so while the sidebar content was at the correct width
instantly, it was visually clipped by the outer wrapper which was still
animating — causing the + and ... buttons to appear to lag behind the resize
line on drag-right (not on drag-left, since shrinking doesn't clip content).

Fix: add sidebar-shell-outer/sidebar-shell-inner class names to both chrome
wrappers, and suppress their transitions via html.sidebar-resizing rule when
a drag is active. The html.sidebar-resizing class is toggled directly in the
resize hook alongside is-resizing, so it takes effect synchronously on mousedown.

* fix(icons): redesign Download icon to match Upload style; fix Upload/Download confusion

Download icon was missing the tray/shelf line at the bottom that Upload has,
making it look like a plain arrow rather than a matched pair. Updated Download
to use the same viewBox, stroke weight, and three-path structure as Upload
(tray + stem + arrowhead), just pointing down.

Also fix 5 places where Upload (↑) was incorrectly used for download/export
actions:
  - files.tsx: two Download action rows in toolbar and context menu
  - tables/table.tsx: Export CSV toolbar button
  - table-context-menu.tsx: Export CSV context menu item
  - logs.tsx: Export toolbar button
  - landing-preview-logs.tsx: decorative Export button

Import CSV and actual upload actions correctly keep the Upload icon.

* fix(icons): replace Upload with Download on all remaining export/download actions

- panel.tsx: Export workflow dropdown item
- context-menu.tsx: Export in sidebar workflow context menu
- chat.tsx: Export chat button
- output-panel.tsx: Export console CSV button
- terminal.tsx: Export console CSV button
- resource-content.tsx: Export table as CSV + Download file buttons

* fix(icons): fix remaining Upload→Download on download actions in files and logs

- action-bar.tsx: download button in files toolbar
- file-row-context-menu.tsx: Download item in file context menu
- file-download.tsx: both download buttons in log details file viewer

* updated block skills, settings pages, modals, buttons -> chips, blocks missing metadata

* updated skill modal

* improvement(resource-header): refine breadcrumb truncation ux

* Fixes

* improvement(resource): add floating overflow text tooltips

* wire up credits counter

* improvement(resource-header): mute path dropdown title

* refactor(resource-header): share floating-tooltip engine, prune dead overlay tooltips (#4844)

Clean up the breadcrumb truncation feature for reuse and correctness:

- Extract useFloatingTooltip / useIsOverflowing / FloatingTooltip into a shared
  floating-tooltip module. BreadcrumbSegment and FloatingOverflowText now consume
  one implementation instead of duplicating ~150 lines of positioning, velocity,
  overflow-detection, and portal logic.
- Replace the hardcoded terminal-label regex in ResourceHeader with a typed
  `terminal` flag on BreadcrumbItem (set by the document chunk/loading crumbs),
  decoupling the generic header from knowledge-base copy.
- Clear the path-popover close timeout on unmount and reuse the shared
  POPOVER_ANIMATION_CLASSES constant.
- Drop the redundant manual overflow-state writes (fixes a sticky fade mask).
- Revert FloatingOverflowText inside Combobox `overlayContent` back to plain
  truncating spans across files/logs/tables/scheduled-tasks/document: the combobox
  overlay is pointer-events-none, so the tooltip handlers never fired there.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(emcn,resource-header): address PR #4844 review feedback

- useIsOverflowing now uses a callback ref so the ResizeObserver follows the
  element across mount/unmount/reassignment instead of capturing it once at mount.
  Safe for conditionally rendered consumers of the shared hook. (greptile P2)
- Move POPOVER_ANIMATION_CLASSES out of chip-date-picker implementation internals
  into emcn/components/popover/popover-animation.ts, exported from the
  @/components/emcn barrel. Consumers now import from the module boundary.
  (greptile P2)

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

* Prompt surface

* Regenerated contracts

* upgrade table and styling upgrade

* Literalness gaps

* Updates

* Remove prefix for running code

* Display names for reads

* Tool catalog

* fix schema to include integration

* fix(files): align delete icon with tables view (Trash → Trash2)

Co-Authored-By: waleed <waleed@simstudio.ai>

* fix(mothership): preserve blockType for integration contexts in sent messages

Integration mention chips were missing their provider icons in sent messages
because blockType was dropped when mapping ChatContext to messageContexts.
renderIntegrationTile returns null without blockType, silently hiding the icon.

* fix(mothership): allow 'integration' resource type in chat resources API

The VALID_RESOURCE_TYPES allowlist was missing 'integration', causing a
400 error when adding integrations to the Mothership resource tab — so
they never persisted and disappeared on refresh.

* fix(ui): Add "File" title next to file resource header

* fix(ui): fix resource header columns being bolded

* fix(resource): keep the floating tooltip from jumping on click

Gate the focus-driven show behind :focus-visible so a mouse click (which
focuses the trigger) no longer re-shows the tooltip anchored to the element's
bottom edge. On click the tooltip now hides cleanly instead of jumping down;
keyboard focus still shows it.

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

* perf(sidebar): eliminate unnecessary re-renders in workspace switcher for non-search users

- onMouseEnter: only set highlightedIndex when showSearch is true, preventing
  a state update + re-render on every workspace row hover for users with ≤ 3
  workspaces where the search is never shown
- onOpenChange: only reset workspaceSearch and highlightedIndex when showSearch
  is true, since both values are always already at their defaults for non-search
  users and setting them triggers a pointless re-render during dropdown close
- data-workspace-row-idx: only set when showSearch is true since the scroll
  effect that reads this attribute is already gated on showSearch

* feat(search): context-aware cmd-k results on the integrations page

When cmd-k is opened on the integrations page, show two new result
groups: connected accounts (visible even with empty input) and catalog
integrations (appear once the user types). Selecting an OAuth integration
deep-links to its detail page with ?connect=oauth so the connect modal
auto-opens. Non-OAuth integrations navigate to the plain detail page.

Both groups are gated to the integrations page only and respect the
hideIntegrationsTab permission. The credentials fetch shares the same
React Query cache key as the integrations page itself (no double fetch).

* refactor(emcn): make the floating tooltip the one canonical Tooltip

Replace the Radix-based emcn Tooltip with the cursor-following floating tooltip so
every tooltip in the app uses one consistent style. Built on the shared
floating-tooltip engine (relocated into emcn), not a parallel implementation.

- Move the floating-tooltip engine into emcn/components/tooltip and export it from
  the barrel; re-point its consumers (FloatingOverflowText, resource-header)
- Extend the FloatingTooltip bubble to render arbitrary children (+ role/id for
  a11y) so it can back general tooltips, not just overflow text
- Rebuild emcn Tooltip (Root/Trigger/Content/Provider/Shortcut/Preview) on
  useFloatingTooltip — compound API preserved, ~350 call sites unchanged, legacy
  side/align props accepted and ignored (the tooltip follows the cursor). Removes
  @radix-ui/react-tooltip usage (package kept for a later cleanup; react-slot
  retained for asChild)

Note: general tooltips now show instantly (no hover delay) and follow the cursor.

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

* style(emcn): put tooltip text on the design scale (text-caption)

Replace the tooltip's ad-hoc `text-xs` + `leading-[18px]` with the semantic
`text-caption` (12px) font-size token so the text styling is fully on the design
scale and self-documenting, matching how the rest of the system is set up. The
color already used the global `--text-body` token. No visual change (still 12px
with a ~18px line height).

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

* Mship byok

* feat(sidebar): add empty task state and inline task creation

- Show "No tasks yet" in the Tasks section (expanded and collapsed) when the list is empty
- Clicking + now creates a task via the API and navigates directly to it, rather than navigating to home
- Add isCreatingTaskRef guard to prevent double-click from spawning multiple tasks
- Disable + button while creation is pending
- Fall back to home navigation on creation error

* invite, billing, home

* Sandbox warning

* improvement(seats): auto purchase seats on invitations into workspace (#4857)

* improvement(seats): auto purchase seats on invitations into workspace

* improve sampling for seat drift reconciler

* address comments

* feat(knowledge): align connector UI with integrations page styling

- ConnectorTypeCard now matches integration rows: brand-colored rounded-xl tile, ArrowRight, title/subtitle hierarchy
- ConnectorCard icon upgraded from flat surface-4 to branded tile (white icon on brand bg, graceful fallback)
- Connector header badges use chipVariants instead of custom Button classes
- Add-connector search input aligned to integrations style (h-[30px], rounded-lg, border-1)

* fix(icons): trim Folder SVG viewBox to remove right-side whitespace

The folder path only extends to x≈14.33 in a 15-unit viewBox, leaving
~0.5 units of empty space on the right. At 12px rendered size this
produces ~0.4px extra gap (visible as ~1px on retina displays) compared
to solid icons like the workflow color square. Trimming the viewBox to
14.5 units makes the folder fill its chip slot evenly.

* fix(user-input): restore draft text synchronously to preserve contexts on nav

The SSR-safe approach (empty useState + effect restore) created a timing
window where the sync effect in useContextManagement fired with message=''
before the value was set, clearing any restored contexts. Folder and workflow
contexts (not re-added by applyAutoMentions) were lost on every nav-back.

Revert to the staging approach: initialize value synchronously from the
draft store so message is already populated when effects run, matching
the behavior on staging.

* fix(queue): render context chips in queued messages

- Remove plainMentions from queued message rows so context chips render
  with icons, consistent with sent messages
- Fix computeMentionRanges to use '/' prefix for skill contexts (content
  has the slash trigger restored at submit time, not '@')

* fix(mothership): remove integrations from add-resource dropdown

* fix(mothership): comment out integrations from add-resource dropdown

* File serializer

* Skills

* chore(db): drop form, templates, template_creators, template_stars tables

These tables backed the Forms and Templates platform features which were
intentionally removed from this branch. Clean up the DB schema to match.

* chore(db): add migration metadata for 0224 drop tables

* block icons, sidebar, toolbar

* chore: remove remaining dead code for template-profile feature

- Remove 'template-profile' from SettingsSection union type
- Remove 'template-profile' entry from SECTION_TITLES
- Remove now-redundant template-profile guard in settings sidebar
- Remove commented-out template-profile nav item

* fix(multi-select): preserve anchor on range selection for tasks and folders

After a shift+click range, the anchor (lastSelectedTaskId / lastSelectedFolderId)
was being updated to the end of the range (toId). This caused subsequent
shift+clicks to extend from the wrong point instead of the original click.

Standard behavior: anchor stays at the initial click (fromId) so repeated
shift+clicks always expand/contract relative to where you started.

* Update skills

* Load skill tool

* Fix vfs dynamic context encoding

* Media subagent

* feat(emcn): add SearchInput component and unify search bars platform-wide

- Add SearchInput to emcn: 30px chip-family filled search input matching the
  integrations page pattern (border-1, surface-5, leading Search icon)
- Migrate all 22 search bars across settings, EE pages, and integrations to
  SearchInput (only layout classes allowed at callsites)
- Rename Sim Keys -> Sim API Keys in nav/title; page copy now says API key
- Remove components/ui input, label, and verified-badge; migrate consumers
  to emcn equivalents or raw inputs (table cell editor, wand prompt bar)
- Delete dead EE skeleton files (data-drains, data-retention)
- General settings: Home Page chip moves to header left as navigation

* fix(files,tables): restore new-file editor autofocus and CSV import error toasts

Both were dropped on the staging line and regressed vs production (main):

- Files: the new-file editor autofocus chain (files.tsx -> file-viewer ->
  text-editor) was stripped by the react-doctor dead-code pass in #4544,
  which misread the prop-drilled `autoFocus` (consumed by an imperative
  `editor.focus()` effect) as unused. Restored the prop through all three
  layers and the one-shot focus effect so creating a new file focuses the
  editor immediately.
- Tables: CSV import failures were silently logged with no user feedback.
  Restored the per-file and generic `toast.error` surfacing.

* Move superagnet back into superagent

* feat(home): score suggested actions by workspace signals

- Derive the suggestion pool from the curated block template catalog
  (1,343 prompts across 172 blocks) instead of 15 hardcoded entries
- Fix inverted relevance: prompts for connected providers are now boosted
  4x (instantly runnable) instead of excluded; unconnected discounted 0.4x
- Weight by featured (3x), popular category (1.5x), and resource gaps
  (no tables -> boost table starters; has KBs -> dampen KB-creation prompts)
- Weighted sampling without replacement, max one suggestion per block
- Connect rows weighted by catalog template count; 2 for fresh workspaces,
  1 once something is connected
- Key the catalog map by both versioned and base block types so gmail_v2
  templates resolve (gmail, github, notion, linear were silently dropped)
- Replace derive-in-effect state with a useMemo keyed by a shuffle nonce
- Add suggested_action_clicked / suggested_actions_shuffled /
  suggested_actions_toggled PostHog events

* billing, teammates

* improvement(credentials): credentials invites, secrets tab wiring up (#4874)

* improvement(credentials): move away from invite notion

* wire up secrets ui/ux

* address comments

* get consistent styling by removing emcninput + text area

* styling consistency

* remove fallback

* address comment:

* refactor(ui): migrate settings & workspace UI to chip design system

Migrate modals to ChipModal (showDivider, hint, resizable, size, leading,
ChipModalTabs), standardize Chip variants, add ChipCombobox wrapper, and apply
chip inputs/dropdowns across settings, knowledge, logs, tables, inbox, EE tabs.
Render ChipModalTabs as a ChipSwitch segmented control. Align /settings/secrets
detail with /integrations, refresh whitelabeling, and restore file-editor
autofocus and CSV import error toasts.

* Update media

* fix(mothership): restore integrations to useAvailableResources for @ mention

Integrations were fully removed from useAvailableResources which broke
the @ mention menu since user-input shares the same hook. Now integrations
are always included in the hook but excluded at the AddResourceDropdown
component level, keeping them out of the sidebar + menu while remaining
available for @ mention autocomplete.

* refactor(settings): chip design-system consistency pass across all tabs

Extract a shared chip-field shell (CHIP_FIELD_SHELL/CHIP_FIELD_INPUT) mirroring
Input variant='chip' and route secrets, credential detail, and integrations
credential detail through it (30px height, font-medium, focus ring). Add a
Discard action to the secrets header when dirty. Group BYOK providers into
Models/Search & web/Enrichment sections and align its tiles to the integrations
tile.

Normalize list-row typography to text-[14px]/text-[12px] and icon tiles to
rounded-xl + border across api-keys, copilot, custom-tools, mcp,
workflow-mcp-servers, credential-sets, and access-control. Tone the secrets
Details chip and per-row affordances to ghost. Fix token correctness: raw
tailwind colors to design tokens (data-drains), missing chip variant on the
Snowflake role input, ColorInput chip-field reuse (whitelabeling), error token
and icon sizes (workflow-mcp-servers), border token (mcp), no-results sizing
(secrets), row chrome (recently-deleted), hover token and Button to Chip
(access-control), and deduped textarea chrome (sso).

* feat(settings): unify filter dropdowns on ChipSelect (integrations style)

Add a ChipSelect emcn component — a filled chip trigger + chevron opening a
DropdownMenu, matching the integrations category filter — supporting single
select, multi-select (checkbox rows), grouped options, and optional in-menu
search. Migrate every settings/EE filter dropdown off ChipCombobox to it:
audit-logs (resource-type multi + time-range), data-retention, data-drains,
general, admin (grouped tool picker), inbox status filter, and the workflow
MCP-server pickers. The SSO provider-id field stays an editable combobox since
it accepts free-text slugs.

Also fix audit findings: chip the MCP client-secret input, normalize an MCP
error-text size, and drop now-dead destination-icon code in data-drains.

* fix(mentions): require explicit @ for integration mentions; decorate sent messages robustly

- Bare integration names in prose (Monday, Notion, Clay) are no longer
  auto-converted to mentions or chipped — mention treatment is strictly
  opt-in via a token-starting @ (fixes the scunthorpe problem)
- @-prefixed mentions still canonicalize casing (@slack -> @Slack) on both
  the keystroke fast-path and bulk paths (paste, template, draft, STT)
- Sent/queued messages now self-sufficiently decorate @IntegrationName
  tokens via a text scan, covering messages sent before the input pass
  ran or authored outside the chat input
- Integration contexts missing a resolvable blockType (messages persisted
  before blockType was saved) are backfilled by label lookup so their
  mention pills render the brand icon again

* refactor(settings): section the API keys page like secrets

Wrap Workspace, Personal, and the allow-personal-keys toggle in SettingsSection
(muted label + divider) instead of bare bold headers, matching the secrets and
BYOK pages.

* fix(settings): ChipSelect renders above modals + full-width form mode

Raise the ChipSelect menu to --z-popover so it layers above modal surfaces
(--z-modal) instead of opening behind them. Add a fullWidth prop that stretches
the trigger and right-aligns the chevron for form-field use, and apply it to the
workflow MCP-server pickers.

* fix(emcn): ChipSelect uses the emcn flat chevron, not lucide's square one

The lucide ChevronDown is square; rendering it at the chip's 9x7 footprint
stretched it. Switch to the custom emcn ChevronDown (built for that wide aspect),
matching the integrations filter and ChipDropdown.

* fix(emcn): ChipSelect trigger hugs its content (w-fit)

In a stacked form layout the trigger was stretched by align-items: stretch,
leaving an empty gap to the right of the value. Add w-fit so the chip sizes to
its content (a compact pill) everywhere; fullWidth form selects are unaffected.

* fix(emcn): ChipSelect uses a square lucide chevron

Revert to lucide's ChevronDown sized square (size-[14px]) so it renders crisp,
matching the standard select chevron used by Combobox.

* renamed tasks to chats

* rename and file change

* Wspace resource refs

* improvement(billing): wire up billing, org, teammates tabs + remove deprecated subscription tab (#4887)

* improvement(billing): wire up billing, org, teammates tabs + remove depr subscription tab

* pass exec timeout to tool routes

* reuse helper

* address comments

* address disable comment

* Vfs updates

* chore(db): remove migration 0224 to regenerate on top of staging

Co-authored-by: Cursor <cursoragent@cursor.com>

* VFS updates and linter

* fix type errors and regen migration?

* File block v5

* Remove docs

* Fix

* Doccer image

* Nuke the dag

* chore(db): drop branch migration 0226 ahead of staging merge; will regenerate

* chore(db): regenerate migration 0226 after staging merge

* Fix lint

* externalize before compaction in fallback'

* Run workflow improvements

* Loosen run options validation

* Lint

* fix save/discard chips to be consistent

* fix(ui): remove smodal tabs in favor of chip modal tabs

* update byok manager component

* fix(ui): skip auto-scrolling on mouse highlight of workspace

* Add creds to wspace context

* Wscontext

* fix(platform): restore settings redirects, forgot-password Enter submit, and tag tooltip visibility

- Re-add SETTINGS_REDIRECTS so /settings/integrations and /settings/skills
  deep links redirect to their top-level routes instead of rendering an
  empty settings panel (accidentally removed in 86da193cc3 one minute
  after cca5054cf6 added it)
- Add opt-in onSubmit to ChipModalField input/email variants and wire it
  in the forgot-password modal so Enter submits again (lost in the
  ChipModal conversion)
- Knowledge tag tooltip: drop the max-h/overflow-y-auto clamp that the
  pointer-events-none floating tooltip made unreachable; truncate each
  tag row instead so all tags stay visible with bounded height

* feat(usage-limits): org member specific limits (#4893)

* feat(usage-limits): org member specific limits

* revert feature flags

* fix type issues

* address comments

* address comments and address other billing gapes'

* fix

* improvements

* fix remaining credits display

* fix

* pass ws id to route validations

* chore(db): remove migration 0226_third_spot before staging merge

* chore(db): regenerate migration as 0227 after staging merge

* Block

* feat(telemetry): add posthog + audit coverage for new platform actions

Audit log (compliance/permission-relevant only):
- org_seat.provisioned — seat auto-purchased when an invite acceptance
  grows the org (actor = accepting user, includes seat delta)
- org_plan.converted — Pro→Team conversion triggered by invite acceptance
- org_seat.drift_reconciled — hourly cron healed a drifted seat count
- credential_member.added/removed/role_changed — credential sharing
  surface was previously fully unaudited
- table.created — parity with existing table.updated/deleted
- skill updates now record skill.updated instead of mislabeled
  skill.created

PostHog:
- seats_provisioned, credential_shared/unshared,
  environment_updated/deleted (key counts only, never names/values)
- table_import_started/completed — background CSV imports previously had
  zero failure observability
- table_exported, file_downloaded, skill_updated
- credential_connected now fires for OAuth completions (draft-hooks),
  credential_deleted for OAuth disconnects; previously only manual
  credentials were tracked
- table_workflow_run gains deployment_mode (live/deployed/mixed)

Also: logger.warn on all new credential-admin 403 denials (members +
environment routes); invite-created audit enriched with
enforcedFixedSeats/plan.

Deliberately excluded as noise: per-hour dead-letter audit rows (would
re-record the same stuck event every cron run) and a duplicate
system-actor org_plan.converted in the Stripe outbox handler.

* feat(home): fill textarea on suggested prompt click instead of sending

Clicking a prompt action in the Suggested Actions panel now populates
the Mothership user-input textarea (via applyAutoMentions) and focuses
it with the caret at end, rather than immediately submitting. The user
can review, edit, and send manually.

* fix(templates): name owning integration in featured block template prompts

Four featured prompts omitted their owning integration name, making them
unbranded and disconnected from their title. Each prompt now explicitly
names GitHub or Google Sheets so mentionifyIntegrations renders the chip
and the copy reads as a self-contained agent-building instruction.

* fix(templates): rewrite fragment prompts so each names its integration and reads as a complete instruction

Featured and non-featured block template prompts that either omitted
the owning integration's canonical name or were phrased as marketing
fragments rather than natural user instructions have been rewritten.
Each updated prompt now starts with an imperative verb ("Build a
workflow that…"), names the owning integration explicitly so the
@-mention chip renders correctly, and aligns with the entry's title.

Files changed: salesforce, hubspot (×2), github, slack, airtable,
firecrawl, iam.

* fix(templates): name integration in remaining block template prompts

- google_docs.ts: replace 'Google Doc' with 'Google Docs document' in 4 prompts; update title 'Meeting notes to Google Doc' to 'Meeting notes to Google Docs'
- google_sheets.ts: replace 'Google Sheet' with 'Google Sheets spreadsheet/Google Sheets' in 3 non-featured prompts
- slack.ts: replace 'Google Doc' with 'Google Docs document' in 'Daily standup summary'
- stripe.ts: replace 'Google Sheet'/'Slacks' with 'Google Sheets'/'Slack' in 'Weekly metrics report'
- reddit.ts: add 'Reddit' to 3 prompts that only referenced subreddits
- notion.ts: rewrite featured prompt to start with a verb and name Notion
- jira.ts: rewrite featured marketing-fragment prompt to start with a verb
- linear.ts: rewrite featured marketing-fragment prompt to start with a verb
- gmail.ts: rewrite featured marketing-fragment prompt to start with a verb and name Gmail

* fix(icons): convert monochrome dark brand icons to currentColor for dark mode

LinkupIcon, InfisicalIcon, IntercomIcon, LumaIcon, GranolaIcon, OnePasswordIcon,
and RailwayIcon were hardcoded to black/near-black fills/strokes, making them
invisible in bare DARK mode. Convert all to currentColor so they follow the
theme-aware text color.

Add iconColor: '#286efa' to IntercomBlock (Intercom brand blue is a confident
mid-tone, safe on both themes).

Two-tone icons (StagehandIcon, AgentPhoneIcon, QuiverIcon) are left unchanged
because their white details are structural — converting to single-tone would
destroy logo legibility.

* removed color, user input, sidebar, suggested actions, chips/emcn

* removed color migration

* Table bump

* improve(blocks): audit block catalog metadata for accuracy and fill gaps

- Fix template prompts that claimed capabilities blocks don't have
  (fabricated triggers, non-existent tools) across ~98 integrations
- Normalize integration tags to family conventions and valid union values
- Align alsoIntegrations and modules with template prompt content
- Add BlockMeta for circleback, imap, and rss trigger integrations
- Add templates to clickhouse and greptile metas
- Remove duplicate PageSpeed deploy-gate template

* chore(telemetry): drop org_seat.drift_reconciled audit

System self-heal bookkeeping doesn't belong in the user-facing audit
trail — membership and seat-purchase changes are already audited, and
the cron's logger output covers ops visibility.

* chore(db): consolidate branch migrations into single 0227

* fix(emcn): make ChipModal scroll internally when content exceeds viewport

The Modal→ChipModal migration dropped the old ModalBody scroll container:
ChipModal renders ModalContent bare (no overflow-hidden) and its wrappers
had no min-h-0 chain, so tall modals (e.g. New data drain with an S3
destination) overflowed max-h-[84vh] off-screen with no way to scroll.

Complete the flex min-h-0 chain through ChipModal's frame and give
ChipModalBody flex-1 min-h-0 overflow-y-auto — header/footer stay pinned,
body scrolls only when constrained. Short modals are unaffected (max-h
caps, it doesn't stretch), and dropdowns inside the body are Radix-portaled
so the new scroll container cannot clip them. Five modals that had locally
patched this with max-h-[Nvh] overrides keep working unchanged.

* fix(emcn): don't close modal when dismissing a dropdown via outside click

Radix dispatches pointer-down-outside to every open dismissable layer at
once, so clicking outside an open dropdown/select inside a modal closed
both the dropdown and the modal in one jarring step. ModalContent now
prevents its own dismissal while a portaled popper layer is open — the
first outside click closes just the popper, the next one closes the
modal.

* docs(skills): de-duplicate and correct agent docs; canonical styling tokens; mirror sim-sandbox rule

* docs(skills): broaden boundary-raw-fetch scope note, sync cursor rule mirrors

* fix(emcn): harden modal popper guard and exempt caret-anchored dropdowns from body scroll

Adversarial review follow-ups to the ChipModal scroll + dismissal fixes:

- Popper guard now requires data-state="open" inside the popper wrapper,
  so a dropdown that is merely animating closed no longer swallows the
  next outside click on the modal (DropdownMenu has an exit animation
  that keeps its wrapper mounted briefly)
- Port the same guard to SModalContent for consistency
- custom-tool-modal: opt its body out of the chrome scroll container
  (flex-none + overflow-visible); the caret-anchored EnvVar/Tag
  autocomplete dropdowns are absolute-positioned inside the body and
  must spill past its bounds rather than clip against a scroll boundary

* refactor(billing): drop unnecessary useCallback wrappers from event handlers

* fix(data-drains): complete chip migration of destination forms and fill missing placeholders

Finishes the in-flight FormField → ChipModalField conversion for all
destination form specs and adds the placeholders that several inputs
never had (S3 bucket/region/access keys, Azure account key, Datadog API
key, webhook signing secret/bearer token) — the cause of the New Data
Drain modal showing placeholder-less inputs inconsistently.

* fix(emcn): broaden modal popper guard to onInteractOutside

Covers the focusOutside dismissal path too: when a popper's focus scope
unwinds on close, the transient focus shift could still dismiss the
modal (and simultaneous body pointer-events lock teardown could freeze
the page). Same data-state="open" scoping as the pointer guard.

* fix

* fix(emcn): harden modal outside interactions

* Remove migration 0227 to regenerate after merge

Drop the 0227_org_member_usage_limit migration, its snapshot, and journal
entry so it can be regenerated cleanly after merging improvement/platform.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Regenerate migration as 0228 after merging improvement/platform

drizzle-kit generate re-emits the org_member_usage_limit table, document
uploaded_by, and table_run_dispatches triggered_by_user_id changes on top of
improvement/platform's 0227 snapshot. Also collapse the admin.tsx emcn import
left multi-line after merge conflict resolution.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix pre-existing type errors surfaced by post-merge type-check

These were present on dev before the merge; the post-merge type-check made
them visible.

- workspace-vfs.ts: materializeEnvironment now emits full oauth integration
  objects (id, providerId, displayName, role) so the summary matches
  WorkspaceMdData['oauthIntegrations'] (consumer reads credentialId from id)
- workflows/utils.ts: narrow resolved workflow rows to non-null workspaceId
  (the inArray filter already guarantees it) so the resolved result type holds

Co-authored-by: Cursor <cursoragent@cursor.com>

* Slack trigger

* fix tool mark complete bugs

* change thinking icon to just be blimp

* fix tests

* MCP fixes

* Fix files

* refactor use chat

* multiple fixes

* Fix deploys

* fix billing, tracing, retry lifecycle issues

* remove stale generated migration

Drop migration 0228 and its metadata so it can be regenerated after merging staging.

Co-authored-by: Cursor <cursoragent@cursor.com>

* merge staging in

* fix sockets err classification

* sockets invite flow fix

* remove stale generated migration 0229 before staging merge

Co-authored-by: Cursor <cursoragent@cursor.com>

* regen migration

* Update bun lock

* Fix schema issues

* Fix lint

* Fix build

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Theodore Li <theo@sim.ai>
Co-authored-by: Waleed <walif6@gmail.com>
Co-authored-by: Emir Karabeg <emirkarabeg@berkeley.edu>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: andres <k62hc5kjst@privaterelay.appleid.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: andresdjasso <andresdjasso@users.noreply.github.com>
Co-authored-by: waleed <waleed@simstudio.ai>
2026-06-09 13:39:51 -07:00
Vikhyath Mondreti 1933fc4f11 fix(env): schema treatment of empty string (#4862) 2026-06-03 10:21:47 -07:00
Waleed 8d7bbbc670 chore(utils): migrate to shared random/ID utilities and add enforcement linting (#4623)
* chore(utils): migrate to shared random/ID utilities and add enforcement linting

- Replace all Math.random(), crypto.randomUUID(), crypto.randomBytes(), nanoid, and uuid usages with shared @sim/utils/random and @sim/utils/id helpers across 72 files
- Add new @sim/utils exports: deepClone, omit, filterUndefined (object), truncate (string), backoffWithJitter, parseRetryAfter (retry), getErrorMessage (errors)
- Sweep all getErrorMessage, sleep, deepClone callsites across 500+ files to use shared utilities
- Add Biome noRestrictedImports rule to catch nanoid, uuid, and crypto named imports at lint time
- Add scripts/check-utils-enforcement.ts to catch Math.random and crypto.* global property access
- Add check:utils script to package.json

* chore(utils): replace deepClone wrapper with structuredClone built-in

deepClone() was a one-line wrapper around structuredClone(), which is
universally available in Node 17+ and all modern browsers. Removing the
abstraction reduces indirection and means contributors don't need to
learn a project-specific name for a well-known built-in.

- Remove deepClone from packages/utils/src/object.ts and index.ts
- Replace all 17 call sites with structuredClone() directly
- Update check:utils script suggestion text
- Update CLAUDE.md and global.md docs

* fix(utils): add missing biome noRestrictedImports rule and correct truncate docs

- Add noRestrictedImports to biome.json under style — bans nanoid and uuid
  package imports at lint time (crypto.randomUUID/randomBytes are caught by
  the check:utils grep script which handles global property access)
- Correct truncate() TSDoc and parameter name: sliceLength makes it clear
  that total output length is sliceLength + suffix.length, matching the
  behavior all callers were already written to expect

* fix(utils): add missing getErrorMessage imports at 4 call sites

The sweep agents added getErrorMessage calls without the corresponding
import in 4 files, causing test failures. Added the missing imports.

* fix(utils): fix build errors from getErrorMessage sweep and retry.ts Turbopack issue

- Fix retry.ts cross-file import: Turbopack cannot resolve './random.js' for
  internal package imports; inline the jitter crypto call directly
- Add missing getErrorMessage imports to 32 files where the sweep added calls
  without the corresponding import (caught by type-check and test runs)
- Remove accidental getErrorMessage import from crowdstrike/query/route.ts
  which has its own domain-specific getErrorMessage for parsing CrowdStrike's
  JSON error format
- Fix use-sub-block-value.ts type error from structuredClone narrowing:
  add 'as T' cast at emitValue callsite (safe — valueCopy is always a
  structural copy of newValue)

* fix(tools): use toError in crowdstrike catch block instead of local getErrorMessage

The catch block was calling the local getErrorMessage function which
parses CrowdStrike API JSON responses, not JavaScript Error objects.
Use toError(error).message to correctly extract the message from a
caught value in this context.
2026-05-15 17:31:27 -07:00
Waleed b5dba82ac9 improvement(db): reduce connection saturation and egress hotspots (#4594)
* improvement(db): reduce connection saturation and egress hotspots

* fix(vfs): preserve native content type in copilot SQL projection

* fix(vfs): guard jsonb_array_elements against non-array contentBlocks
2026-05-13 23:39:59 -07:00
Vikhyath Mondreti 43f53bb7d3 feat(execution): payload size bottlenecks with lazy execution value hydration, safer materialization, and batched parallel execution (#4560)
* improvement(resolver): lazy resolution for underlying fields greater than 10MB

* progress

* feat(parallel): batching

* codegen to allow inline substitution

* address comments

* ui inconsistencies

* cleanup redundant code

* address more comments

* address comments

* replace helper

* fix tests
2026-05-12 14:57:32 -07:00
Vikhyath Mondreti 6cb779601a feat(search-replace): search & replace, cut, deploy modal ui flicker (#4507)
* feat(search): workflow search and replace

* fix alignment

* fix hidden fields bug

* fix loops/parallel badge case

* resource resolver

* add cut

* update docs

* address comments

* make source code for func blocks dispay resolved code instead

* fix match issue

* fix padding
2026-05-07 20:34:53 -07:00
Theodore LiandClaude Opus 4.7 98f8e854eb improvement(tables): extract TablesDetail wrapper, ship trigger followups (#4476)
* ui improvements

* Update status pils, make checkbox column sticky

* add Run workflow to context menu

* Refactor dispatching logic

* fix checkbox width to be smaller if csv is small

* Add drag behavior for workflows, stop workflow on multi select

* fix z index of checkbox to left, add view workflow button

* Switch to emcn buttons for Add inputs

* Split up workflow sidebar from column sidebar, refactor cells

* Lint and add auto run toggle

* fix column reordering, add action bar

* Create and use emcn square

* Reconcile post-merge: drop positionMap, use rowId-based selection

Staging refactored Tables UI to decouple from DB position (gutter from
array index, checkedRows keyed by rowId, no PositionGapRows). Bring
HEAD's action-bar / context-menu helpers in line: contextMenuRowIds,
selectedRowIds, actionBarRowIds now key off row.id and walk `rows`
directly. Drop the maxPosition / positionMap derived state. Collapse
COLUMN_SIDEBAR_WIDTH_CSS to a numeric COLUMN_SIDEBAR_WIDTH used by both
the sidebar shell and the table's reserved padding-right.

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

* feat(table): backfill remapped workflow outputs from execution logs

When a workflow column is re-pointed to a different (blockId, path),
populate its existing rows with the new output's value pulled from saved
execution logs instead of leaving them empty until the next run. Rows
where the new mapping has no logged value clear (matching the previous
behavior for those rows), but rows where the workflow already has the
new output's value surface immediately.

Refactor backfillAddedGroupOutputs into a generalized
backfillGroupOutputsFromLogs helper with an `overwrite` flag — used in
both the added-outputs path (preserves hand-edited values) and the new
remapped path (overwrites since the new mapping is the source of truth).

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

* fix(table): update column type when remapping workflow output

A remap that changes the output's leaf type (string → number, json →
boolean, etc.) was leaving the column's declared type stale. The clear-
then-backfill flow then failed schema validation on every row, so the
backfill silently aborted and the column stayed empty.

Resolve the new leaf type via flattenWorkflowOutputs +
columnTypeForLeaf for each mappingUpdate, and patch
schema.columns[i].type before the schema write. The clear-tx then
backfill ordering now works end-to-end across type changes. If the
workflow or its target output can't be resolved (workflow deleted,
block removed), fall back to leaving the column type alone — the
backfill will skip rows whose picked value doesn't match, same as
before.

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

* fix(table): stringify objects instead of "[object Object]" in cells

If a column's declared type lags its row data (e.g. a workflow column
mid-remap, where the schema cache hasn't refetched yet but the row data
already has the new mapping's value), formatValueForInput and the
cell-render text variant fell through to String(value) and rendered
"[object Object]". JSON-stringify objects in both spots so the transient
skew shows the actual data.

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

* fix(table): drop extra left border on workflow group meta header

The meta cell had border-r/b/l while regular headers have only border-r/b.
With border-separate tables, that extra 1px left border shifted the
meta cell's content one pixel right of the columns below it.

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

* fix(table): align workflow meta header without dropping its left border

Restore border-l and pull the cell back -1px with -ml-px so the visible
left border overlaps the previous cell's right border instead of adding
1px to the meta cell's box. Content lines up with the columns below.

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

* fix(table): draw meta header left border via ::before pseudo

Adding border-l to the meta cell shifted its content right by 1px
because table-fixed + border-separate honors the border inside the
colspan'd cell's width budget. -ml-px doesn't work on <th>. Render the
visible left edge via a ::before at left: -1px instead — paints over
the prior cell's right border without consuming any of the meta cell's
content area. Content lines up with the columns below.

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

* refactor(tables): add missing barrels, drop doubled-path imports

Match the convention used by logs/components: every component folder
exposes its public API via index.ts so consumers import from the folder
name, not from its internal filenames.

- New barrels: column-config-sidebar/, workflow-sidebar/,
  table-action-bar/, table/cells/, table/headers/.
- Rename table-filter/index.tsx → index.ts (barrel is not a component).
- Top-level components/index.ts re-exports every sibling folder so
  external consumers have one import path.
- Replace `from '../foo/foo'` doubled paths in table.tsx with the
  shorter barrel-anchored form.

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

* refactor(tables): introduce TablesDetail wrapper as thin passthrough

Phase 1 step 0 of the wrapper extraction (see plan
okay-lets-make-a-shimmying-trinket.md). page.tsx now renders
TablesDetail, which today is a passthrough to <Table>. Subsequent
commits lift surface state out of <Table> into this wrapper one piece
at a time.

The mothership chat path (<Table embedded>) is untouched — <Table>
stays exportable as a lower-level component for embedded contexts.

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

* refactor(tables): lift slideout panel state into TablesDetail wrapper

The three right-edge slideout panels (column config, workflow config,
execution details) move out of <Table> into the wrapper. The wrapper
owns a single useReducer that encodes the at-most-one-open invariant
as a discriminated union — opening any one panel automatically closes
the others. <Table> emits open requests via three new callback props.

Also extract <ExecutionDetailsSidebar> from inline-in-table.tsx to its
own folder so the wrapper can compose it cleanly. Update the embedded
mothership callsite (resource-content.tsx) to render <TablesDetail
embedded> instead of <Table embedded>.

Phase 1 step 1 of the wrapper extraction. <Table> shrinks from 3849 →
3787 lines; <TablesDetail> grows from 19 → 145 lines.

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

* refactor(tables): lift delete-table modal + mutation into wrapper

The delete-table confirmation modal and `useDeleteTable` mutation move
out of <Table> into TablesDetail. <Table> exposes a new
`onRequestDeleteTable` callback fired by the page-header Delete action.

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

* refactor(tables): lift CSV import dialog into wrapper

ImportCsvDialog moves out of <Table>. Grid exposes
`onRequestImportCsv` fired by the page-header menu item; wrapper owns
the open state and renders the dialog.

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

* refactor(tables): lift RowModal (edit + delete) into wrapper

Both RowModal instances move out of <Table> into the wrapper. Grid
emits `onOpenRowModal(row)` (Space key) and
`onRequestDeleteRows(snapshots)` (context menu).

Post-delete cleanup (push undo, clear selection) needs grid-internal
state, so the grid populates an `afterDeleteRowsSinkRef` callback that
the wrapper's modal `onSuccess` invokes.

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

* refactor(tables): lift delete-columns modal into wrapper

The destructive delete-columns confirmation modal moves into the
wrapper. Grid emits `onRequestDeleteColumns(names)`; the cascade itself
(per-column mutation, undo push, columnOrder + columnWidths cleanup)
stays in the grid as a sink the wrapper invokes on confirm — too
grid-internal to lift cleanly.

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

* refactor(tables): lift run/stop mutations + TableActionBar to wrapper

useRunGroup and useCancelTableRuns move out of <Table> into the
wrapper, along with the <TableActionBar> render. Grid receives
onRunGroup, onRunRows, onStopRow, onStopRows, onStopAll, and
cancelRunsPending as props — used by the per-row gutter Play/Stop, the
workflow-group meta-cell run menu, and the right-click context menu's
Run/Stop on selection items.

Action-bar selection state (actionBarRowIds, runningInActionBar,
hasWorkflowColumns) is derived from grid-internal state, so the grid
emits a `SelectionSnapshot` via `onSelectionChange` from a useEffect.
Wrapper uses the snapshot to drive the floating <TableActionBar>.

Phase 2 step 1 of the wrapper extraction.

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

* refactor(tables): lift queryOptions to wrapper

queryOptions (filter + sort) moves out of <Table> into the wrapper,
making it a single source of truth that drives one useTable call. The
wrapper passes the bundle down to the grid; sort/filter handlers in
the grid call onQueryOptionsChange.

Eliminates the previous double-useTable pattern (one for the grid's
filtered/sorted view, one in the wrapper's hardcoded null/null query
for sidebar metadata).

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

* refactor(tables): lift page header (breadcrumbs/options/filter) to wrapper

Phase 3 of the wrapper extraction. The full page-header surface moves
out of <Table>:

- ResourceHeader (breadcrumbs, table-rename UI, headerActions, createTrigger)
- ResourceOptionsBar (sort + filter toggle)
- TableFilter (filter panel — wrapper owns filterOpen state)
- RunStatusControl (in the leading actions when runs are active)

useRenameTable + useInlineRename for the breadcrumb name move to the
wrapper. The grid populates pushTableRenameUndoSinkRef so the rename is
still part of the grid's undo stack.

Extract NewColumnDropdown and RunStatusControl from inline-in-table.tsx
to their own folders so the wrapper composes them cleanly without
reaching into the grid's internals.

Hoist generateColumnName from grid-internal useCallback to a shared util
so both the page-header and inline-header NewColumnDropdowns use the
same logic.

After this lift <Table> is the data grid only — no page surface, no
modals, no slideouts, no breadcrumbs. The selection snapshot now
includes totalRunning so the wrapper can render the page-header
RunStatusControl from outside the grid.

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

* chore(tables): cleanup pass on TablesDetail wrapper extraction

Six-pass cleanup against the wrapper extraction diff:

- Effects: add content-compare bailout to onSelectionChange emit so
  unchanged snapshots don't churn wrapper re-renders.
- Memos: drop unnecessary activeSortState memo, fold into sortConfig.
- Callbacks: remove ~10 useCallbacks with no observed reference (sidebars
  not memoized, modals not memoized, inline arrows on non-memoized
  children); keep the ones that feed into <DataRow>/<RunStatusControl>/
  <ResourceHeader> (memoized) or grid-side useCallback deps.
- Dead props: drop onQueryOptionsChange/onRequestDeleteTable/
  onRequestImportCsv from <Table> — the page-header lift made them
  unused but the props weren't removed.
- React Query: drop redundant tableWorkflowGroupsRef (created when
  onRunRows was useCallback-wrapped; after callback cleanup it can read
  the query data directly).
- emcn: normalize Loader sizing to h-[14px] w-[14px] to match the
  codebase convention.

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

* fix(table): re-seed columnOrder when columns change server-side

The metadata-seed effect short-circuited after the first seed, so any
later schema change (e.g. adding a workflow output column) couldn't
push the new column into local columnOrder. The new column would then
fall into the "remaining" bucket of `displayColumns` and render at the
end of the table — until the user refreshed and the grid re-mounted
with the now-current metadata.

Drop the `metadataSeededRef.current` short-circuit from the early
return so the effect can also reach the after-first-load re-seed
branch, which already does the right thing (only re-seeds when the set
of columns changes, leaves pure-reorder cases alone).

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

* refactor(tables): rename wrapper to <Table>, grid to <TableGrid>

Match the naming convention used elsewhere in the workspace
(workflow.tsx → <Workflow>, base.tsx → <Base>, logs.tsx → <Logs>).

- tables-detail.tsx → table.tsx (exports <Table>)
- components/table/ → components/table-grid/ (exports <TableGrid>)
- components/table-grid/table.tsx → table-grid.tsx
- Drop <ExecutionDetailsSidebar> — was a 3-line passthrough
  (executionId → useLogByExecutionId → <LogDetails>); inline directly
  into table.tsx where it's used.
- Flatten components/run-status-control/ folder to a single
  components/run-status-control.tsx file. 25-line single-use component
  with no internal subdirs — folder was overhead. Matches knowledge's
  max-badge.tsx precedent.

Net: 1 wrapper rename + grid rename + 2 folder collapses, all imports
updated. The mothership chat callsite updates from <TablesDetail
embedded> to <Table embedded>.

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

* fix(table): don't show "Waiting" for autoRun=false workflow groups

A workflow group with autoRun=false never fires from the scheduler —
the cell stays empty until the user clicks Run manually. Treating
empty cells as "Waiting" misleads the user into thinking the group
will auto-fire once deps are filled, which it won't.

Skip autoRun=false groups when computing the per-row waiting labels
so their cells render the empty-dash instead of the Waiting pill.

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

* chore(copilot): regenerate tool catalog from copilot dev (#247)

Pulls in the workflow_group operations on user_table:
add_workflow_group / update_workflow_group / delete_workflow_group /
add_workflow_group_output / delete_workflow_group_output /
run_workflow_group, plus the autoRun / blockId / dependencies / groupId
parameters and a tightened mapping description for import_file.

Also picks up biome import-order fixes from `bun run lint`.

* improvement(table): action bar in mothership + per-execution mode

Three related improvements to the table action bar:

1. Reposition from `position: fixed` to `position: absolute` inside
   the table's container. Fixed-positioning anchored to the viewport,
   which centered the bar across the whole window instead of the table
   panel — wrong in mothership embedded view, where the table sits in
   the right half. Absolute scopes the bar to the table's bounds.

2. Show the bar for single-execution highlights — when the user
   selects one workflow-output cell, or 1 row × N cols all within the
   same workflow group. The bar enters per-execution mode with Run /
   Stop / View execution buttons targeting that one cell or group.

3. Skip View execution for cancelled cells. A cancelled cell may have
   been cancelled before the worker ever picked the job up, so its
   executionId can't be relied on. Tighten the gate everywhere
   (context menu + action bar) to only `completed` / `error` / `running`.

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

* fix(table): backfill on add_workflow_group_output, don't re-run

addWorkflowGroupOutput (the one-shot single-output add path used by
the copilot user_table tool) was calling triggerWorkflowGroupRun({
mode: 'all' }) after appending the output — that re-fired the workflow
on every row. Trace a307ed8fd5fe2d931aa84dedab5a60f0 shows ~75
workflow-group-cell jobs enqueued in the seconds after a single
add_workflow_group_output call.

Replace with backfillGroupOutputsFromLogs (overwrite: false), the same
flow updateWorkflowGroup uses when receiving newOutputColumns. Reads
each row's saved trace spans and writes the new output's value back —
no compute beyond a JSONB write per row, no double-billing the user
for runs they didn't ask for.

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

* fix(table): drop sql.raw quote-escaping in column-name interpolation

Six call sites in lib/table/service.ts built JSON-key string literals
at runtime via `sql.raw(\`'\${name.replace(/'/g, "''")}'\`)` for use
with PostgreSQL's `data->'key'` / `data->>'key'` operators. Practically
safe (NAME_PATTERN gates column names to alphanumeric+underscore at
insert time) but a smelly pattern that breaks the moment validation
loosens.

Both `data->` and `data->>` accept a parameterized text value as the
key, so the `sql.raw` is unnecessary. Replace each with a normal
`${name}::text` binding. No behavior change; eliminates the manual
quote-escaping surface.

Affected sites: renameColumn (the data-rewrite UPDATE), upsertRow's
match filter, updateColumnType's IS-NOT-NULL gate, updateColumnConstraints'
required-check + unique-duplicate-check.

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

* feat(copilot-tool): forward autoRun + mappingUpdates on update_workflow_group

The sim-side service and contracts already accept both fields, but the
copilot tool's update_workflow_group handler was dropping them on the
floor. Now `args.autoRun` (toggle the persisted auto-fire flag) and
`args.mappingUpdates` (per-output (blockId, path) swap) get forwarded
through to updateWorkflowGroup.

Pairs with the upcoming copilot-side change that exposes these in the
tool catalog JSON / Go handler / prompting (see copilot branch
redo-workflow-tools).

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

* fix(table): keep gutter border visible when hovering Run-row button

The per-row Run button sat flush against the row-gutter cell's right
border. Its hover background (rounded-rect surface-2) painted over the
border line for the 20px height of the button, making the gutter
divider appear to disappear at the hovered row.

Add mr-px to the button so the hover bg stops 1px short of the cell's
right edge, leaving the divider intact.

* fix(table): unify auto-fire and manual run paths in scheduler

scheduleWorkflowGroupRuns now owns eligibility, autoRun semantics,
dep evaluation, and enqueue for both paths. Auto-fire callers omit
opts; manual callers (triggerWorkflowGroupRun) pass { groupId,
isManualRun: true } to bypass the autoRun=false skip and (for
autoRun=false groups) the dep check.

Per-row /run-workflow-group route delegates to triggerWorkflowGroupRun
with rowIds=[rowId]. Single server-side path for both manual entry
points.

Also: optimisticallyScheduleNewlyEligibleGroups skips autoRun=false
groups so editing a row's data doesn't phantom-mark autoRun=false
output cells as Queued.

* fix(table): render empty cells as blank, not em-dash

Empty cells (any column type) showed an em-dash placeholder. Drop it
so empty cells render blank — matches what the user expects when
nothing's there.

* fix(table): per-row Run fires autoRun=false groups regardless of deps

handleRunRow filtered out every group whose deps weren't satisfied,
which silently dropped autoRun=false groups (since their deps usually
aren't satisfied — that's the whole point of autoRun=false). Click
Run row, the autoRun=false group's cells stayed empty.

Mirror the scheduler's semantics: autoRun=false bypasses the dep check,
autoRun=true still requires deps.

* fix ui shape

* improvement(table): collapse run ops into run_column, derive action-bar buttons from selection

The action bar now reflects what's actually selected:
- Selection-driven scope (cells the user highlighted, not their full rows)
- Play visible when there's anything empty/failed; Refresh when there's anything completed; both for mixed
- run_cell / run_row deleted; everything funnels through run_column
- Per-row gutter Play, right-click "Run workflows on N rows", and column-header menu all share the canonical run path
- Shared RunMode type from the contract; cleanup pass via /simplify (readExecution / isExecInFlight reuse, runScope helper, flat onViewExecution prop)

* chore(copilot): regen tool catalog after dropping run_cell / run_row + dependencies.workflowGroups

Mirror the copilot-side catalog change so the generated TS catalog matches the deployed copilot tool surface.

* fix(table): atomic per-key writes for executions, plus run-op race fixes

The executions blob on user_table_rows was read-modify-written wholesale on every
update. Concurrent writers (a column edit and a manual-retry stamp, two pickup
calls, a cancel and a cascade) each computed a merge from their own snapshot,
and the last writer clobbered keys it never touched — producing stuck "queued"
cells, vanished stamps, and stale completed exec records reappearing after
retries.

Fixes:
- updateRow / batchUpdateRows now apply executionsPatch via a SQL jsonb merge
  expression. Each writer only mutates the keys it explicitly patches; other
  keys are preserved. Eliminates the cross-key clobber.
- writeWorkflowGroupState bypasses the stale-worker guard for `queued` (new
  scheduler stamp) and `cancelled` (authoritative cancel) writes — those ARE
  the new authority for the cell. Previously the new run's stamp was being
  rejected by the same guard meant to block the OLD worker's writes.
- skipScheduler flag on UpdateRowData / BatchUpdateByIdData lets the cancel
  path and runWorkflowGroupsInternal opt out of the implicit auto-fire pass
  (cancel was waking up siblings; manual-run was racing its own scheduler).
- CELL_CONTENT pinned to h-[22px] so status badges don't grow rows.

* chore(table): remove table-row sockets, both sides

Tables don't use realtime sockets in prod — strip the dead path so we stop
paying the per-row HTTP forward + socket emit on every cell write. Polling on
running execs already covers reconciliation.

Sim side:
- service.ts: drop notifyTableRowUpdated/Deleted, notifyTableDeleted, the
  postRealtimeBridge helper, and all callsites.
- hooks/queries/tables.ts: drop the socket subscription block in useTableRows;
  poll-on-running stays. Remove useEffect / useSocket imports.
- app/.../tables/[tableId]/hooks/use-table.ts: drop the merge-on-event
  useEffect and unused imports.
- app/workspace/providers/socket-provider.tsx: drop joinTable/leaveTable,
  onTableRowUpdated/Deleted/onTableDeleted, currentTableId state, related
  events + types.

Realtime side:
- handlers/tables.ts deleted; index.ts no longer wires it.
- routes/http.ts: drop /api/table-row-updated, /api/table-row-deleted,
  /api/table-deleted endpoints.
- rooms/{memory,redis}-manager.ts: drop emitToTable, handleTableRowUpdated/
  Deleted, handleTableDeleted, related imports.
- rooms/types.ts: drop method declarations, TableRowUpdatedPayload type,
  tableRoomName helper.
- middleware/permissions.ts: drop unused verifyTableAccess.

Bonus from parallel work:
- cell-content typewriter trigger refinement.

* fix(table): clearing a workflow output cell also clears its exec record

When the user wipes a workflow output column value, the auto-fire reactor
needs to be re-armed for that group. Previously, a stale cancelled / error
exec record blocked the eligibility predicate (gate at line 79 hard-rejects
those statuses on auto-fire) and the cell stayed stuck in its old terminal
state — visible as "Cancelled" cells that wouldn't re-run no matter what.

Both updateRow and batchUpdateRows now derive an `executionsPatch[gid] = null`
for any output column the patch sets to empty. The data clear and the exec
clear ride the same SQL transaction, so the row never lands in a stale-
status-with-empty-data state.

Symmetric to how `completed` already worked via `areOutputsFilled` in the
predicate — clearing the cell wins over the prior exec status, regardless of
what that status was.

(Also revert typewriter-trigger experiment from a parallel session that was
in-progress on this branch.)

* fix(table): waiting state, optimistic UX, schema-mutation polling, exec cleanup

A bundle of small UX + correctness fixes around workflow-cell run state.

cell-render.tsx
- In-flight (queued/running/pending) now wins over the existing value, so
  re-runs surface immediately instead of looking like nothing happened until
  the worker writes the new value.
- "Waiting on X" wins over a stale `cancelled` / `error` exec when deps are
  unmet — clearing a dep now reads as actionable instead of stuck.

useRunColumn (hooks/queries/tables.ts)
- onSettled now cancels in-flight polls before invalidating. Stops a poll
  that landed mid-mutation from clobbering the optimistic state with stale
  data, which produced the queued → cancelled → queued flicker.

addWorkflowGroup / updateWorkflowGroup (autoRun toggle on)
- Awaits scheduleRunsForTable instead of fire-and-forget. The route returned
  before the queued exec stamps committed, so the post-mutation refetch saw
  no in-flight cells and polling never started — cells looked stuck even
  though the server eventually stamped them.

deleteColumn / deleteColumns
- Strip orphaned executions[gid] keys when deleting a column orphans its
  parent group. Without this, stale running/queued exec records lingered on
  every row forever and inflated the page-header "N running" counter even
  on tables with no actually-running cells.

UI
- Action-bar leading label: "Selected N workflow cell(s)".
- Context menu: Run / Refresh items mirror the action bar's Play / Refresh
  split, gated on the same selection-status flags so both surfaces show the
  actions that match the current state.

* refactor(table): consolidate exec-status helpers + fix N-running counter

Cleanup pass on the recent table changes — pulls duplicated predicates and
SQL snippets into shared helpers and fixes one drift bug along the way.

- isExecInFlight: now single export from lib/table/deps.ts. Removed the
  duplicate in components/table-grid/utils.ts. Used by isGroupEligible
  (server eligibility) and runningByRowId (client counter).
- isOptimisticInFlight: kept local to hooks/queries/tables.ts — renamed from
  isInFlight to disambiguate from the stricter isExecInFlight. The two
  predicates differ on `pending` without a jobId: optimistic patches and
  poll-trigger want the broader version, eligibility wants the strict one.
- areOutputsFilled: single export from lib/table/deps.ts, dropped duplicate
  from workflow-columns.ts.
- classifyExecStatusMix: shared row × group walker in table-grid/utils.ts.
  Replaces two copies of the same loop in table-grid.tsx (selectionStats +
  contextMenuStats). Both surfaces now have the same short-circuit
  semantics, including the seen-all-selected-rows early break that
  contextMenuStats was missing.
- stripGroupExecutions: SQL helper in service.ts. Replaces three copies of
  the `UPDATE user_table_rows SET executions = executions - $gid::text`
  pattern across deleteColumn / deleteColumns / deleteWorkflowGroup.

Drift bug:
- runningByRowId / totalRunning counted only `running` and `queued`. Every
  other in-flight check in the codebase treats post-stamp `pending` as
  in-flight too, so the page-header "N running" badge briefly dropped to 0
  between scheduler stamp and worker pickup. Now uses isExecInFlight.

* fix(table): address pr review (drop dead workflowNameById prop, reset didDragRef on dragend, align sidebar width)

* fix(table): scope post-clear schedule to targeted groups, forward mode

Multi-group manual runs (Run row, gutter Play, action-bar Play across mixed
completed + cancelled cells) re-fired completed-and-filled siblings.
runWorkflowGroupsInternal cleared only the groups it filtered, then called
scheduleRunsForRows with isManualRun: true and no group / mode filter — so
the post-clear pass walked every group on the table with default mode 'all',
and any autoRun=true completed sibling whose deps were satisfied got queued
again. Scope the post-clear call to targetGroups and forward mode.

* fix(table): meta-cell drag-leave flicker guard + plumb unique on create

* fix(table): strip sibling deps when removing workflow output via updateWorkflowGroup

deleteWorkflowGroup already stripped removed-column deps from sibling
groups, but updateWorkflowGroup (the path the UI takes when deleting one
output of a multi-output group) didn't — schema validation then rejected
the update with 'Group X depends on missing column Y'.

* improvement(table): debug logs at every cascade decision branch

* improvement(table): parallelize queued-stamp writes within concurrency-cap chunks

* Simplify stripping column names

* fix lint, ci

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:43:05 -04:00
Theodore Li 31cfb74dc2 feat(table): add workflow execution column type (#4338)
* Add table triggers for columns and row added

* Add async batching job for running column

* Add ui improvements, stop mechanism

* Use trigger dev for workflow runs

* Use unified column sidebar for table

* Add socket waits for tables, multi column workflow support

* change back to cell based trigger jobs

* Reuse code, add view log inline in table view

* reorganize db to treat each column separately

* adjust column naming strategy

* Column ui improvements

* fix live update on table

* Change new column behavior

* fixed errored workfows not showing as stopped

* Column sidebar improvements

* fix table column swapping behavior

* fix bugs

* add prompting, fix lint

* fix ui stuff

* flip feature flag

* Add zod contracts fix initial auto-run of columns

* ui improvements

* Use db filter to query unran rows

* Use live workflow run

* Change wording for deleting workflow column

* Update tools

* Add tool to run selected rows

* Add mothership tools

* adjust col width

* Restore ff

* fix drizzle migration

* fix test
2026-05-02 15:48:12 -04:00
Vikhyath Mondreti af859cd508 feat(workflows): lock/duplicate improvements for workflows (#4387)
* feat(workflows): lock/duplicate improvements

* fix duplicate var remap bug

* address comments

* remove dead vars

* fix tests

* address comments

* code cleanup

* address comments

* address comments

* minor change

* remove dead code
2026-05-01 20:42:10 -07:00
Vikhyath Mondreti b8959eb20d improvement(repo): zod based client-server boundary (#4355)
* improvement(repo): centralized zod contracts (#4336)

* improvement(repo): zod schema contracts

* type checks

* fix(notion): correctly register tool (#4337)

* fix func blokc

* more improvements

* fix tests

* type check

* remove v3 refs

* minor type improvements

* address comments

* update jira contract

* remove validateJsonBody

* improvement(repo): consolidation of boundary helpers + better unknown usage (#4352)

* improvement(repo): consolidation of boundary helpers + better unknown usage

* address comments

* improve file transfer error messaging

* fix docs listing schema drift

* fix inocrrect type casting

* address council comments

* remove prefix
2026-04-30 12:16:28 -07:00
Waleed ccb5f1e690 fix(db): revert statement_timeout startup options breaking pooled connections (#4284) 2026-04-23 23:31:52 -07:00
Theodore Li c22ac38ab0 Set statement timeout of 90 seconds (#4276) 2026-04-23 18:27:15 -04:00
5f0f0edd63 improvement(repo): separate realtime into separate app (#4262)
* improvement(repo): restructuring to make realtime image narrower scoped

* improvements

* chore(repo): rebase fixes and quality improvements for realtime split

Addresses merge-time issues and gaps from the realtime app split:
- Retarget stale vi.mock paths to @sim/workflow-persistence/subblocks
- Restore README branding, fix AGENTS.md script reference
- Restore TSDoc on workflow-persistence subblocks helpers
- Use toError() from @sim/utils/errors in save.ts
- Add vitest config + local mocks so @sim/audit tests run standalone
- Move socket.io-client to devDependencies in apps/realtime
- Add missing package COPY steps to docker/app.Dockerfile
- Add check:boundaries/check:realtime-prune scripts and wire into CI

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

* refactor(security): consolidate crypto primitives into @sim/security

Move general-purpose crypto primitives out of apps/sim into the
@sim/security package so both apps/sim and apps/realtime can share them.

@sim/security exports (all pure, dependency-free):
  ./compare    safeCompare (constant-time HMAC-wrapped equality)
  ./encryption encrypt/decrypt (AES-256-GCM, iv:cipher:tag format)
  ./hash       sha256Hex
  ./tokens     generateSecureToken (base64url)

Migrate apps/sim call sites to use these + @sim/utils helpers:
  crypto.randomUUID()            -> generateId() from @sim/utils/id
  createHash('sha256').digest    -> sha256Hex
  timingSafeEqual on hashed hex  -> safeCompare
  new Promise(setTimeout)        -> sleep from @sim/utils/helpers

No behavior change: encryption format, digest output, and token
length are preserved exactly.

* refactor(copilot): use toError in remaining otel/finalize sites

Replace the last two `error instanceof Error ? error : new Error(String(error))`
patterns with toError from @sim/utils/errors. Completes the sweep of clean
candidates — no behavior change.

* refactor(security): consolidate HMAC-SHA256 primitives into @sim/security

Adds hmacSha256Hex and hmacSha256Base64 to @sim/security/hmac and migrates
15 webhook providers plus 5 other hot paths (deployment token signing,
outbound webhook requests, workspace notification delivery, notification
test route, Shopify OAuth callback) off bare `createHmac` calls. Secret
parameter accepts `string | Buffer` to cover base64-decoded Svix-style
secrets (Resend) and MS Teams' HMAC scheme. AWS SigV4 signing in S3 and
Textract tools intentionally retains direct `createHmac` usage — its
multi-step key derivation chain doesn't fit a generic helper.

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

* chore(packages): post-audit test + packaging polish

- Add safeCompare unit tests (identity, length mismatch, hex-nibble diff).
- Add Buffer-secret cases to hmac tests to lock in Svix/MS-Teams contract.
- Declare `reactflow` as a peerDependency on @sim/workflow-types — only used for type imports.
- Add a barrel export to @sim/workflow-persistence for consumers that prefer package-level imports; subpath exports retained.
- Document the data-field invariant in load.ts for loop/parallel subflow patching.

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

* chore(realtime): address PR review feedback

- Remove redundant SOCKET_PORT=3002 env from Dockerfile runner stage
  (env.PORT already defaults to 3002 via zod schema).
- Reorder PORT fallback so an explicitly-set SOCKET_PORT wins over
  the schema default for PORT; keeps SOCKET_PORT functional as an
  override instead of dead code.
- Add dedicated type-check CI step for @sim/realtime so TS errors
  surface pre-deploy (the Dockerfile runs source TS via Bun and has
  no implicit build-time type check).

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

* chore(realtime): remove unused SOCKET_PORT env var

SOCKET_PORT has lived in the socket server since the June 2025 refactor
but was never actually set in any deploy config — docker-compose.prod,
helm values/templates, .env.example, and docs all use PORT or the 3002
default exclusively. No self-hoster was ever pointed at SOCKET_PORT, so
removing it is safe.

Simplifies realtime port resolution to `env.PORT` (zod-validated with a
3002 default) and drops the orphaned sim-side schema entry.

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

---------

Co-authored-by: Waleed Latif <walif6@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 23:06:16 -07:00