mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-21 21:15:56 +08:00
d5701d5b2bc2d60ce94de9ab0f34b9e398dcab15
6200
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d5701d5b2b |
fix(icons): align table block icon and 12-unit icon stroke with the emcn family (#6708)
The table/table_v2 blocks and the table trigger used a local lucide-shaped TableIcon (stroke 2.0, full-bleed 24 viewBox, 3x3 grid) while every other table surface used emcn's Table. Consolidate onto the emcn icon and drop the local copy. Nested tool-call rows in Chat applied no color class, so a non-brand block icon inherited body text instead of --text-icon. redo/undo/zoom-in/zoom-out draw 0.85 stroke on a 12-unit viewBox, rendering 0.992px at a 14px box against the family's 0.904px. 0.775 restores parity. |
||
|
|
9664e7ec17 |
Revert "fix(integrations): read every service mark from one registry (#6682)" (#6707)
This reverts commit
|
||
|
|
b7a5f22abd |
fix(integrations): read every service mark from one registry (#6682)
* fix(integrations): read every service mark from one registry A service looked like itself on the canvas and like nothing in particular everywhere it was connected. `OAUTH_PROVIDERS` registers 93 icons and no colour at all, so the surfaces built on it — the connect dialog above all — drew a flat grey mark for a block whose config already carries its brand icon and `bgColor`. Bridges the two: `resolveIntegrationBlockTypeForOAuth` maps any OAuth id (a service id, a provider id, an extra authorization server) to the catalog block behind it, so a credential surface holding only an OAuth identity can still reach the registry. The connect dialog now wears the block's tile, and `ChipModalHeader` takes a rendered mark so a tile can carry its own chrome instead of being tinted with the header's grey. Folds in the copies that had grown around the gap: `IntegrationTile` resolved its fill from the registry but took its icon from whatever the caller passed — one tile, two sources — and now defaults to the registry, with an override kept for the family service-account marks that genuinely are not the block's. The letter fallback it grew alongside was reading the catalog's `bgColor` while the tile beside it read the registry's; both are the tile now. Two `getProviderIcon` implementations for the same job (one tinted, one not) become one `ProviderIcon`, the connector tile duplicated verbatim across two knowledge-base surfaces becomes one `ConnectorTile`, and the permission rows that hardcoded `text-white` — which renders white-on-white on a pale brand tile — go through `BlockTile`. Public pages keep their generated catalog: importing the registry there would ship 282 block configs to a marketing page, and `integrations.json` is generated from the same `bgColor`, so the two cannot drift. * fix(blocks): drop the dead copies of a block's colour Sweeping the surfaces above turned up colour data nothing reads and colour data two surfaces disagreed on. Dead: `BLOCK_COLORS.DEFAULT/LOOP/PARALLEL` in the tag dropdown (only `VARIABLE` was ever referenced), `BlockIconInfo.color` on table columns — whose consumer documents that it deliberately ignores the colour, so the `#2F55FF` behind it could never render — and the `bgColor` threaded into the add-resource dropdown, whose row renders a bare tinted icon. Disagreeing: the Variables tile is `#2F8BFF` in the tag dropdown and `#8B5CF6` in the preview panel, for the same "V" on the same concept. Both now read `VARIABLE_TILE_COLOR`, and the preview panel's two hand-rolled squares become `BlockTile` like every other tile. Four spellings of the neutral fallback (`#6B7280`, `#6b7280`, `#666666`, and a `cancelled` status that happened to equal it) now point at `DEFAULT_BLOCK_TILE_COLOR`. The terminal and logs resolvers stay. They look like duplicates of `accent.ts` but carry behaviour it does not have — status fills for synthesized error/validation/cancelled rows, near-black contrast correction, MCP tool-id parsing, and a model-provider branch — so folding them in is a behavioural change, not a deletion. * chore(copilot): delete the mention machinery nothing calls The workflow panel's copilot tab renders `MothershipChat`, and that component brings its own input — so `panel/components/copilot` no longer holds a component at all, only the hook library the old input used. Five of those hooks have no caller anywhere: `useMentionData`, `useMentionKeyboard`, `useCaretViewport`, `useMentionInsertHandlers`, `useTextareaAutoResize`. They are not all of it. `home/components/user-input` still imports `useFileAttachments`, `useMentionMenu`, `useMentionTokens`, `useContextManagement`, and `useIntegrationAutoMention` from this directory, so it survives as a shared hook library rather than dead weight — which is why this removes the uncalled five rather than the folder. What they alone reached goes with them: `getFolderData` / `getFolderLoading` / `getFolderEnsureLoaded` and the `FOLDER_CONFIGS` table describing every mention folder, `buildMentionHighlightNodes`, the `MentionFolderNav` type, and the slash-command tables. Of the 266-line constants file only `SCROLL_TOLERANCE` had a live reader left. * fix(integrations): never answer with a sibling's tile Two integrations can share one OAuth id: Google Slides is authenticated by Drive's `google-drive` service and Jira Service Management by Jira's `jira`. Indexing first-write-wins made those ids resolve to whichever sorted first, so the dialog connecting Slides could wear Drive's brand. An id claimed by more than one block type now resolves to neither, and the caller keeps the service-specific mark it already had. A wrong brand is worse than no tile. |
||
|
|
5a88ce22d1 |
feat(ashby): incremental job sync, custom field writes, and application lifecycle ops (#6703)
* feat(tools): add incremental job sync and draft postings to Ashby reads
list_jobs accepts Ashby's syncToken and returns it as nextSyncCursor, so a
scheduled sync costs O(changed reqs) instead of rescanning every req. Ashby only
returns the token once the last page is drained, which the param description
states.
The output is named as a cursor deliberately. It is an opaque resumption marker,
not a credential, so it belongs with nextCursor - and a field literally named
syncToken matches the /^.*token$/i deny-list in redaction and renders as
[REDACTED], which makes an incremental sync unusable since the operator cannot
read the value the next run needs. The wire name stays syncToken.
list_job_postings gains includeUnpublishedJobPostings, plus the posting status
field - without status a caller cannot tell a returned draft from a published
posting, which makes the flag useless.
Also widens the custom field valueLabel type, which MultiValueSelect returns as
an array, for the write operations that follow.
* fix(tools): render Ashby object-shaped API errors readably
Ashby documents two error shapes and uses both. The `errors` array form carries
`{ message, parameter }` objects, which stringified to '[object Object]' and hid
the real cause - including the 403 a key gets when it lacks a module permission.
Also adds the shared pieces the new write operations need: one definition of the
custom field value shape for the read and write paths to agree on, and a
normalizer for Ashby's case-sensitive objectType enum so a model emitting
'candidate' fails here with the allowed values rather than at the API.
* feat(tools): add Ashby custom field writes, delete, source, and anonymize
customField.setValue/setValues are the only way to annotate a job or req, since
Ashby has no job notes and no job tags. Writing null clears a value, so the
annotation is reversible.
Because null clears, every one of these operations requires explicit intent
before it can destroy data. The block's required markers do not cover the agent
path - a model calls the tool directly, so tools.config.params never runs and
validateRequiredParametersAfterMerge skips a param marked not-required:
- set_custom_field_value rejects an absent or blank fieldValue; an explicit null
still clears
- change_application_source requires unsetSource to clear, and rejects a source
id and an unset request together, since preferring either one silently
discards the other. Ashby has no 'leave unchanged' mode, so setting and
clearing are the only two intents and exactly one must be expressed
- set_custom_field_values rejects an empty array locally rather than relying on
Ashby to reject it
application.delete needs candidatesDelete, a module permission separate from
candidatesWrite. candidate.anonymize strips PII but leaves the record; Ashby
exposes no candidate deletion endpoint.
* test(tools): cover the new Ashby request and response shapes
Includes a gated live harness (ASHBY_LIVE=1) alongside the mocked tests.
vitest.setup.ts stubs global fetch for every file in the app, so the live file
restores the real implementation and asserts the restore worked - without that
guard the whole suite silently passes against a mock.
* feat(blocks): expose the new Ashby operations in the block
fieldValue is polymorphic (boolean, number, string, array, object, null), so it
decodes structured input and otherwise passes text through. The decoding is
deliberately narrow rather than a blanket JSON.parse, which corrupts real text:
1e999 becomes Infinity and serializes back out as null, which CLEARS the field;
a long numeric id loses precision past 2^53; and prose starting with { turns into
an object. Only the literal keywords, {, [ or " prefixes, and exactly
round-tripping numbers decode.
fieldValue carries no wand generationType: json-object forces braces and
json-array forces brackets, and both would wrap a value that must stay bare.
fieldValues, whose contract really is an array, uses json-array.
Setting and clearing an application source are mutually exclusive, so the Source
ID field is conditioned off while the clear switch is on and the params mapping
sends only the intent the switch selects. A value typed before the switch was
flipped cannot reach the tool and surface as an error with no visible cause.
* docs(ashby): document the new operations, permissions, and limitations
Ashby scopes permissions per module and they fail at runtime, not build time, so
the block docs now carry the permission table. Also records the hard API limits
worth designing around: no note or tag on a job, no pagination on
jobPosting.list, and no delete for jobs, candidates, or custom field definitions.
* fix(blocks): stop a stale create-path source id leaking into a source change
The executor merges { ...inputs, ...transformedParams }, so any key the params
mapping leaves unset inherits whatever inputs held. The shared create-path
sourceId subblock reaches inputs even on change_application_source: it is mode
'advanced', and the serializer includes an advanced subblock whenever its value
is non-empty without ever evaluating its condition (serializer/index.ts).
So a source id typed while on Create Application survived into a source change.
With both fields blank it silently attributed a source nobody asked for, and
with the clear switch on it collided with the unset request and failed with no
visible cause, because the field producing it is hidden in that state.
sourceId is now always assigned for this operation rather than conditionally,
so it can never inherit. The regression test asserts the merged result rather
than the mapping alone, since the gap between them is where the bug lived.
|
||
|
|
3d4e3d26dd | fix(settings): preserve tab when switching workspaces (#6704) | ||
|
|
af076a7a34 |
fix(logger, blocks): log nested errors and stop spurious model-selection warnings (#6706)
* fix(logger, blocks): log nested errors and stop spurious model-selection warnings
Two production defects found in the prod logs, neither release-related.
logger: mergeArgs copied object arguments verbatim, so an Error held under a
key stayed an Error instance and JSON.stringify rendered it {} — message and
stack are non-enumerable on Error.prototype. 399 call sites use the
logger.x('msg', { error }) shape and every one logged error: {}, which is why
BlockOutputs failures (10k/week) were undiagnosable. The colorized path had
the same hole via formatObject.
blocks: router, evaluator and agent resolved config.tool through
getBaseModelProviders(), which deliberately excludes gateway providers
(OpenRouter, vLLM, LiteLLM, Ollama, ...). A valid openrouter/* model therefore
threw "Invalid model selected", as did a model still holding an unresolved
<variable.x> at serialization time — ~140 warnings/day. The value is cosmetic
(every handler re-derives the provider from the resolved model), so the throw
bought nothing.
- unwrap keyed Errors on both the structured and colorized log paths
- keep `error` a plain message string so log queries can group on it
- resolve serialized provider ids through getProviderFromModel, the same
resolver the executor uses, via one shared helper for all four call sites
- drop the unreachable `if (!model)` checks behind `params.model || default`
* fix(blocks): move the fallback rationale into TSDoc
The repo forbids non-TSDoc comments; the explanation for why recovery returns
a constant instead of resolving again belongs on the declaration anyway.
|
||
|
|
5bb59f08ee |
feat(connectors): add 9 knowledge base connectors (#6699)
* feat(connectors): add 9 knowledge base connectors
Box, Zoho Desk, PagerDuty, Trello, Microsoft Excel, Google Slides, Google
Vault, Mintlify, and SFTP. Selected by intersecting the published connector
catalogs of Glean, Onyx, Dust, Vectara, Writer, Guru, Elastic, Microsoft 365
Copilot, Notion AI, Unstructured, and Airbyte against services that already
ship a Sim block, so OAuth providers, credentials, and icons are reused. Box
was the largest gap, appearing in 7-8 of ~10 catalogs.
Every connector was validated against live provider documentation twice, the
second pass treating the first pass's conclusions as unproven. Notable
correctness work that came out of that:
Listing truncation. The sync engine hard-deletes documents past a cap that is
not flagged with `listingCapped`, and five connectors had a path there — an
empty Mintlify discovery, Zoho Desk's exact-multiple default caps, Trello's
archived lists and 1000-card ceiling, a Google Vault cursor bailout, and a
PagerDuty stalled page. The engine also gained a backstop: an empty or
collapsed listing blocks deletion reconciliation until the same observation
repeats on a consecutive sync, reconstructed from existing sync-log counters
so no migration is needed.
API alignment. `desk.zoho.ca` does not resolve (Canada is
`desk.zohocloud.ca`, and Singapore and UAE were missing); `modifiedTime` is
absent from Zoho's ticket list projection, so every ticket re-embedded on
every sync; Trello's `dateLastActivity` is documented to miss some edits;
PagerDuty's 10,000-record ceiling bounds `offset + limit`, not offset; Excel
indexed dates as raw serial numbers while Google Sheets renders them; Google
Vault truncated at roughly 249 matters.
Security. SFTP followed symlinks in `getDocument` and composed unchecked
server-supplied filenames into paths; it now also supports optional host-key
fingerprint verification, which runs during key exchange before any password
is sent. Trello interpolated user-supplied board ids into URL paths. Google
Vault is narrowed to `ediscovery.readonly`. `getDataverseBaseUrl` accepted
any host while attaching a bearer token, and is pinned to Microsoft's
Dataverse domains — pre-existing shipped code, fixed here.
Also adds `ConnectorAuthConfig.optional` so a public source can be configured
without inventing an API key, and teaches the scope check that a granted
read-write scope satisfies a required `.readonly` sibling.
Microsoft Dataverse was built and then removed: its OAuth cannot complete
consent. Dataverse requires a per-environment resource URI, the provider
declares a static `https://dynamics.microsoft.com/user_impersonation` that is
not an Entra Application ID URI, and the environment URL is only collected
after the credential exists. That predates this change and also affects the
12 shipped Dataverse tools.
* fix(dataverse): strip the bearer token when a request redirects
The host allowlist added alongside the connector work only constrains the
initial destination. `secureFetchWithPinnedIP` follows redirects and keeps the
`Authorization` header unless a tool opts out, so a redirect away from an
allowed Dataverse origin would forward the caller's OAuth token to whatever
host answers. Dataverse redirects in normal operation — file downloads hand
back a signed storage URL, and environment hosts move between regional
origins — so this is reachable without a compromised environment URL.
Sets `stripAuthOnRedirect` on all 18 Dataverse tools, matching the existing
GitHub job-logs and Windchill precedent.
* fix(connectors): address review findings on listing and hashing
- microsoft-excel: `fetchWorksheets` read only the first Graph page and never
followed `@odata.nextLink`. A workbook with more sheets than fit in one page
dropped the remainder from the listing without setting `listingCapped`, so
the sync engine reconciled those documents away as deleted. The walk now
pages, bounded by MAX_WORKSHEETS, and only follows a nextLink that stays on
the Graph origin, since the link is server-supplied and carries the token.
- google-slides: the listing `contentHash` covered only the file id and
modified time, so toggling the speaker-notes option left every stored hash
matching and no presentation was ever re-hydrated with the new scope. The
setting is now part of the hash, in the single shared stub builder so the
list and hydrate paths stay identical.
- mintlify: `pathPrefix` filtered with a bare `startsWith`, so a prefix of
`/guides` also matched a sibling like `/guides-archive`. It now shares the
`/`-boundary rule `withinBasePath` already used, extracted as `isUnderPath`.
* fix(connectors): list newest first in zoho desk, accept a trailing slash prefix
- zoho-desk: `sortBy: 'createdTime'` is ascending — Zoho denotes descending
with a `-` prefix — so the default 500-record caps kept the oldest tickets
and articles and recent ones were never listed. Because the cap sets
listingCapped, that stale tail could not reconcile away either. Now sorts
`-createdTime`. Still ordering on createdTime rather than modifiedTime, so
rows do not reshuffle mid-walk.
- mintlify: `resolvePathPrefix` kept a trailing slash while `isUnderPath`
accepts an exact match or `prefix + '/'`, so `/guides/` matched neither
`/guides` nor `/guides/intro` and the source synced nothing. A regression
from the previous round, which replaced a bare `startsWith`. The prefix is
now normalized before comparison.
* fix(dataverse): strip the bearer token on the upload route's own redirect
`upload_file` posts to an internal route rather than calling Dataverse
directly, so the tool-level `stripAuthOnRedirect` added in
|
||
|
|
cf78946529 |
fix(v2): close seven correctness and honesty gaps found sweeping the API (#6702)
* fix(v2): close seven correctness and honesty gaps found sweeping the API A ten-slice sweep of the live v2 surface turned up no regression from the recent cancellation work, but did surface a set of pre-existing defects where an endpoint either lost data, hid a failure, or reported something that was not true. Each is fixed at the layer that owns the behavior. Terminal execution logs. The two force-fail boundaries wrote `status: 'failed'` without `ended_at` or `total_duration_ms`, so a force-failed run dropped out of every duration-filtered log query — the same defect class already closed for cancellation, still open on its sibling. The cancellation payload factory is generalized to take the status; the cancellation call sites are untouched and still emit a byte-identical row. Custom tools. One malformed row failed the whole page, and because the list is keyset-paginated that row made every page containing it permanently unreachable. The projection now validates against the same contract schema the route builder applies, repairing only what can be repaired without inventing information — a stringified schema, and a missing `type` whose contract admits exactly one value — and omitting with a warning what cannot. Both rows observed in production are recovered rather than discarded. Table filters. `eq`/`ne`/`in`/`nin` compiled a wrongly-typed operand into a containment test that silently matched nothing, so a filter written against the value the write path had stored returned an empty page instead of its rows. The operand is now read through the same column-type registry the write used, and rejected only where that registry refuses it. Range operators already behaved this way; `null` and the cleared-cell sentinel still pass through untouched. Error messages. A custom `error` on a string schema also replaced the wrong-type wording, so supplying a number for a name reported that the name was missing. Messages now distinguish an omitted field from a mistyped one, `topK` names its own bounds, the knowledge search refine reports against a field rather than the whole body, and a workspace id is bounded before it reaches a lookup. Archived file metadata. A soft-deleted file was listed but unreadable, leaving no way to check share state before restoring it. The read takes the same `scope` selector the list already exposes; the default is unchanged, and the parameter relaxes only the `deleted_at` predicate, never the authorization. Cancellation reporting. Cancelling an already-terminal run reported a durable write that never happened. The service now distinguishes the no-op and names the state it observed, and both surfaces present one vocabulary instead of the internal route deriving its own. No claim predicate or write changed. Protocol. A 401 carries a challenge naming the header the API actually reads, and a body that failed to parse is reported as an unsupported media type only when the caller positively declared a non-JSON one — after the read has already failed, so nothing that succeeds today can begin to fail. * fix(v2): correct three regressions this branch introduced, and harden its tests Adversarial review of the previous commit found that three of its "behavior preserving" claims were wrong. Each is corrected here at the layer that owns it. Table filters no longer coerce a `date` operand, and no longer throw. `date` is the one column type whose registry `coerce` is not idempotent — it drops sub-second precision — and the leaf that compiles a filter also builds the unique-constraint and upsert-conflict probes, so re-reading an already-coerced operand could stop it matching the row it was written from and admit a duplicate inside the write transaction with no error. Throwing was the second mistake: the v2 predicate grammar type-checks structure but not operand values, so a rejected operand no longer failed at submission but inside the delete, update, dispatch and cancel runners, where a filter that cannot compile means the cells it started can no longer be cancelled. Coercion is now total — it rewrites what the registry accepts and passes everything else through unchanged, exactly as before. Reviving a force-failed run no longer inherits its terminal duration. Writing `ended_at` and `total_duration_ms` on the force-fail boundary was correct in isolation, but a partial resume flips that row back to `pending` and those columns survived. The preserved value is meant to be the pause checkpoint — the run's active time — and it had become wall clock measured at the failed resume, which the checkpoint rule then faithfully carried into the next terminal write. The revival clears them only for a row that was terminal, so an ordinary paused row keeps the checkpoint it is supposed to keep. Cancelling reports the terminal state it actually observed. Reclassification now requires that nothing else went wrong, so a genuine paused-reconciliation failure survives instead of being rewritten as an already-terminal no-op, and the claim's own row count — not a snapshot read before it — decides whether this cancel terminalized the run or lost a race to something else. The status the snapshot needed rides along on the ownership query that already reads the row, rather than the second read that query's own contract warns against. A custom tool that cannot be projected now answers the same way everywhere: the list omits it, and reading or patching it by id reports it as absent rather than as a server fault. Analytics stops reporting a cancellation for a request that cancelled nothing. The tests around all of this were audited by mutating each fix and checking the suite noticed. Where it did not, the assertion is stronger now: the absent content-type branch is genuinely exercised rather than relying on a header the client library supplies, the duration encoder is pinned to the column it must measure from, execution ownership is pinned to both ids it must match, and the archived-file concealment test proves it conceals the archived read specifically. Two tests that asserted a paused branch they could not observe are gone; the rendered-SQL test that can decide it already covers them. * fix(execution): report a workflow-group cancellation as the write it performed Cancelling a workflow-group run whose log had already been cancelled, but whose cell sidecar still needed reconciliation, durably cancelled that sidecar and then reported `already_cancelled` with `durablyRecorded: false` — because the terminal-status shortcut answered from the entry snapshot alone and never asked what this request had written. The analytics event, which now gates on that field, stopped firing for a cancellation that really happened. The outcome a cancel reports is the same question whichever path answers it, so there is now one vocabulary for it rather than one the direct claim tracked and one the group transition did not. Every group result maps to that outcome through a total map, so a new group result cannot compile without deciding what it wrote, and the reclassification leads with whether this request wrote at all. A group transition that reports itself already cancelled is deliberately mapped as unknown rather than as a no-op: it leaves the sidecar alone but still terminalizes a log that was active, and the result does not say which happened. That costs nothing today, because the only snapshot that would reclassify proves the log was already terminal. * fix(execution): have a workflow-group cancellation report the writes it made Three review findings landed on the same reporting logic, each a different face of one cause: the caller could not see what the group transaction had written, so it inferred. It inferred from an entry snapshot, then from the returned kind, and the remaining blind spot was the kind that covers two different transactions — a repair that terminalizes an active log, and a genuine no-op — which left a cancel that wrote nothing still claiming a durable write when it lost a race. The transaction now reports both writes it can make, each read from that statement's own returning row and recorded immediately before the throw that already depended on it, so the report cannot drift from the write. The caller derives its outcome from those rather than from the kind, and the kind is back to naming the situation instead of standing in for the work. The group path can now always answer whether it wrote. The only remaining unknown is the direct claim when its update throws or is never attempted, which genuinely has no row count to report. |
||
|
|
237f973a11 |
fix(condition): stop a secret value from breaking or forging a condition (#6705)
Condition expressions pasted every environment variable value into the
expression as source. Block references in the same expression go through a
proper escape and get quoted; env vars went through neither. That left three
defects:
- A bare string placeholder was a SyntaxError. `{{NAME}} === 'alice'` resolved
to `alice === 'alice'`, so the form the Function block docs recommend could
not be used here at all.
- Ordinary data broke the block. An apostrophe (`O'Brien`) or a newline in a
legitimate value produced unparseable source and failed the run.
- The quoted form was injectable. A value of `x' || true || '` turned
`'{{NAME}}' === 'bob'` into `'x' || true || '' === 'bob'`, forging a true
branch out of a comparison that should be false.
Inline only structurally inert literals — numbers, booleans, and null, with
optional space/tab padding. Every other value keeps its `{{NAME}}` placeholder
and is bound as a string by the execution-boundary compiler, the same one
Function blocks and Custom Tools already use.
Legacy outcomes are preserved. `{{COUNT}} === 3` and `{{ENABLED}} === true`
still compare as literals, and an embedded `"Bearer {{API_KEY}}"` still
compares equal — now via compiled concatenation rather than a pasted value.
Padding is admitted rather than trimmed so the inlined text stays
byte-identical to the stored value, which is what keeps a padded number
correct both bare and quoted.
A resolved secret also no longer travels to the execution boundary inside the
condition source.
The one deliberate behavior change: a value whose text is itself a quoted JS
literal (a secret stored as `'foo'`, a plausible workaround for the bare-string
SyntaxError) now compares as the 5-character string rather than as source.
That form is the injectable one, so it cannot be kept.
Docs: state the placeholder type contract, which was described mechanically but
never in terms of what a reader gets. `{{KEY}}` in Function and Custom Tool code
always evaluates to a string, so a bare `if ({{FLAG}})` is always true and a list
has to be stored as JSON. This is what a customer hit after the resolver lift in
#6247 moved Function blocks off source inlining.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
a7115e87ee |
feat(integrations): add Azure Data Explorer (#6701)
* feat(integrations): add Azure Data Explorer Add a 14-operation Azure Data Explorer (Kusto) integration covering KQL queries, schema and metadata discovery, table management, inline and query-sourced ingestion, ingestion-failure triage, and arbitrary management commands. Authentication uses a Microsoft Entra service principal through an internal proxy route, since the Kusto token audience is per-cluster and cannot be expressed as a static-scope OAuth provider. * fix(azure-data-explorer): only read partial-failure status from the QueryStatus table Scanning every returned table for Severity and StatusDescription columns misread an ordinary query as a failed request whenever the user's own result selected columns of those names — a common shape for a log table. Failure detection now consults only the table the response's table of contents names as QueryStatus, and primary-result selection reuses the same index instead of re-reading it. * fix(azure-data-explorer): keep the Show Operations and Show Table Details cards from painting empty check:canvas-sentences flagged the Show Operations sentence: it anchored `core` on operationId, which is an advanced-mode optional field, so an untouched card resolved to nothing and painted empty. Show Table Details had the same shape in milder form — table is optional there, since omitting it describes every table, leaving a dangling preposition. Both now lead with literal copy and treat their field as an optional refinement. Also simplifies the primary-table condition to a single `!= null` check. * fix(azure-data-explorer): authenticate sovereign clusters against their own Entra authority The cluster allowlist accepted Azure China and US Government hosts, but every token request went to login.microsoftonline.com. Those clouds are isolated instances with their own Entra endpoints, so a sovereign cluster passed URI validation and then could never obtain a token. Each Kusto service domain is now declared alongside the authority that issues tokens for it, so the two cannot drift apart, and the authority is part of the token cache key. * improvement(azure-data-explorer): warn that ingest-from-query matches columns by position Kusto aligns an ingested query result to the target table on column type and order, never on column name, so a query projecting the right columns in the wrong order lands data in the wrong columns without erroring. Surfaces that in the tool description and param the model reads, in the wand prompt that generates the query, in the rollup skill's steps, and in the docs. Also verifies the target schema first rather than after. * chore(azure-data-explorer): drop the unsourced kustomfa host from the cluster allowlist Every other entry traces to a Microsoft reference — the Kusto connection-string doc, the national-cloud endpoint tables, and the Fabric KQL-database REST reference. kustomfa.windows.net does not, and the connection-string doc states the trust boundary as hostnames ending in kusto.windows.net. An allowlist should only hold hosts we can justify, so this drops it and records the sourcing standard for anything added later. * fix(azure-data-explorer): handle commas inside quoted properties and empty extent IDs Two defects in the shared command helpers: buildWithClause split the property list on every comma before validating, so a value that legally contains one — a docstring sentence, or a tags array with more than one entry — was torn in half and rejected. Splitting is now quote-aware, and an unterminated quote is rejected outright rather than swallowing the rest of the clause. transformColumnListResponse dropped empty strings, but `.ingest inline` reports "no data shards were generated" as a single record carrying an empty extent ID. A no-op load therefore looked like a missing column instead of an empty result. Only non-strings are skipped now. |
||
|
|
1d342722ad |
feat(rabbitmq): add RabbitMQ integration (#6700)
* feat(rabbitmq): add RabbitMQ integration * fix(rabbitmq): strip auth on redirect, require https, and bound the retrieval response * fix(rabbitmq): reserve message metadata in the retrieval response budget |
||
|
|
3051954396 |
fix(agent): show files in tool picker (#6666)
* fix(agent): show files in tool picker * chore(agent): remove redundant tool exclusions |
||
|
|
7f64d5e600 | perf(tables): stop a table write refetching every loaded page in the tab that made it (#6698) | ||
|
|
41819124d7 | fix(search): stop cmd+k boosts from lifting weaker matches over stronger ones (#6668) | ||
|
|
0650eab6e0 |
fix(desktop): let sites copy to the clipboard in the browser tab (#6696)
The agent partition denied every site permission, which included clipboard-sanitized-write — the permission Chromium routes navigator.clipboard.writeText through. Copy buttons rejected with NotAllowedError and did nothing at all: no error, no copied text. Sites still on document.execCommand('copy') kept working, which is why only some looked broken.
Granting it hands the page no reach it lacked. Chromium still requires the document to be focused and holding a transient user activation, and a sanitized write only places text the page already renders onto the clipboard. Reading stays denied, along with media, geolocation, and notifications.
|
||
|
|
0239db80c6 |
fix(desktop): report a stock Chrome user agent in the browser tab (#6695)
Electron's default user agent carries Sim/<version> and Electron/<version> tokens, and the detection libraries sites gate on test for Electron before Chrome — so the browser read as "Electron", which is on no site's supported list. Ashby warned "Ashby does not support this browser"; stricter sites refuse to render. Rebuild the string as the desktop form Chrome's user-agent reduction specifies — same platform token and Chromium major version, the rest zeroed, no application or Electron token — and apply it to both the browser partition session and each tab's WebContents. Service workers do not inherit a tab's user agent, so without the session-level call a worker's script request still announced Electron. Scoped to the browser partition: app.userAgentFallback is left alone so the Sim shell's own user agent is unchanged. |
||
|
|
b2b6e55a39 |
fix(tables): stop every table paginating forever on a null totalCount (#6694)
* fix(tables): stop every table paginating forever on a null totalCount * fix(tables): keep an emptied view terminated, and count masked reads off the seq scan |
||
|
|
b9a70e43f9 |
fix(execution): give a cancelled async run its terminal metadata (#6693)
* fix(execution): give a cancelled async run its terminal metadata The staging integration suite has been failing integ-cancel-async-api-key/async-execution-becomes-cancelled: the run reports status cancelled with a null endedAt and a null duration, so the assertion fails and the dependent worker-stop check never runs. The run resource falls back to the queue job whenever no execution-log row exists yet, and a cancel that lands before the worker has written that row leaves exactly that state. Trigger.dev marks a run canceled the moment it accepts the cancellation but only stamps its finish time when the worker drains, so for the seconds in between the job is terminal with no timestamp, and the projection faithfully reports a terminal status with nothing to date it. The run writes a correct log row when it finally drains, which is why the endpoint heals itself and the alarm fires intermittently rather than always. The backend was discarding the one timestamp that is always present: the retrieve response carries a required updatedAt beside the optional finishedAt. A finish time still wins wherever it exists, so nothing that already reports correctly changes, and the fallback is taken only once the mapped status is terminal — an active run's updatedAt marks progress, and reading it as an end would retire a run that is still going. It records the server's last transition for the run rather than the reader's clock, so it stays put across polls instead of growing. Also carries the duration on a fifth cancellation write, in the internal cancel route, that the earlier pass missed because its sweep covered lib and not app. * refactor(execution): derive a cancelled run's terminal fields in one place The five cancellation paths each hand-assembled the same four-key payload for the workflow-execution log, and one of them had already drifted: the direct cancel never cleared `execution_deadline_at`, leaving a cancelled row carrying the deadline of an attempt that had stopped running. Extract the payload so the key set cannot vary between them, and leave the paths themselves alone — they differ in handle, claim predicate, whether they read the row back, and what they do when the claim is lost, so they stay separate statements. Bind the end instant through the `started_at` column encoder rather than a pre-stringified ISO literal. `check:sql-date-binding` exists to enforce exactly that binding; the literal passed only because it was already a string. Collapse the duration expression to one `COALESCE` over a valueless-`ELSE` `CASE`, which builds the elapsed fragment once instead of in both branches, and reuse it for the stale-execution sweeper, which carried its own copy along with a second int4 ceiling constant. Document the invariants the fix depends on where a reader meets them: that `total_duration_ms` means wall clock for a terminal row and active time for a paused one, and that a terminal job must carry its transition instant. |
||
|
|
c6047efc4e |
fix(sidebar): align credits chip with panel toggle and square the collapsed rail (#6691)
* fix(sidebar): align credits chip with panel toggle and square the collapsed rail * fix(sidebar): center collapsed rail chips in the rail * fix(sidebar): shrink collapsed rail to 48px so chips center without shifting on toggle |
||
|
|
9aa36a424f |
fix(icons): match folder icon optical size to sibling resource icons (#6692)
The folder icon read visibly smaller than the table/database icons beside it in resource rows despite an identical size-[14px] class, because the class sets the SVG box and not the artwork inside it. The folder outline only spanned 15 units of the shared -1 -2 24 24 box against 17.5-18 for its siblings. Scale the outline to a 17-unit extent on the same .25 grid and quarter circle corner radii, keeping the body path byte-identical across Folder, FolderOpen and FolderCode so the sidebar expand/collapse toggle does not shift. FolderCode's brackets move with the body they are centred on. |
||
|
|
fa394e5e07 |
fix(execution): resolve secrets against the acting principal, not the workflow owner (#6690)
* fix(execution): resolve secrets against the acting principal, not the workflow owner * fix(execution): resolve anonymous public-API runs as the workspace billing account * fix(execution): propagate run identity across dispatch paths and scope public runs to workspace secrets |
||
|
|
009f5fea7f |
fix(logs): record how long a cancelled run had been going (#6686)
* fix(logs): record how long a cancelled run had been going A cancelled run got an end timestamp and no duration. Every other terminal transition writes both — the completion path sets them together, and a paused run already records its elapsed time — but cancellation writes the log row directly rather than through completion, so it had no in-memory duration to store and simply omitted the column. That is not cosmetic. `GET /api/v2/logs` filters on `minDurationMs` and `maxDurationMs`, and a null column drops the row out of every such query, so cancellations are invisible to exactly the searches someone runs when investigating cancellations. The published contract also says the end timestamp is null only while a run is active, which a cancelled run is not. Both cancellation writes now derive the duration in the same statement from the row's own `started_at`, through one shared expression so the two cannot drift apart the way they did from the completion path. The end instant is computed once and reused, so the stamped end and the derived duration describe the same moment rather than two clock reads. The instant is bound as an explicit `timestamp` rather than a `Date`: `started_at` is `timestamp without time zone` holding a UTC wall clock, and a driver-bound date would infer `timestamptz` and make the interval depend on the session zone. The floor of one millisecond matches the completion path, so a run cancelled inside its first millisecond still records that it ran. * fix(logs): saturate the cancelled-run duration at the column ceiling The column is `integer`, so an untimed run cancelled after roughly twenty-five days overflowed the cast. That cost more than the duration it was recording: the direct write is caught and logged, so the row would have stayed `running` with no end timestamp at all, and the workflow-group write would have failed its transaction and taken the whole cancellation with it. Saturating keeps the terminal write. A duration wrong in its last digits is a smaller lie than a run that never ended. * fix(logs): record the duration on the other two cancellation writes Review found the first pass had only covered two of four terminal cancellation writes. The two it missed spell the timestamp `endedAt: now` rather than `endedAt: new Date()`, so the search that found the first pair could never have found them — and one of them is the common case: a workflow-group run with a live cell sidecar takes that branch, and the direct cancel skips its own log update whenever group cancellation handled the run, so it was the only writer for those cancellations. The other is the paused-cancellation write, which the first pass reported as already correct on the strength of that same search. A paused run records its duration when it pauses; cancelling it did not. All four now derive the duration the same way, and the sweep for the remaining ones went over every `status: 'cancelled'` write rather than one spelling of the timestamp beside it. * fix(logs): let a recorded duration outlive a later cancellation A paused run measures its own active duration at the pause checkpoint. The previous commit then had cancellation overwrite that with wall clock from the start, which quietly redefines the column for those runs to include the time the run spent waiting rather than working — filling a gap by discarding an answer someone else had already computed. The duration now coalesces onto whatever the row already carries, so a cancellation only supplies the value when nothing else did. Every other cancellation path leaves the column null, so the change is inert there. * fix(logs): only a paused run keeps the duration it recorded Preserving any duration already on the row was too broad. Resuming flips the log back to running and leaves the pause checkpoint value behind, so a resumed run carries a stale reading while it is accruing time again; cancelling it would have frozen that pre-resume figure and disagreed with the resume completion path, which measures wall clock. What separates the two is the row's status rather than whether the column is populated. A paused run is not accruing, so its recorded active duration stands. A running one recomputes. |
||
|
|
b9b9c9c9a9 |
fix(canvas): stop phantom ports and a latched-open action bar (#6688)
* fix(canvas): stop phantom ports and a latched-open action bar Ports surface on hover from a swell painted on the card border, and that swell was raised with no regard for whether a handle exists behind it. A Response block mounts no source handle, so hovering its edge raised a knob no edge could ever leave from. A trigger mounts no target handle, yet still swelled under a connection dragged from another card, offering a drop it cannot accept. Gate each direction on the handle that backs it, and limit a trigger's own swell to its source edge the way the subflow start node already does. The action bar latched open for the same interaction. Leaving the card arms a retract and installs a pointermove listener to track the pointer across the gap up to the bar; re-entering the bar's band called openHover(), which cancelled the retract AND removed that listener. No further pointerleave can arrive once the pointer is off the node, so nothing was left to close the bar. Keep the listener installed and re-arm the retract when the pointer moves back out. Also clear the magnetized port when the pointer leaves the tracking band onto the action bar: only the in-band path recomputed it, so the last knob stayed pinned at hover amplitude with the pointer nowhere near it. * fix(canvas): scope the receive gate, cover both swell directions Gating the foreign-drag listener also ran the shared pointer-tracking reset, which belongs to the card's own hover. A trigger sets canReceiveConnection false while canStartConnection stays true, so the reset undid the layout effect's :hover bootstrap and left a card that mounted under the pointer with no source swell until the pointer left and came back. Skip the listener instead; the effect's own cleanup already covers a true-to-false flip. Drop the trigger-only cursorSwellSides restriction. A swell on a trigger's input edge resolves to a source handle, so an edge genuinely can be made there — it was a behavior change beyond the bug, not a phantom. Cover both directions of the swell gate, and use the shared sleep helper in the action-bar test. Hoist the constant connection sides out of the render body so they stop riding the borderPorts dep array. |
||
|
|
9436a93f1b |
feat(library): How to Turn a Workflow Into a Reusable MCP Tool (Sim vs n8n, Gumloop, and Zapier) (#6687)
Co-authored-by: Sim Pi Agent <pi@sim.ai> |
||
|
|
264d4f3469 |
feat(canvas): add a setting to turn off auto-focus when clicking blocks (#6685)
* feat(canvas): add a setting to turn off auto-focus when clicking blocks Clicking a block animates the camera to center it, which zooms in far enough that you lose sight of the rest of the workflow. Add an "Auto-focus on click" preference (on by default, so existing behavior is unchanged) that keeps the camera still on click. Also re-record the auto-connect and canvas-error-notification tooltip previews and re-encode all three at a smaller size. * fix(canvas): keep click-marked framing when auto-focus is off, recut tooltips Gating the whole click branch on the setting also skipped the userFocusedWorkflowIdRef write, which is what stops <ReactFlow onInit> from running fitView over the user's framing. That would have blown away the framing of exactly the users who turned auto-focus off to keep it. Mark the workflow as user-framed on any plain node click and gate only the camera move. Crop the auto-focus preview to the recording's viewport center so the blocks are legible at the 240px width Tooltip.Preview renders at, and trim the 2.45s of empty lead off the error-notification preview. * docs(canvas): correct useAutoFocusOnClick scope to clicks only The TSDoc claimed the preference also gated arrow-key navigation, which calls focusBlockInView without consulting it. State the click-only scope and why arrow-key navigation and block creation are excluded. |
||
|
|
9fc65863cd |
fix(v2): hold create to the rules update enforces, and bind the last two cursors (#6684)
Three defects, one shape: a rule applied to one path and not its sibling. Two were found by probing the live surface after the previous fixes deployed, and the third by reading for the pattern. Creating a workflow group through the public surface validated almost nothing the update path validates. An enrichment group could name an enrichment the registry does not define, or an output the enrichment does not have, or carry no output id at all — each a 201 storing a column no run can ever write, discovered only when the caller later tried to edit the group and got the 400 create should have given. The workflow half was the same: a fabricated block-and-path coordinate was stored on create and refused on update. Create now runs the same two registry helpers and the same workflow-output check the update path uses. The discriminator there is the backing workflow id, not the declared type. The workflow sidebar creates enrichment-template groups labelled `enrichment` while backed by a real workflow and carrying no enrichment id, so keying on the label would have refused the first-party create path outright. A group's producer type could also be relabelled after the fact into a state creation refuses. Nothing rejected it and nothing could repair it, since the update body carries no enrichment id to supply. Relabelling an enrichment group as workflow-backed is the harmful direction: it keeps the enrichment id while moving the group onto the workflow branch with an empty workflow id, so every cell run fails. An update may now only restate the type the group already has. The workflow-version and workspace-member lists were the last two paged reads minting cursors with no route identity, so a token from one parent resumed another at a position that silently skips rows — the defect the previous change closed everywhere else. Both now wrap their domain token with the same scope binding, and the pagination guardrail gained a declaration of every nested list's parent path param, because the old one recorded only query filters and so could not tell an unfiltered list from a forgotten parent. |
||
|
|
4ff339e9a8 | fix(chat): focus the composer when opening a new or existing chat (#6683) | ||
|
|
ab8a64fcae |
fix(v2): close the defects live probing found (#6681)
* fix(v2): close the defects live probing found Staging finally deployed the merged release, so the surface could be exercised for real. Every fix already shipped held up. These are the defects only live traffic surfaced, plus the ones a static sweep had found and left. A cursor named a position in a sequence without naming the sequence. `cursorScopeKey` hashed only the caller's filters, so any two lists filtering on nothing but `workspaceId` produced one fingerprint and accepted each other's tokens: a tables cursor replayed against the knowledge list answered 200 and silently skipped a row. Table rows never reached that check at all, so a cursor from one table paged another. Identity now comes from the route's own contract — method plus resolved path — because a hand-written name is the step an author forgets, and forgetting it is invisible. An unresolved path placeholder throws rather than fingerprinting the template, so a misconfigured route fails on every request instead of an unlucky one. Every token minted before this is refused with an accurate message; they are single-walk and unpersisted. Knowledge search and the document list answered different questions. Search grouped same-tag filters by slot and joined them with OR while the list conjoined every filter, so `gte 9` and `lte 2` on one tag returned nothing from the list and a full billed page from search. Search now conjoins. The OR grouping replaced an explicit `|OR|` mechanism that was deleted outright, was never documented in any contract, and cost the ability to express a range on a single tag; the union it gave is still reachable as separate searches. The search body also accepted an unbounded query that was billed and then silently truncated to the embedding model's window, and ignored the tag-filter cap the list enforces. A body over ten mebibytes was reported as malformed JSON. Next's proxy truncates there, well under this app's fifty-megabyte ceiling, so the parse failed on a body the caller sent whole and the size branch was unreachable. The ceiling is now clamped to what the proxy will pass. Also: a group's output columns accepted a `workflowGroupId` and discarded it; an enrichment group could never gain an output, because a new output coordinate demanded workflow metadata a group with no workflow cannot have; `newOutputColumns` alone reported success and created nothing; a saved view stored layout references to columns that do not exist while refusing the same name in a filter; an MCP server stored `retries: 0` as three and overrode an explicit auth type; a disabled server answered tool discovery with an unclassified fault; rotating a header server's headers left it reading connected; and a run whose workflow was deleted reported the root folder path while also reporting the workflow deleted. Where the honest fix was out of reach, the contract was corrected instead of half-fixing the code: the polled run resource rebuilds `error.code` by matching the persisted message, so it can never report the two codes that need block attribution, and now says so. `OUTPUT_TOO_LARGE` is removed — no path ever emitted it. `triggers` was left alone deliberately. It reads as a closed enum but production holds 43 distinct values, because a webhook run stores its provider id; pinning the enum would refuse legitimate history a log search exists to find. The description now says the vocabulary is open. * fix(v2): clamp explicit body caps to the proxy ceiling too The previous commit clamped the default JSON body cap but left explicit per-route overrides alone, so a route declaring a larger `maxBodyBytes` still fell into the truncation it was meant to report: the four inline workspace-file routes at 70 MB and the deployed-chat route at 220 MB. Next attaches `proxyClientMaxBodySize` to every request and clones the body unconditionally for any non-GET method on a matched path, pushing EOF at ten mebibytes with only a warning, so the handler reads a truncated prefix. Those routes therefore already fail above that size — as a malformed-JSON 400. Clamping the effective limit inside the two body readers makes the same request fail as payload-too-large, quoting the limit actually in force. One existing test asserted the unreachable case, allowing a sixty-mebibyte base64 body; it now asserts what the proxy will forward intact. The inline-file path still advertises fifty mebibytes and cannot exceed the proxy ceiling until that ceiling is raised, which changes buffering for every route and belongs in its own change. * fix(v2): close the two holes the first review round found Both are places where a fix in this branch shut one door and left a smaller one open in the same wall. Letting an enrichment group gain an output meant skipping workflow resolution — but that resolution was the only thing validating a new output, so a PATCH began storing coordinates the runner can never fill. It fills a cell from `result[out.outputId]` and skips an output with no `outputId` at all, while the writer diffs on that same id and the sidebar reads and writes by it; the contract leaves it optional. The regression test added with that fix was itself asserting such a dead coordinate. Create's registry checks are now two shared helpers both paths call, and on update an output is exempt only when an identical binding already existed, so renaming a group whose enrichment has since changed still works while anything added or repointed must name a real output. `mappingUpdates` on an enrichment group now says it is inexpressible rather than resolving an empty workflow id into a missing workflow. The layout-reference check was handed the tolerant column set, so a placeholder minted to keep a dangling filter writable also whitelisted a brand-new layout reference — storing an entry the next read discards, which is the inconsistency the check was added to remove. Layout now resolves against the live columns, which is exactly what pruning keeps, while filters and sorts keep the exemption they need. |
||
|
|
44524d1b07 | fix(autolayout): rescue new notes from blocks they were created on top of (#6680) | ||
|
|
1424809614 |
feat(netsuite): add Oracle NetSuite integration (#6476)
* revise netsuite integration * fix(netsuite): align selector route with snowflake * test(netsuite): remove selector route coverage * test(netsuite): align coverage with snowflake * fix(netsuite): complete integration validation * refactor(netsuite): align integration with codebase patterns * test(netsuite): correct async job citation * fix(netsuite): address final audit findings * fix(netsuite): surface upsert/transform Location, relax task link check Oracle documents the Location response header for create and update, and both tools already require it. Upsert and transform also produce a record but Oracle documents no response headers for either, so they dropped the header entirely and the new record's ID was unreachable. Add a `resource-optional` location mode that captures Location when NetSuite sends it and never fails when it is absent, and wire it to upsert and transform along with their tool and block outputs. Async task discovery rejected the whole response if any task link carried a rel other than `self`, collapsing the picker into a 502. Oracle documents a `self` link per task but never guarantees it is the only one, so skip other relationships and fail only when no self link exists. Also use the shared `truncate` helper in the error sanitizer per the repo convention instead of an inline slice. * fix(netsuite): validate SuiteQL pages against their documented shape The shared collection-page validator required links, items, count, hasMore, offset, and totalResults on every 200, and a missing field turns a successful call into a reported failure. Oracle documents all six for record collections and SuiteAnalytics dataset pages, but its SuiteQL reference lists only links, count, offset, totalResults, and items. A documented SuiteQL response that omits hasMore would therefore have been rejected. Split out a suiteql-page validator that requires the five documented SuiteQL fields and type-checks hasMore only when the account returns it. Record collections and dataset pages keep requiring all six. * chore(netsuite): regenerate tool metadata after rebase on staging The rebase conflicted only in the generated tool-id, tool-metadata, and tool-output artifacts, which NetSuite and the newly landed LogRocket integration both extend. Regenerated from the merged registries: the result is staging's catalog plus the 27 NetSuite tools, with LogRocket's entries intact and no other tool changed. --------- Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local> Co-authored-by: Bill Leoutsakos <billleoutsakos@Mac.localdomain> Co-authored-by: Waleed Latif <walif6@gmail.com> |
||
|
|
046302aa2a |
fix(sidebar): stop bubbled dragleave events cancelling an in-progress drag (#6679)
* fix(sidebar): close folders a drag spring-opened, and surface reorder failures * fix(sidebar): stop bubbled dragleave events cancelling an in-progress drag * fix(sidebar): disarm the spring-open timer when a drag ends |
||
|
|
58b5ee9337 |
feat(logrocket): add LogRocket integration (#6678)
* feat(logrocket): add LogRocket integration * improvement(logrocket): fail loudly on a non-numeric identify timestamp and cover releases in the catalog copy * fix(logrocket): require a highlights identity and treat pagination cursors as opaque strings * fix(logrocket): trim request fields so whitespace-only input fails validation * fix(logrocket): declare the release version in the block inputs map |
||
|
|
c155a57c76 |
fix(desktop): move prereleases to release repo (#6674)
* fix(desktop): move prereleases to release repo * fix(desktop): validate prerelease authentication |
||
|
|
e2b7335644 |
fix(v2): stop telling callers something the server did not do (#6676)
A works-as-advertised sweep of the v2 surface found one defect class in five
places: input is validated for shape, then its meaning is re-derived
independently by each consumer — so a filter compiles differently than it
validated, or a write commits and the response then reports failure.
Knowledge tag filters were validated once and re-parsed three times. The
document list read `Number()`, search read `parseFloat()`; the list matched
booleans case-insensitively, search compared against the literal `'true'`; the
list escaped LIKE metacharacters, search did not; and the date pattern was
tested against the untrimmed string the validator had already trimmed. Two of
those paths dropped the predicate entirely and answered 200 with the whole
knowledge base — on a billed endpoint. Values are now coerced once, where the
resolved field type is known, and both builders consume the result. A builder
that cannot compile an already-validated filter now raises instead of silently
widening the result set.
`PUT /api/v2/secrets/{name}` with `scope: personal` committed the secret and
then answered 500, because a user-global write was reported through a
workspace-scoped mirror lookup, and an org admin's inherited access has no
`permissions` row for the fan-out to find. The personal path no longer decides
success from a per-workspace mirror. The `workspaceId` descriptions said a
personal secret lives in one workspace; it does not, and they now say so.
A custom tool could be stored with a schema the read path cannot serialize —
`POST /workflows/import` and Copilot both wrote through name-only checks — so
one row made the whole workspace list 500, and a title-only PATCH committed,
audited, then reported failure. Every write now passes the same guard the
response schema is derived from.
`POST /api/v2/tables` accepted `workflowGroupId` on an initial column. Nothing
can populate it legitimately, and it made every later column-add and group-add
fail with no way to clear it. The key is refused at the boundary, and
`createTable` now runs the invariant every later mutation already runs, closing
the internal and v1 ingresses too. Those invariants moved to a leaf module:
reaching them through `workflow-columns` pulled the executable tool registry
into the tables page graph, taking it from 1,767 modules to 6,999.
An out-of-range upload part number answered 500 rather than the 400 its
published contract promises, because the throw happened above the route's
try/catch and was not an `HttpError`.
|
||
|
|
0758df3682 |
perf(workspace): stop the sidebar fetching palette data on every route (#6670)
* refactor(prefetch): give the workspace-file seed its own module and tests * perf(sidebar): let the palette fetch its own lists, so closed routes never register them * perf(prefetch): seed the file list on the pages that render it, not every route * fix(chat): guard the chat page prefetch behind the chat flag |
||
|
|
49ec9a907b |
fix(loading): debounce selector search, fix two row-cache snapshots, and move Vertex refresh to the app (#6667)
* fix(selectors): debounce the provider search, and keep the list while it refetches * fix(tables): snapshot row pages from the non-collidable prefix * fix(vertex): resolve the OAuth token through the app, which holds the client config * fix(selectors): drop the previous-options fallback so a context change cannot leave stale ones selectable * fix(selectors): clear the search without waiting out the debounce * fix(realtime): wait for the streak-reset preconditions instead of sleeping past them |
||
|
|
86dbd0acaf |
improvement(search): make overlap dedupe linear in match count (#6640)
* perf(search): make overlap dedupe linear in match count Typing in workflow Cmd+F froze the editor for over a second on a large workflow, with the typed characters landing late in one burst. `dedupeOverlappingWorkflowSearchMatches` ran `deduped.findIndex(...)` over the whole accumulated list for every match, recomputing each candidate's scope key inside the predicate - O(n^2) string builds. The memo re-runs on every keystroke (the query is not debounced), and a single character is the worst case because it matches the most. Reproduced on an 81-block workflow (4530 subblocks, real knowledge-base and OAuth references). Stage timing during one typing burst: searchBlocks merge 13ms index 35ms hydration 10ms filter + dedupe 1458ms <- resource options 6ms Overlap is only ever resolved within one value of one subblock, so bucket candidate indices by scope key and scan the bucket. A bucket holds exactly the entries the old predicate could match (scope key and range both present), buckets keep insertion order, and the scan stops at the first overlap, so the same candidate wins. A per-bucket `maxEnd` skips the scan entirely when a match starts at or after every kept range's end, which keeps a single long field full of disjoint hits linear too. Measured on that workflow, dedupe alone, by query: query matches before after email 558 6.4ms 0.56ms r 1698 53.3ms 0.71ms e 3373 232.9ms 1.10ms End to end in the browser the longest task while typing went from 1534ms to 247ms, with the same 521 matches either way. Two traps `maxEnd` sets, both found by adversarial review and both now pinned by tests: - `shouldPreferOverlappingMatch` prefers the SHORTER range, and a shorter range can end further right than the one it evicts. `maxEnd` has to be refreshed on the replacement path, not only on append, or the short-circuit skips real overlaps and leaks duplicates into replace-all. - Widening with a non-finite end would pin `maxEnd` at NaN, and since every comparison against NaN is false that silently switches dedupe off for the rest of the scope. Only finite ends widen it, which matches how the unbucketed scan treated such a range. `resolvers.test.ts` gains a reference implementation - a transcription of the original linear scan - checked against the bucketed one over 400 sequential seeds whose generator also emits inverted, empty and non-finite ranges, plus the two concrete replacement shapes above and a 20k-element single-scope case that pins the asymptotics. An earlier revision of this test pinned 8 hand-picked seeds and passed while 7.7% of the seed space diverged, so the sweep width is the point. * docs(search): describe maxEnd as a bound, not the exact maximum Review pointed out the comment claimed `maxEnd` is "the largest range.end currently kept in the bucket", which stops being true the moment a replacement swaps in a range that ends earlier - it is only ever widened. Only the upper bound is load-bearing, so say that. A bound left too high costs a scan that would have been skipped, never a wrong answer, and the staleness is capped at one token length because every range spans a matched token rather than the field. Also records why the exact maximum is deliberately not recomputed: on the realistic overlap shape at 10k matches, recomputing measures 45ms against 23ms as written, and 1010ms for the scan this replaced. * fix(search): preserve infinite range overlap semantics * test(search): group dedupe equivalence coverage --------- Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai> |
||
|
|
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). |
||
|
|
85c8451a86 |
fix(blocks): give the detail headers the same tile as everything else (#6672)
The tile consolidation left the large detail headers on their own treatment, so a block wore one colour on the canvas and another in the header naming it: the preview panel behind the deploy modal and the logs trace detail both painted straight from the catalog `bgColor`, showing Start's catalog blue over a neutral card, and hardcoded `#2FB3FF`/`#FEE12B` for the two subflows. Adds the 18px header slot to `BlockTile` and points all three headers at it — preview panel, trace detail, and the editor header, which had been carrying its own inline copy of the accent rule. `WorkflowTypeIcon` takes an icon class so the accent chip can draw the larger glyph the header uses. |
||
|
|
1a62c44913 | improvement(canvas): cancel an in-flight edge drag with Escape (#6669) | ||
|
|
8d319a430a |
fix(v2): close the correctness gaps the release audit found (#6671)
An end-to-end audit of the v0.8.1 release surfaced one regression the release itself introduced and a set of filter/cursor gaps that let a caller's spelling change what a query answered. An `enrichment` workflow group stored no `workflowId`. The public contract invites a caller to omit it, the write persisted caller input through an `as WorkflowGroup` cast that hid the omission from the type checker, and the response schema still required it — so the write committed and then the outbound parse threw. Because the list presenter maps every group through that schema, one such row made GET, PATCH and DELETE on that table's groups fail from then on, with no public way to remove it. The cast is gone rather than papered over, so the same class of omission cannot recur silently. Knowledge tag filters accepted any operator string and then ignored an unrecognized one in opposite directions: the document list dropped the predicate and answered with the whole knowledge base, while search fell through to equality and answered a different question. Both now reject at the boundary. `.strict()` is applied on the v2 chain only — v1 has always stripped unrecognized keys, and the `between`-requires-`valueTo` rule already closes the mis-cased `valueTo` trap on both versions. Cursor scopes bound set-valued filters to the caller's ordering: `all`/`any` clause order and `in`/`nin` operand order in the table predicate, and the raw `resourceType` text on audit logs, whose query splits it into an `inArray`. Audit logs is canonicalized on both sides, because canonicalizing the scope alone would have given two genuinely different result sets one fingerprint. Also: the exposed-header list reached only the fallback CORS policy, so all five matched rules — including the wildcard-origin execute route, the only one that emits `X-Run-Id` — could not hand a browser the run id or a 429's `Retry-After`; a bulk row update reported an uncoercible value only when its filter happened to match; the cost and duration windows accepted an inverted pair and answered it with an empty page; and the sortless runs list advised callers to fix a `sortBy` it rejects. |
||
|
|
9c7b24369f |
fix(sidebar): keep the right-click context menu open over the collapsed chat flyout (#6665)
* fix(sidebar): keep the right-click context menu open over the collapsed chat flyout * fix(sidebar): use an absolute import in the context menu test * test(sidebar): cover item selection after a surrounding menu takes focus * fix(sidebar): keep the collapsed workflow actions menu open on right-click |
||
|
|
cc7f005b3a |
feat(emails): add aug-13 what's new broadcast (#6663)
* feat(emails): add aug-13 what's new broadcast Adds the August 13 broadcast alongside july-1, covering realtime collaboration in tables/files/workflows and the redesigned workflow editor. Assets live in public/email/broadcast/aug-13/ so the broadcast is self-contained: the two feature GIFs plus its own copies of the shared logo and pre-footer, matching the per-broadcast asset layout july-1 established. * improvement(emails): update aug-13 broadcast copy and compress its GIFs Copy revision from the marketing draft, plus asset compression: - feature-realtime.gif: lossless gifsicle -O3, 77KB -> 55KB, pixels unchanged - feature-workflow.gif: 1888px -> 1200px (still 2x the 600px render width) and a 64-color palette, 1.15MB -> 764KB. Verified frame-identical at render size and at 2x crop; frame count and timing preserved. |
||
|
|
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). |
||
|
|
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
|
||
|
|
1e6004259e |
fix(tools): resolve credentials over HTTP again so token refresh keeps the app's OAuth config (#6662)
* fix(tools): resolve credentials over HTTP again so token refresh keeps the app's OAuth config * chore(ship): note what to keep out of PR titles and descriptions |
||
|
|
0c4e674132 |
perf(server): stop calling our own API over HTTP during render, execution, and tool runs (#6660)
* fix(prefetch): parse the file-folder seed through its contract The audit found this key was the workspaceFilesKeys bug waiting to recur. The manager's record type and workspaceFileFolderSchema are two independent declarations that agree today by coincidence; the seed had no parse, so adding a column to one would have silently cached a shape a client fetch strips — and three of its fields are z.coerce.date(), the exact divergence that put ISO strings under the file-list key. Its sibling is immune because listWorkspaceFilesWithShares parses at the data layer. This does the same at the seed, and adds the shape-parity assertion the key never had. Verified falsifiable: removing the parse turns it red. Doing so also exposed the existing folder test as fixture-thin — a folder with only an id, which the contract rightly rejects — so it now uses a real row. Also points the credential block's fetchQuery at the exported staleTime constant instead of restating 60 * 1000; it was a fifth producer on that key free to drift from the four that share it. * fix(queries): stop a table cell edit throwing, and an upload gate failing shut Two functional bugs found auditing the query layer. patchCachedRows walked tableKeys.rowsRoot non-exact, but rowsRoot is a prefix: the find (search results) and write (pending writes) subtrees hang off it with non-paged shapes, and the updater's old.pages.map threw on them. It runs inside onMutate, so the whole cell edit rejected before reaching the server — reachable as soon as a find entry exists, i.e. after the user searches the table once. The sibling isDefaultOrderRowsQuery already excluded those subtrees and its docstring claimed they "never match"; that was only true of the sibling. Both now share one isRowListQueryKey helper so they cannot drift apart again. useCloudStorageConfigured combined staleTime: Infinity, retry: false, and the global retryOnMount: false on a workspace-independent key, so one transient failure left it errored for the tab's lifetime with no way back — navigating or switching workspace cannot change the key, and the upload path fails closed, so cloud-backed uploads stayed disabled until a full reload. useVoiceSettings carries the same three options and already escapes this with retryOnMount: true; this one now matches. Note: hooks/queries/workspace-files.test.tsx cannot load in a git worktree (pre-existing postcss resolution failure), so CI is the first place that file runs against this change. * perf(server): memoize the request-scoped workspace and entitlement reads The workspace row was read ~3x per workspace route and ~5x on settings, and the same Max-tier entitlement was resolved twice on one render. Memoization is deliberately partial. getWorkspaceWithOwner accepts a transaction and forUpdate, and live callers use both, so only the plain no-options read routes through the memo; a row read inside one caller's transaction or under a lock it alone holds can never be served to a later caller. includeArchived is part of the key so the two variants cannot alias. Three substitutions were considered and rejected as behavior changes, not optimizations: hostContext.ownerBilling resolves subscriptions differently from hasWorkspaceTierAccess and exposes no Max tier, so it cannot answer the Inbox/Sandbox gates; isOrganizationOnEnterprisePlan carries self-host short-circuits ownerBilling has no equivalent for; and widening WorkspaceHostContext to carry the full row would push owner and org ids onto the wire for every viewer to save a server-side read, since that type is a response contract rather than an internal struct. * fix(selectors): key CloudWatch lists by search, and stop a caller erasing the credential gate The CloudWatch log-group and log-stream selectors forwarded `search` into the request as `prefix` but left it out of the query key, so every keystroke resolved to the same fresh entry and no refetch fired. Server-side filtering was dead: a log group outside the first page could not be reached. An audit of all 69 selector definitions found these two and no others. useSelectorOptions resolved `args.enabled ?? definition.enabled(...)`, so a caller supplying its own gate replaced the definition's precondition rather than narrowing it. useSelectorDisplayName knows nothing about credentials, so a card holding a saved value with no credential context ran a query that could only reject. The two are now conjoined. The detail hooks keep the override deliberately — resolving one known id needs less context than listing, which their TSDoc already documents. The list-key fix has a test, proven to fail without it. The `enabled` change has none: loading use-selector-query pulls the selector registry and emcn CSS, which cannot resolve in a git worktree. * fix(queries): give optimistic rows collision-free ids generateTempId used Date.now(), so two rows created in the same millisecond shared an id and the first server response overwrote both — leaving one row duplicated and the other's real id lost until a refetch. Now uses generateId(), matching what the workflow mutations already do. Reachable by double-clicking create, or by any scripted or bulk create. Also documents the contract of fetchOAuthConnections, which reports an unknown connection state as disconnected. No consumer reads that field today — both read names and icons, and connection state comes from useWorkspaceCredentials — so letting the query reject would blank the suggested-action rows and drop the credential page to raw provider ids. The note is what stops a future consumer branching on it silently. * fix(queries): close six stale-data gaps found auditing the query layer Each was verified against the mutation that changes the data and the keys that expose it, not taken on report. - Workspace usage/credits were invalidated nowhere in the app. Six sites already refreshed subscriptionKeys after credits moved — post-run, post-wand, limit edits, upgrades, top-ups — and none touched workspace usage, so the credits chip and the run gate held their page-load values until a reload. Adds one shared invalidateWorkspaceUsage and calls it from all six. - Knowledge-base list doc counts went stale: document upload, delete, and bulk delete invalidated only the detail key, though the list carries docCount. - Plan switches that do not redirect refreshed only the host context, leaving subscription and credit state showing the previous plan. - The copilot tool-event handler invalidated a raw workflowKeys.list, which covers only the active scope and skips the selector prefix; it now uses the shared invalidateWorkflowLists like the other thirteen call sites. - scheduleKeys.byId was a strict prefix of scheduleKeys.schedule, so the two addressings aliased, and nothing invalidated byId. De-aliased and invalidated. Not changed: the CSV preview key already folds in the file version and storage key, so a content update addresses a different cache entry — version-in-key is the mechanism there, not a missing invalidation. Tests added for the usage and knowledge fixes, both proven to fail without them. The other four live in files that cannot load in a git worktree (pre-existing postcss resolution failure), so CI is where they first run. * improvement(queries): make the row-list prefix non-collidable, and share the usage refresh Two corrections from reviewing the previous commits. patchCachedRows was fixed with a predicate naming the sibling subtrees to skip — a denylist that rots the moment a fifth subtree is added under rowsRoot. The key factory already separated row lists under an 'infinite' segment; it just had no prefix accessor, so every caller reached for the parent and subtracted. Adding infiniteRowsRoot lets the walk be an allowlist by construction and deletes the predicate, the helper, and both docblocks explaining the subtraction. The searched-rows view is consequently no longer patched by a cell edit and is left to its own refetch — it holds a flat result, not pages. That is recorded on the function rather than left to be rediscovered. The delayed usage refresh was written out three times across two files, a duplication the previous commit enlarged rather than introduced. It is now one scheduleUsageRefresh beside the keys it invalidates, which also gives the bare 1000ms a name and one place to change it. * improvement(prefetch): budget the workspace file seed and narrow its columns * improvement(api): resolve credentials and checkpoint reverts in-process * improvement(executor): run router and evaluator provider calls in-process * improvement(queries): fix a second row-cache collision, and make the memoized workspace read actually dedupe * chore(test): type the evaluator provider-request helper instead of using any * improvement(perf): restore the parallel file read, and close the gaps a diff audit surfaced * improvement(perf): drop a duplicate authorization, parallelize the credential reads, and trim the comments |
||
|
|
618cee5cb9 |
test(prefetch): cover the workspace-list seed, and fix two tests that could not fail (#6659)
The audit found the seed contract — the whole point of #6656 — had no test, and that one existing assertion was vacuous. - prefetchWorkspaceSidebar and seedWorkspaceList now have coverage: the empty list seeds nothing (so the client reaches the route's default-workspace creation path), a populated list seeds, a rejected read neither throws nor seeds, and a host context for another workspace seeds nothing at all. Verified falsifiable — removing the empty-list guard turns the first red. - The graceful-failure row for prefetchFilesBrowser asserted on the file-list key, which that function deliberately never writes, so it held no matter what the code did. It now asserts the folder key it owns, and the setup rejects the folder read. - The sidebar was the third hand-rolled copy of the folder prefetch the shared helper was extracted to remove; it now calls prefetchResourceFolders too. - prefetchKnowledgeBases returns early without a userId like every sibling, rather than reaching the authenticator and throwing per render. - Dropped two docblock claims about a `retry` default that no longer applies server-side. |
||
|
|
c49751b32e |
perf(prefetch): stop calling our own API over the wire during server render (#6657)
* perf(prefetch): read the data layer instead of calling our own API over the wire
Four server-render prefetches went out over HTTP to our own routes. With
INTERNAL_API_BASE_URL unset in prod, getInternalApiBaseUrl() falls back to the
public base URL, so each was RSC -> public HTTPS -> load balancer -> back into
the app, awaited inside the render with a second round of auth.
- /home fetched the workflow folder list that the workspace layout had already
fetched, under the identical query key. Since getQueryClient() builds a new
client per call on the server, the two never deduped: same data, twice a
request, once directly and once over the wire. Dropped; the layout's entry
already hydrates it.
- /home cached raw route JSON under workspaceFilesKeys.list, while
files/prefetch.ts seeds that same key from listWorkspaceFilesWithShares. The
contract declares the date fields z.coerce.date(), so consumers hold Dates —
a file record's type depended on which page the viewer landed on. Now reads
the same function files/prefetch.ts does.
- tables and knowledge folder reads now call listFoldersForWorkspace, matching
the sidebar prefetch.
These reads carry no authorization of their own, so each surface proves the
viewer through getWorkspaceHostContextForViewer first and caches nothing when
it fails, leaving the client fetch to reach the route for the real 403. Both it
and getSession are cache()d and already resolved by the layout, so the proof
costs no extra queries.
Left on the wire, deliberately: the tables and knowledge lists, whose cached
shape is the serialized wire shape, and pinned items and members, which have no
exported data-layer function.
* improvement(prefetch): skip the viewer proof when there is no session
Passing an empty-string userId ran a real permission query that could only
return null. Take an optional userId instead and skip straight to the
unauthorized path, matching how the home prefetch is called.
* perf(prefetch): finish removing self-HTTP prefetches and delete the legacy helper
Converts the last four server-render prefetches that called our own API over
HTTP, and deletes prefetch-internal-fetch.ts now that nothing imports it.
- knowledge bases: runs the route's own listInternalKnowledgeBases use case
with a principal from the same internalSessionAuth policy the route declares,
then projects through the same presenter and contract. Not a bypass of the
application boundary — the same path, called in-process.
- tables: extracts the route's list projection into lib/table/wire.ts as
toTableListItem, which the route and the prefetch now both call. This matters
because listTablesContract's response schema is a passthrough z.custom, so a
client fetch caches the route's JSON verbatim. Seeding listTables() directly
would have put Date objects and the server-only metadata field under a key the
hook never sees them on.
- pinned items: extracts the route's inline query into lib/pinned-items/queries.ts
as listPinnedItemsForUser, which the route now calls too.
- workspace members: getWorkspaceMemberProfiles already existed; the prefetch
calls it directly.
normalizeColumn moves from app/api/table/utils.ts to lib/table/wire.ts with ten
importers repointed. That also removes a pre-existing lib/* -> app/api/* boundary
violation in lib/table/import-runner.ts. No response shape changes: the v1/v2
edits are import-path moves only.
Every converted read proves the viewer first and caches nothing when that fails,
so an unauthorized viewer's client fetch still reaches the route for the real
403. Authorization equivalence was checked by unfolding both paths to
checkWorkspaceAccess rather than assumed.
* improvement(prefetch): collapse the duplicated folder prefetch and unify the call shape
- Extract prefetchResourceFolders. The same eight-line folder prefetch was
written three times, varying only by resourceType, with the key, stale time
and mapper kept in sync by hand.
- Adopting it removes the conditional spread from the tables and knowledge
prefetches. Tables can now early-return, matching prefetchFilesBrowser:
prefetchResourceListChrome already self-guards on the same cached host
context, so a null context meant the function did nothing either way.
- Take userId as string | undefined everywhere and guard inside, so every
prefetch module has one calling convention rather than two.
- Export toWireTimestamp and use it for the create-table response's own copy of
the same idiom, and drop a cast that the extraction made dead: the parameter
is already TableDefinition, whose schema is TableSchema.
- Read params and the session concurrently on the tables and knowledge pages,
matching the files page, and drop TSDoc that restated each prefetch's own.
* fix(prefetch): keep the tables list on its route and cut the executor edge
Reading listTables from a page prefetch put the executable tool registry into
the Tables page server graph — ~4,700 modules, which check:tool-registry-boundary
rejects. lib/table/service reaches workflow-columns by several independent
paths (directly, and through jobs/service and rows/service), so severing one
edge is not enough; untangling that belongs in its own change.
- The tables list goes back through GET /api/table, with the reason recorded so
the next person does not repeat the attempt. Folders and chrome on that page
stay on the data layer.
- stripGroupDeps moves to its own leaf module. It is a pure projection over a
WorkflowGroup, but living beside the group runtime meant every importer of
lib/table/service paid for the executor to get it.
Net effect on the Tables page graph: 2,186 modules to 1,742.
* perf(prefetch): finish the migration, delete the legacy helper, ratchet page graphs
Answers the question the previous commit left open: the tables list did not have
to stay on HTTP. lib/table/service reached the executor through
jobs/service -> rows/service -> workflow-columns, for one symbol.
pendingDeleteMask is a delete-visibility SQL clause with no executor
involvement, so it moves to its own leaf and that chain is cut. The tables
prefetch now reads the data layer like every other one, and
prefetch-internal-fetch.ts is deleted: nothing in the app calls its own API over
HTTP during a server render any more.
stripGroupDeps likewise moves to a leaf rather than being re-exported through
workflow-columns, so its importers no longer pull the executor to get a pure
projection.
React Query mechanism fixes, all found by audit:
- settings/[section] fired two prefetches without awaiting them. Only a settled
query is dehydrated, so those were shipped mid-flight; a rejection hydrated
into an error state retryOnMount: false never retries, leaving the panel
broken for the session. Awaited now, and the pending-dehydration opt-in is
removed since nothing streams.
- The viewer profile was prefetched by both the layout and the settings page.
Separate server QueryClients mean that was a real second read per request.
- prefetchSubscriptionData was dead, and hand-rolled an unannotated raw fetch.
- retry is scoped to the browser. Query core defaults it to 0 on the server;
stating one value for both opted awaited prefetches into a retry backoff. The
gcTime default is dropped entirely — 5 minutes is already the browser default,
and setting it explicitly overrode the server's Infinity, leaving a live timer
and payload per request.
check:tool-registry-boundary now also ratchets per-page module counts against a
committed baseline, attributing a regression to the import that caused it via a
dominator tree. It caught a +444 regression in this branch by hand; it would
have caught it in CI. Its import regex also missed bare side-effect imports,
so `import '@/tools/registry'` could have slipped past it entirely.
Prefetch guidance added to .claude/rules/sim-queries.md.
* fix(prefetch): correct the extracted module's db imports and stale rationale
Audit findings from the migration.
- pending-delete-mask imported its schema tables from @sim/db rather than
@sim/db/schema, which the module it came from was careful to split. The
global test mocks are bound per-entrypoint and only the schema mock exports
tables, so every suite that reaches pendingDeleteMask would have failed on a
missing mock export. Restored to the original convention, and the same split
applied to the new pinned-items queries module before it grows a test.
- The settings prefetch and page justified awaiting with a mechanism this
branch removed — pending queries being shipped with their promise. Only a
settled query is dehydrated now, so an unawaited prefetch is dropped from the
payload entirely. Same conclusion, correct reason, and no longer contradicting
the rule this branch added.
- Removed the doc block left orphaned above validateSchema when stripGroupDeps
moved out of workflow-columns.
Skill projections regenerated after trimming the boundary skill.
* chore(table): drop a section separator comment
Separators like these are non-TSDoc decoration that CLAUDE.md already rules
out. This is the only one in a file this branch touches; the rest of the repo
is swept separately.
* chore: remove section separator comments
CLAUDE.md already rules these out ("No ==== separators. No non-TSDoc
comments"), but 546 of them had accumulated across 48 files. They decorate
rather than explain, and they drift: a separator says "Validation" while the
code beneath it moved elsewhere, as one in workflow-columns already had.
Pure deletion — no source line was touched, and lines inside template
literals were skipped so nothing in a generated string changed.
|
||
|
|
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> |