Skip to content

v0.7.27: document tags improvements, grok 4.5, forking, custom blocks, library, typescript 7, jupyter notebook, o11y#5531

Open
waleedlatif1 wants to merge 27 commits into
mainfrom
staging
Open

v0.7.27: document tags improvements, grok 4.5, forking, custom blocks, library, typescript 7, jupyter notebook, o11y#5531
waleedlatif1 wants to merge 27 commits into
mainfrom
staging

Conversation

@waleedlatif1

@waleedlatif1 waleedlatif1 commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

TheodoreSpeaks and others added 25 commits July 7, 2026 22:37
…#5495)

* feat(pii): add opt-in GLiNER NER engine (PII_ENGINE), device-agnostic

Swap the 4 NER entity types (PERSON/LOCATION/NRP/DATE_TIME) to a single
multilingual GLiNER zero-shot model when PII_ENGINE=gliner; spaCy stays the
default and all ~36 regex/checksum recognizers are identical on both engines.
Device-agnostic via PII_DEVICE / cuda auto-detect — same code on Fargate CPU
now and EC2-GPU later.

- engines.py: side-effect-free builders; SharedModelGLiNERRecognizer loads
  ONE model shared across the 5 per-language instances and restricts labels
  to the entities it owns; small spaCy models keep tokenization/lemmas for
  the regex recognizers; fail-fast on the lean image
- pii.Dockerfile: multi-stage — default target unchanged (lean spaCy);
  --target gliner is a superset (torch CPU + gliner + baked model) where
  both engines work; gliner-gpu scaffold for the GPU fleet
- CI publishes the gliner variant (:staging-gliner/:latest-gliner, amd64)
- Helm: pii.engine / pii.device values wired to PII_ENGINE/PII_DEVICE
- scripts/bench_engines.py: throughput + NER-parity diff harness
- tests: unit (mocked GLiNER) + in-image integration for both engines

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

* refactor(pii): ship both engines in one image — engine is a pure env flip

Collapse the gliner build target into the single pii image: spaCy lg models,
torch (CPU), gliner, and the baked GLiNER weights all ship in it, so
PII_ENGINE switches engines with no image swap and no tag matrix. CI reverts
to the single pii build (no -gliner tags). The GPU variant becomes the same
Dockerfile built with --build-arg TORCH_INDEX_URL=.../cu128. Image grows
~6.1GB -> ~9.6GB.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(tables): remove tables-fractional-ordering feature flag

* improvement(tables): drop dead position tracking from paste undo path

* improvement(tables): drop dead position from create-row undo records
…166/#167) (#5508)

Patches a high-severity stored XSS in the oidc-provider and mcp plugins
via javascript:/data: redirect_uri schemes. Also bumps @better-auth/sso
and @better-auth/stripe to matching 1.6.13 peers. Patch-only release
(1.6.11 -> 1.6.12 -> 1.6.13), no breaking changes in either changelog.
* improvement(knowledge): open document tags from row context menu

The document row context menu had a Tags entry that only navigated to the document detail page, requiring another click through the breadcrumb dropdown to actually edit tags. It now opens the tag editor directly, and shows even when the document has no tags yet so a first tag can be added.

* fix(knowledge): gate document tags menu item on edit permission

Matches the existing disableRename/disableDelete/disableToggleEnabled pattern; the document detail breadcrumb already hides its Tags entry for non-editors the same way.

* fix(knowledge): derive tags modal document data from live list cache

The modal was fed a frozen document snapshot taken at right-click time. After a save, the mutation only invalidates the single-document and KB-detail queries (not the documents list query), so the modal's own sync effect rebuilt tags from the stale snapshot and could revert or drop the just-saved value. Track only the document id and look it up from the same documents array updateDocument() patches, so the modal always sees current data.
* refactor(utils): consolidate duplicated helpers onto @sim/utils

Replaces ~90 hand-rolled reimplementations of error-message extraction,
postgres error-code checks, sleep, Math.random, retry/backoff, object
filtering/omission, noop, string truncation, date/time formatting, email
normalization, and plain-object type guards with the shared @sim/utils
exports. Wires check:utils into CI (test-build.yml) so these patterns
don't regress.

* fix(test): mock @sim/utils/random instead of Math.random in schedule-execute tests

The schedules/execute route previously used Math.random() for jitter delay;
this consolidation PR switched it to randomInt() from @sim/utils/random,
which is backed by crypto.getRandomValues() rather than Math.random(). The
route.test.ts spies on Math.random() no longer had any effect, so jitter
became real random delay instead of the deterministic 0ms the tests expect,
causing intermittent 10s timeouts in CI.

* fix(retry): preserve uncapped Retry-After comparison in tools/index.ts

parseRetryAfter() caps its return value at 30s by default. tools/index.ts
compares the parsed Retry-After against a caller-configured maxDelayMs to
decide whether to skip a retry entirely -- capping before that comparison
silently defeats the skip check whenever maxDelayMs is configured above
30s, since a Retry-After between 30s and maxDelayMs would incorrectly look
"within limits" and get retried instead of skipped (caught by Cursor
Bugbot). Added an optional maxMs param (default unchanged) so tools/index.ts
can request the raw, uncapped value for its own comparison while
backoffWithJitter still clamps the actual sleep duration to maxDelayMs.
Added a regression test covering maxDelayMs > 30s.

* fix(utils): fall back to Intl-resolved abbreviation for unmapped timezones

getTimezoneAbbreviation only covered 9 hardcoded IANA zones and returned
the raw IANA string for everything else, degrading schedule descriptions
for zones like Europe/Berlin or America/Toronto (caught by Greptile). The
deleted local implementation in schedules/utils.ts resolved any valid IANA
timezone generically via Intl.DateTimeFormat's short timeZoneName. Restore
that as a fallback so only genuinely invalid timezone strings return
themselves unchanged.
* feat(providers): add xAI grok-4.5 model

* fix(providers): correct grok-4.5 release date to API-availability date
…es (#5513)

* fix(comparison): correct stale and unsourced claims on comparison pages

- Remove stale "Templates" references from the LangChain page and
  related blog posts (feature was removed platform-wide)
- Re-verify every sourced claim across all comparison-page profiles
  (Sim's own facts and all 20 competitor profiles) against live
  sources; fix dead links, renamed products, outdated figures, and
  claims no longer supported by their citation
- Fix a stale sidebar permissions reference in the enterprise
  access-control docs

* fix(comparison): use consistent URL format for n8n AI Workflow Builder citation
…, public apis, external mcp servers/tools, special subblocks (#5505)

* bad checkpoint

* stash

* add unlink, move UI to settings pages

* fix edge cases

* remove dead code

* remove migration 0255 ahead of staging merge (regenerated after)

* regenerate workflow_mcp_server enum migration on top of staging (0257)

* fix tests

* more tests

* address comments

* consolidate fork migrations into 0257 (enum value + activity metadata indexes)

* acquire MCP server locks before reads in attachment reconcile (TOCTOU)

* move into ee folder + ui perm gates

* use randomInt from @sim/utils/random for chat identifier suffix

---------

Co-authored-by: Siddharth Ganesan <siddharthganesan@gmail.com>
…agement (#5507)

* fix(settings): let workspace admins reach the Custom blocks settings page

The Custom blocks page lives in the Enterprise nav section, which the
sidebar filter hides unless the user is an org admin/owner. But the
server authz for managing custom blocks is source-workspace admin
(hasWorkspaceAdminAccess), not org admin — so a workspace admin who
could manage a block via the API couldn't even reach the page.

Add an `allowNonOrgAdmin` nav flag that exempts an item from the org
admin/owner requirement (the plan/hosted entitlement still applies; the
page enforces its own per-resource authz), and set it on custom-blocks.
Other Enterprise items (access control, audit logs, SSO, data
retention/drains, whitelabeling) stay org-admin-only.

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

* feat(custom-blocks): any org member views; create gated on workspace admin

Split visibility from management to match the org-wide nature of custom
blocks:
- View: any org member (the nav item is now plan-gated, not org-admin).
- Create: the "Create block" action shows only when the user is admin of
  a workspace in the current org, matching the publish route's
  source-workspace admin authz.
- Source picker: lists only workspaces the user administers (in the
  current org); the default selection snaps to an eligible workspace when
  the current one isn't one they can publish from.

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

* feat(custom-blocks): read-only detail for non-managers + fill image icon

- Add the source workspaceId to the block wire type and gate the detail
  view on it: a viewer who isn't an admin of the block's source workspace
  gets a read-only view — no Save/Discard, no Delete, all fields disabled.
  Matches the server authz (edit/delete require source-workspace admin),
  so the UI no longer dangles buttons that 403.
- SettingsResourceRow: add an opt-in `iconFill` so uploaded image icons
  fill the tile edge-to-edge instead of clamping to 20px; glyph svgs still
  normalize to 20px. Custom blocks list opts in.

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

* fix(custom-blocks): include workspaceId in publishCustomBlock return

The publish path builds a CustomBlockWithInputs literal; it was missing
the workspaceId field added to the interface, breaking the type check.

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

* fix(custom-blocks): don't flag create as dirty when source auto-snaps

The create dirty-check compared selectedWorkspaceId against the URL
workspace, but the source picker now auto-snaps to the first workspace
the user can publish from — which may differ. Compare against that
eligible default so opening/discarding the create flow doesn't show a
false unsaved-changes state. (Bugbot)

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
)

* refactor(blog): split AEO/GEO content into a new /library section

Blog was mixing hand-written editorial posts with listicle/comparison/how-to
posts optimized for answer engines. Extract a generic content engine
(lib/content) shared by both sections, move the 6 AEO/GEO posts into a new
/library route tree, and keep /blog editorial-only.

- lib/content: generic registry factory, MDX components, SEO builders
- lib/blog + lib/library: thin per-section instantiations over the shared engine
- app/(landing)/library: mirrors the blog route tree via shared Content*Page components
- app/sitemap.ts, app/robots.ts, navbar: updated for the new section
- next.config.ts: permanent redirects from moved /blog/<slug> URLs to /library/<slug>

* fix(blog): thread route id through buildAuthorMetadata for canonical URL

Author pages for unknown/unmatched ids fell back to author?.id, which is
undefined when no post matches — collapsing canonical/OG URLs to
{basePath}/authors/ instead of {basePath}/authors/{id}. Pass the route id
explicitly so canonical always resolves correctly.

* fix(blog): address Greptile review — RSS lastBuildDate, shared author cache, dead-export docs

- RSS lastBuildDate now uses updated ?? date so edits after publication are
  reflected, matching the per-item pubDate semantics (blog + library)
- Author JSON is now cached once per authorsDir at module scope instead of
  once per registry instantiation, since blog and library point at the same
  directory
- Documented getNavPosts as reserved-but-unwired, matching the existing
  PLATFORM_MENU/SOLUTIONS_MENU convention in the navbar

* fix(blog): dedupe featured posts from ContentIndexPage pagination pool

A featured post older than POSTS_PER_PAGE would appear both on page 1's
featured row (sorted to the front) and again on its natural date-sorted
page, since only page 1 excluded featured posts from the remaining list.
Carve featured posts out of the paginated pool up front so pagination stays
consistent and duplicate-free across all pages.

* fix(blog): RSS lastBuildDate reflects the whole feed, not just the newest item

lastBuildDate only looked at items[0]'s updated/date, so revising an older
post already in the feed wouldn't advance it. Extract the max-across-posts
logic sitemap.ts already had into a shared lib/content/utils helper
(latestModified) and use it in both RSS routes and the sitemap, so a
revision to any feed item is reflected.
…stored title (#5501)

* fix(workflow): render agent tool chip names from static sources, not stored title

Tool entries in an agent block's inputs carry a mutable `title` in workflow
state, which copilot edits (edit_workflow) could rewrite to change what the
UI displays. Derive chip names from static/canonical sources instead: the
block registry name for integration tools (raw type id if unregistered),
the custom-tool record over the stored snapshot, the live MCP tool name,
and a static literal for workflow-as-tool. The stored `title` still persists
in state; the UI just no longer reads it for registry-backed tools.

Covers both render paths: the expanded tool-input chips and the collapsed
canvas summary (resolveToolsLabel, also used by workflow preview).

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

* fix(workflow): cover workflow-as-tool and custom-tool fallbacks in static naming

Address review findings: resolveToolsLabel now returns the static
'Workflow' label for workflow/workflow_input entries instead of falling
through to the stored title, matching the panel chip; the panel's
custom-tool name priority now matches resolveToolsLabel (record title,
record schema function name, then stored title); and the display-name
comment no longer overstates the guarantee.

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

* fix(workflow): resolve panel chip names via unfiltered block registry

The panel resolved integration tool names through toolBlocks, which is
permission- and toolbar-filtered, so a stored tool whose type was
filtered out of the picker fell back to the raw type id while the
canvas summary (getBlock) still showed the registry name. Use getBlock
directly for the display name so both paths agree; toolBlocks still
drives the chip icon, params, and picker.

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

* fix(workflow): resolve MCP tool names from live server data in canvas summaries

resolveToolsLabel preferred the stored title for mcp entries, so canvas
and preview summaries could show a state-edited name while the config
panel showed the live MCP tool name. Accept an optional mcpTools list
(matched by toolId, same as the panel) and pass the already-fetched MCP
tool data from workflow-block; the stored title remains the fallback
while server data is unavailable.

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

* refactor(workflow): single shared tool-name resolver; fix overlay, fallback, and search regressions

Consolidate the per-surface name logic into one resolveStoredToolName in
display.ts (canonical source -> stored title -> raw type id) used by the
panel chips, the canvas summary, and the search indexer, fixing the
regressions the per-renderer patches introduced:

- custom-block tools (async registry overlay) no longer render raw
  custom_block_<uuid> ids pre-hydration or after deletion; the canvas
  memo and panel picker now subscribe to the overlay version so labels
  recompute when custom blocks hydrate
- unresolvable block types fall back to the stored title before the raw
  type id, instead of always showing the id
- the search indexer now indexes the resolved display name (the title
  entry was already read-only), so search text and highlights match what
  the chips render instead of the mutable stored title
- redundant hardcoded 'Workflow' labels removed (registry names cover
  workflow/workflow_input); MCP lookups use a shared Map keyed by
  composite tool id instead of per-entry array scans; panel chip chrome
  falls back to the unfiltered registry for picker-hidden types

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

* fix(workflow): thread custom-tool and MCP data into the search indexer; fix blockConfigs type

The search index now resolves tool names with the same data as the chip
renderers: the single caller passes customTools and a live MCP name map
into indexWorkflowSearchMatches, so indexed names match the panel for
custom-tool references and MCP entries, not just registry-backed types.
Also declare the optional name on WorkflowSearchIndexerOptions
blockConfigs entries, fixing the next build type error.

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

* fix(workflow): recompute search index when the custom-block overlay hydrates

The matches memo resolves tool names through getBlock, so it must carry
the overlay version dep like the other getBlock-derived memos; without
it, find-in-workflow could index stale custom-block names until an
unrelated dep changed.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* docs(enterprise): add Custom Blocks page

Document publishing a deployed workflow as a reusable org-wide block:
publishing flow, common uses, using a block, managing, and self-hosted
setup, with UI screenshots. Register in the enterprise sidebar and wire
the settings docsLink.

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

* docs(enterprise): drop explicit Enterprise framing, note access control

The page's Enterprise placement is implicit, so remove the "Enterprise
feature" callout and plan mentions. Add that custom blocks can be
allowlisted per permission group via Access Control.

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

* docs(enterprise): remove Self-hosted setup section from Custom Blocks

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tural bugs (#5517)

* fix(rich-markdown-editor): don't split a list when deleting an empty item

Backspacing an emptied list item in the middle of a list used ProseMirror's default lift, which pulls
the item out into a top-level paragraph — splitting one list into two and stranding an empty paragraph
(a visible gap). Backspace at the start of an empty list item now joins into the previous block instead,
removing the item and keeping a single list. Covers bullet, ordered, and task lists.

* fix(rich-markdown-editor): generalize empty-wrapped-block boundary keys

Replace the joinBackward empty-list-item Backspace branch with a walk-up-and-delete
that removes the whole emptied wrapper, and extend it to task items and blockquotes.
joinBackward left a stray empty paragraph inside the previous item (flat lists) or
no-op'd entirely on nested items (leaving them stuck); blockquotes were never handled
and split in two. Add an Enter handler so an empty non-trailing list/task item is
removed rather than split into two lists around a stranded, non-round-trippable empty
paragraph; a trailing empty item still exits the list via the default.

Covers bullet/ordered/task/blockquote at every position; non-empty items and the
double-Enter list-exit are unaffected.

* fix(rich-markdown-editor): isolate verbatim block nodes from boundary joins

footnoteDef and rawHtmlBlock hold exact source text but were neither isolating nor
atom, so a single Backspace/Delete at their boundary let ProseMirror's default join
merge their raw markdown into an adjacent paragraph as HTML-escaped prose — silently
destroying the node and corrupting saved markdown. Mark the block variants isolating
so boundary keys can't cross their edge. Round-trip and in-place editing are unchanged.

* fix(rich-markdown-editor): escape leading block markers in paragraph serialization

A paragraph beginning with #, -, +, 1., 1), or a bare --- serialized unescaped, so it
silently re-parsed into a heading / list / thematic break on the next load (reachable
via type-a-marker then undo). The upstream serializer escapes inline delimiters (* _ `
[ ] ~, so * bullets and > quotes already round-trip) but not these block-starting
markers. BlockSafeParagraph wraps the paragraph renderer with a leading-marker guard;
escaping is idempotent (parsing consumes the backslash) and never over-escapes non-markers
like #hashtag or -5.

* fix(rich-markdown-editor): strip leading paragraph indent; keep empty paragraphs &nbsp;-free

A paragraph beginning with a 4-space/tab indent re-parsed as an indented code block on the
next load. Leading whitespace never renders in a paragraph (CommonMark strips up to three
leading spaces; four or more become code), so BlockSafeParagraph now strips it — lossless
and idempotent. Composes with the existing leading-marker escaping (e.g. '   # x' → '\# x').

Also locks in that consecutive empty paragraphs serialize via blank lines rather than the
upstream '&nbsp;' marker: replacing StarterKit's Paragraph already dropped the &nbsp; path,
and the round-trip preserves the empty-paragraph count without tripping the read-only
safety gate (which flags &nbsp; as a stable-loss pattern). Added tests for both.
…hook errors (#5518)

* fix(ashby): repair broken idempotency dedup and clarify duplicate-webhook errors

extractIdempotencyId looked for a webhookActionId field that does not
exist anywhere in Ashby's webhook payload schema (confirmed against the
live OpenAPI spec), so every delivery — including Ashby's own retries —
got a fresh random dedup key and could re-execute workflows. Derive the
key from the affected resource's id plus its updatedAt/decidedAt instead.

Also surface a clear, actionable message when Ashby's webhook.create
rejects a request as a duplicate (seen repeatedly in production logs,
where the outbox retried the same failing subscription for ~23 minutes
before dead-lettering) instead of the generic API error passthrough.

Also add the interview stage `type` field to trigger outputs (present
in Ashby's schema, useful for a stage-change trigger) and fix the
employmentType description to match Ashby's actual enum values.

* fix(ashby): correct offer status enum values in trigger output descriptions

acceptanceStatus listed a non-existent "WaitingOnResponse" value and
offerStatus was missing "WaitingOnApprovalDefinition" — both caught on
a second pass re-checking every enum against Ashby's live OpenAPI spec.

* fix(ashby): harden idempotency key against Greptile-flagged collision risks

- Return null (skip dedup) when application.updatedAt is absent instead
  of collapsing to an empty string, which could collide two distinct
  events sharing an application id onto the same key.
- Drop decidedAt from the offerCreate key. It's populated only after
  the fact, so including it gave a retry of the same delivery a
  different key than the original attempt once the candidate
  responded, defeating dedup. offer.id alone is already stable and
  unique per created offer.

* fix(ashby): fall back to a content fingerprint instead of skipping dedup

Returning null when application.updatedAt was missing avoided false
collisions between distinct events, but also disabled retry dedup
entirely for that payload shape — an Ashby retry would get a random
key from the idempotency service's own fallback and re-run the
workflow.

Extract the fallback-fingerprint helper (sha256 of a stably-serialized
payload) out of salesforce.ts into the shared providers/utils.ts, and
use it in ashby.ts: identical retried bytes hash identically (dedup
still works), while two genuinely different events hash differently
(no false collision).

* chore(ashby): remove inline comments, let names carry the intent

* fix(ashby): fingerprint the full data payload, not just application

Hashing only data.application missed other fields (e.g. offer on
candidateHire) when updatedAt is absent, so two deliveries sharing an
application snapshot but differing elsewhere in data could collide
onto the same idempotency key.

* fix(ashby): rename output field 'type' to 'stageType' to fix build

TriggerOutput reserves the 'type' key for the output's own JSON type
(e.g. 'string'), so a nested field literally named 'type' inside
currentInterviewStage collided with that meta-field and broke the
Record<string, TriggerOutput> cast — passing local type-check (which
turbo was silently serving a stale cached pass for) but failing
Next.js's build-time type check in CI. Renamed to stageType, matching
the existing eventType-style convention used elsewhere for API fields
literally called 'type'.

* fix(ashby): rename currentInterviewStage.type to stageType in delivered data too

Renaming the field in the output schema alone (to fix the TriggerOutput
build collision) left a mismatch: Ashby's real payload still has
currentInterviewStage.type, so a picker/expression using the schema's
declared stageType would resolve to undefined at runtime. formatInput
now renames the field in the actual delivered payload so it matches
what the schema declares.

* fix(ashby): fold offer id into the idempotency key when present

When application.updatedAt is present, the key ignored sibling data
fields — a candidateHire delivery carries both application and offer,
so two deliveries sharing an application snapshot could collide even
though they concern different offers. Append offer.id to the
discriminator when present; it's the only sibling object Ashby's
schema ever pairs with application.
- New "customBlocks" row in the platform section: whether a platform
  lets a builder publish a deployed workflow as a reusable, encapsulated
  block for org-wide reuse (Sim's new Custom Blocks feature)
- Researched and sourced an accurate answer for all 20 competitors,
  distinguishing real equivalents from look-alikes (sub-workflow
  composition, custom code nodes, connector wrappers, templates)
* docs(forking): workspace forking

* update comparison links
* chore(typescript): upgrade to TypeScript 7 (native Go compiler)

Bumps typescript to ^7.0.2 across every workspace package. Full
bun run type-check/lint/build/test all pass; apps/sim's type-check
(the one needing an 8GB heap bump) drops from ~55s to ~7s wall time.

Migration fixes required by TS7's stricter defaults:
- baseUrl removed: drop it from 5 tsconfigs (paths already resolved
  relative to tsconfig dir, so behavior is unchanged) and prefix the
  one bare (non-relative) paths entry each in apps/sim and
  apps/realtime with './'
- moduleResolution=node10 removed: switch packages/cli and
  packages/ts-sdk to "bundler", matching the rest of the monorepo
- types now defaults to [] instead of auto-including every @types/*
  package: add "types": ["node"] to the shared base tsconfig (this
  is fundamentally a Node monorepo, so this restores prior behavior
  in one place instead of duplicating it per-package), add explicit
  @types/node deps to packages that now rely on it transitively via
  @sim/db/@sim/logger, and add "declare module '*.css'" to the two
  packages with plain (non-module) CSS side-effect imports that
  TS7's stricter checker now flags
- packages/logger's isomorphic `typeof window` check no longer needs
  DOM lib in every consumer: replaced with `'window' in globalThis`
- packages/testing and apps/realtime's fetch/DOM mocks need DOM lib
  where they're compiled, since they model the browser Fetch API
- the `typescript` npm package no longer exports the classic
  Compiler API from its main entry (moved to unstable/ast subpaths);
  apps/sim's Function-block route used it at runtime to strip
  import statements from user code, so that one call site now uses
  Microsoft's official transition package, @typescript/typescript6
- Next.js 16.2.6's own TypeScript-detection heuristic hardcodes a
  path TS7 no longer ships, and its auto-install fallback assumes
  npm/pnpm; added @typescript/native-preview as a devDependency to
  apps/sim and apps/docs so Next detects a valid native compiler
  instead of trying (and failing) to auto-install one

Not merging yet: TS 7.0.2 published today and is still inside this
repo's bunfig.toml minimumReleaseAge (7-day) supply-chain gate, so
`bun install` will fail for everyone until 2026-07-15. Opening this
now to get it through review; hold the actual merge until then.

* fix(typescript): address Greptile review findings on TS7 upgrade

- packages/logger: 'window' in globalThis treats a shim that leaves
  globalThis.window explicitly undefined as browser-only, silently
  dropping production server logs. Restore the original
  typeof !== 'undefined' semantics via an inline cast instead, so it
  stays correct without requiring DOM lib in every consumer.
- packages/ts-sdk, packages/cli: both are tsc-built, published as
  Node ESM (package.json "type": "module" with an "exports" map).
  "moduleResolution": "bundler" is too permissive for that target -
  it accepts import patterns (e.g. extensionless relative imports)
  that Node's actual ESM resolver rejects at runtime. Switch both to
  "module"/"moduleResolution": "nodenext", the correct pairing for a
  published Node ESM package. Verified real tsc builds (not just
  --noEmit) still succeed for both.

* chore(bunfig): temporarily disable minimumReleaseAge gate for TS7 install

TS 7.0.2 published today, still inside the 7-day gate. Lowering to 0
to unblock this merge; will restore to 604800 in an immediate follow-up
commit right after merging.
…ng triggers (#5524)

* fix(instantly): add idempotency dedup for webhook deliveries

Instantly webhook deliveries had no extractIdempotencyId, so retried
deliveries were never deduped. Instantly doesn't send a delivery-id
header, so key on email_id when present (unique per email/reply) and
fall back to a content-based key (event_type + campaign_id + lead_email
+ event timestamp) otherwise. Added instantly.test.ts covering auth,
event matching, formatInput/output alignment, idempotency, and the
subscription lifecycle.

* fix(gitlab): add delivery idempotency and fix reserved type-field collision risk

extractIdempotencyId was missing entirely, so GitLab's automatic webhook
retries (triggered after timeouts/5xx, up to 4 consecutive failures before
temporary disable) would re-execute the workflow as a brand-new event.
Added a content-derived fallback key (checkout_sha for push, object
id + updated_at otherwise) since GitLab's X-Gitlab-Event-UUID delivery
header isn't available to extractIdempotencyId (body-only).

Also renamed the Issue Hook payload's object_attributes.type (GitLab
17.2+ work item type) to work_item_type in formatInput and exposed it
in buildGitLabIssueOutputs, since 'type' collides with the TriggerOutput
meta-key. Added detailed_merge_status to the merge request outputs to
match the deprecation of merge_status.

Added apps/sim/lib/webhooks/providers/gitlab.test.ts covering verifyAuth,
matchEvent, formatInput, and extractIdempotencyId.

* fix(gmail): correct labels output type and add formatInput/schema alignment test

The gmail_poller trigger declared its `labels` output as type: 'string'
while the description and actual runtime value (email.labelIds) are a
string array. Changed to type: 'json' to match the shape actually
delivered, consistent with how other triggers type array-of-string
fields.

Added apps/sim/lib/webhooks/providers/gmail.test.ts covering
gmailHandler.formatInput passthrough behavior and a regression check
that every key formatInput can deliver on `email` matches a key
declared in the trigger's output schema.

* fix(linear): tighten replay window, dedupe HMAC verify, add idempotency fallback

- Reuse the shared createHmacVerifier helper for Linear's signature check
  instead of hand-rolling header/secret validation.
- Tighten the webhookTimestamp replay-protection skew from 5 minutes to 60
  seconds, matching Linear's documented recommendation.
- Add extractIdempotencyId as a content-based fallback (type:action:id:
  updatedAt) for when the Linear-Delivery header is unavailable.
- Remove unused teamOutputs/stateOutputs dead code from triggers/linear/utils.ts.

* fix(gitlab): preserve raw object_attributes.type alongside work_item_type

formatInput previously spread the entire raw body, so object_attributes.type
(GitLab 17.2+ work item type) was already reachable via a manual expression
even though undocumented. The earlier fix renamed it to work_item_type in
the delivered data too, which would silently break any pre-existing
workflow referencing the raw path. Keep both keys in the delivered data —
this is plain passthrough data, not schema-constrained, so there's no
reserved-key collision risk in doing so; only the declared *schema* needs
the work_item_type name.

* fix(instantly): differentiate idempotency key by timestamp when email_id repeats

Instantly's is_first field ("whether this is the first event of this
type for the lead") only makes sense if the same event_type can fire
more than once for the same email_id: every open of the same email,
every click, and every reply in a thread all share one email_id
(Instantly's reply_to_uuid). Keying idempotency on email_id alone
collapsed all of those distinct, legitimate deliveries into a single
dedup slot, so every open/click/reply after the first was silently
dropped for the 7-day idempotency TTL. Append timestamp (fixed per
event occurrence, confirmed not to regenerate on retry) to keep the
key both retry-stable and unique per occurrence.

* fix(linear): revert replay-window tightening — retry timestamp semantics unverified

The prior pass tightened LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS from 5 minutes to
Linear's suggested 60 seconds, justified by the claim that webhookTimestamp
is re-stamped fresh on every delivery/retry attempt. That claim is not
stated anywhere in Linear's webhook docs (verified against the raw docs
page directly, including the exact wording around retries and the
webhookTimestamp field) — the docs only say it is "the time when the
webhook was sent" with no mention of retry behavior. A third-party
implementation guide explicitly recommends a 5-minute window instead of
Linear's literal 60s suggestion, citing this same ambiguity.

If webhookTimestamp is actually fixed to the original event time (not
refreshed per attempt), a strict 60s window would silently and permanently
reject every one of Linear's own 1hr/6hr retry deliveries following any
transient outage on our side, since Linear gives up after 3 failed
attempts. Idempotency dedup (Linear-Delivery header / extractIdempotencyId
fallback) already prevents double-processing of replayed/retried
deliveries within a wider window, so the extra replay-protection from
matching the literal 60s suggestion is marginal next to the risk of
dropping real business events. Reverting to the 5-minute window pending
explicit confirmation from Linear on retry timestamp semantics.

* fix(gitlab): include ref in push idempotency key to avoid branch-deletion collisions

GitLab sets checkout_sha to null on branch/tag deletion, so the
extractIdempotencyId fallback (checkout_sha || after) degenerated to the
all-zeros SHA for every deletion. Without ref in the key, two unrelated
branch deletions in the same project produced the same idempotency key,
silently dropping the second (and any subsequent) legitimate trigger
execution as a false duplicate.

* fix(gmail): use 'array' not 'json' for poller labels output type

The prior pass changed the gmail_poller trigger's `labels` output from
'string' to 'json', which is directionally correct (labelIds is a
string array, not a string) but not the precise type. The codebase's
own PrimitiveValueType union has a dedicated 'array' variant, used by
sibling triggers with identical semantics (github issue/PR triggers,
Jira triggers all declare their `labels` field as type: 'array').

Concretely, 'json' vs 'array' diverges in generateMockValue
(apps/sim/lib/workflows/triggers/trigger-utils.ts), which backs the
auto-generated "Event Payload Example" sample payload shown in the
trigger config UI (apps/sim/triggers/index.ts, gated on
trigger.id.includes('poller') — gmail_poller qualifies). 'json' mocks
as a single generic object ({id, name, status}), actively misleading
for a field that is always an array. 'array' mocks as a list, matching
the true shape (Gmail API docs confirm labelIds is a string array on
the Message resource).

No behavioral difference in output-path validation/resolution
(isPathInSchema, generateOutputPaths) since the field declares no
items/properties either way — this is purely a type-declaration
correctness fix, verified against Gmail API docs and cross-checked
against every other output field in poller.ts (all match runtime
shape).

* docs(triggers): correct GitLab retry claim, convert inline comments to TSDoc

GitLab does not automatically retry failed webhook deliveries — a failed
request only counts toward auto-disabling the webhook (4 consecutive
failures = temporary disable, 40 = permanent), and re-delivery only
happens via a manual "Resend Request" (UI or API), which carries the
same webhook-id/Idempotency-Key/X-Gitlab-Event-UUID headers as the
original delivery. Those headers are already in the shared idempotency
service's allowlist and checked ahead of extractIdempotencyId, so the
body-based fallback matters for the rare case those headers are
stripped in transit — not for "automatic retries" as previously stated.
Verified directly against docs.gitlab.com.

Also replaced inline `//` comments across the gitlab/gmail/linear/
instantly provider and trigger files with TSDoc blocks on the relevant
declaration where the reasoning was non-obvious, or removed them
outright where they only restated adjacent code.

* fix(triggers): close two Greptile-flagged crash/collision gaps

instantly: the timestamp-present branch was fixed for the email_id
collision risk, but the no-timestamp fallback still keyed on bare
email_id — same collision risk, unfixed. Return null instead so dedup
is skipped rather than risking a false collision between repeat opens/
clicks/replies on the same email.

linear: extractIdempotencyId cast `body` straight to a Record without
checking it was actually an object first, so a JSON `null` body (no
Linear-Delivery header available) would throw on `b.type` and turn a
malformed payload into a webhook 500 instead of a clean skip.

* fix(linear): guard matchEvent and formatInput against a null body

Found during a final backwards-compat pass, same class of bug Greptile
just caught in extractIdempotencyId: both cast body straight to a
Record without checking it was actually an object, so a genuinely
null/malformed body would throw instead of degrading gracefully.
gitlab.ts (asRecord's `|| {}`) and instantly.ts (isRecordLike checks)
already guard this everywhere; linear.ts was the one inconsistent
provider. Uses the same isRecordLike helper as instantly.ts.

* fix(gitlab): distinguish pipeline lifecycle transitions in idempotency key

Pipeline Hook payloads have no updated_at field (confirmed against
docs.gitlab.com — only Issue/Merge Request/Note hooks reliably include
it), so every lifecycle transition of the same pipeline (pending ->
running -> success/failed) collapsed onto the identical fallback key
and later real transitions were skipped as duplicates. Falls back to
status + finished_at/created_at when updated_at is absent.
… pages (#5522)

* fix(landing): fix Core Web Vitals regressions across public marketing pages

- root layout unconditionally rendered next-runtime-env's PublicEnvScript,
  which calls unstable_noStore() and silently forced every route in the app
  dynamic - marketing pages never got static/ISR caching despite their own
  revalidate. Gated it to self-hosted only; hosted now uses a static,
  build-time equivalent (app/_shell/public-env-script.tsx)
- removed real pointer-drag handlers from the hero's decorative workflow
  animation (was draggable despite being aria-hidden)
- disabled dragging/panning on the (currently unmounted) landing-preview
  ReactFlow canvas so it's static-by-default if it's ever wired in
- lazy-mount the Product Demo section's duplicate HeroVisual instance via
  next/dynamic + IntersectionObserver instead of loading it eagerly below
  the fold
- disabled Next.js Link prefetch on always-in-viewport /signup and /login
  CTAs (navbar, hero, mobile nav) so their JS isn't fetched on every
  pageview regardless of whether the visitor clicks
- removed `unoptimized` from local blog/integration images (including the
  priority LCP image on every blog post), letting next/image serve
  resized AVIF/WebP instead of full-size originals

* fix(landing): address review findings on the CWV PR

- revert prefetch={false} on below-fold CTAs (cta.tsx, enterprise.tsx) -
  contradicts the prefetch-on-approach rule this PR itself documents
- restore unoptimized on avatarUrl and the MDX body-image renderer, both of
  which can legitimately hold external URLs outside next.config.ts's
  image remotePatterns allow-list
- simplify handleAnchors to a single block argument now that positions are
  static (the second "live position" argument was always identical to the
  first after the drag-handler removal)
- extract the near-duplicate IntersectionObserver lazy-mount logic shared
  by landing-preview-mount.tsx and product-demo-visual-mount.tsx into a
  single apps/sim/app/(landing)/hooks/use-lazy-mount.ts hook
- import next-runtime-env's own exported PUBLIC_ENV_KEY constant instead of
  a hardcoded string literal, and match its case-insensitive NEXT_PUBLIC_
  filter exactly, removing any drift risk between the two implementations
- drop plain inline comments with no TSDoc home in favor of relying on the
  existing TSDoc/CLAUDE.md documentation

* fix(landing): carry the unoptimized-image fix onto the shared content-*-page components

The blog/library split (#5516) moved the blog post/index/author JSX into
shared ContentPostPage/ContentIndexPage/ContentAuthorPage components while
this branch was in flight, so the original unoptimized removal (verified
local-only ogImage paths for both blog and library content) needs to land
on those shared components instead of the old per-route JSX.

* fix(landing): document ogImage's local-path expectation, fix CLAUDE.md structure doc

- add a one-line comment on ContentFrontmatterSchema.ogImage documenting
  that it's rendered without unoptimized and expects a local path, matching
  the existing avatarUrl comment convention (a reviewer noted ogImage's
  schema is technically unconstrained and seo.ts has an http-prefix branch,
  though all current content is local)
- add the new hooks/ folder to the (landing) CLAUDE.md structure diagram and
  name use-lazy-mount.ts directly in the lazy-mount rule

* fix(landing): escape </script> breakout in the static public-env script

Greptile P1: a NEXT_PUBLIC_* value containing "</script>" would close the
inline script early and could inject markup/script into every hosted page.
Escape "<" in the serialized JSON before interpolating it, matching the
standard JSON-in-script-tag safeguard.
* feat(chat): replace thinking indicator with gooey loader

Promotes ThinkingLoader from the landing route group to the shared
components/ui barrel and swaps it in for the 4-square color loader
in the chat's PendingTagIndicator. Removes the now-dead
animate-thinking-block keyframe/animation.

* fix(chat): keep a stable Thinking… label instead of cycling phase text

Greptile flagged that phase text repeatedly re-announces to assistive tech
during a single pending stream; a static label avoids the repeated
live-region updates while keeping the gooey shape morph.
…nstead (#5528)

PR #5522 removed `unoptimized` from local blog/library/integration cover
images, expecting next/image's runtime optimizer to serve resized
AVIF/WebP. On staging, images broke entirely on /blog - the runtime
optimizer is failing there (root cause still under investigation:
_next/image requests aren't captured by the app's structured logger, so
the underlying error wasn't visible in application logs). Reverting
`unoptimized` immediately restores working images.

In its place, this compresses the actual source files (mozjpeg quality 82
for JPEGs, palette PNG for PNGs) at their EXACT existing pixel dimensions -
verified programmatically per-file (dimension mismatch aborts the write)
and spot-checked visually. This gets the same bandwidth/LCP win the
runtime optimizer was meant to provide, without depending on it:

- 13 blog/library cover images + 3 author avatars + 1 brand logo
- ~1.72MB -> ~1.02MB combined (~41% smaller), zero resolution change
… API docs (#5530)

* fix(slack): guard webhook handlers against null/non-object bodies

handleSlackChallenge, extractIdempotencyId, and formatInput cast the
webhook body to Record<string, unknown> without checking for null first.
handleProviderChallenges runs Slack's handleChallenge unconditionally on
every webhook path before the webhook row is looked up, so a POST with a
literal JSON `null` body crashed with a TypeError instead of degrading
gracefully, unlike sibling providers (e.g. monday.ts) that already guard
this case. Switch all three to isRecordLike, matching the pattern used by
other providers.

* fix(stripe): guard extractIdempotencyId against non-object bodies

Bring the Stripe webhook provider in line with the isRecordLike
convention used by other providers (linear, sendblue, instantly) so a
null/non-object body degrades gracefully instead of throwing on
property access. In practice the only caller already guards against
non-object bodies, so this is defense-in-depth, not a live crash fix.

Adds a colocated stripe.test.ts covering signature verification
(valid/invalid/wrong-secret), event-type filtering, formatInput
pass-through, and extractIdempotencyId including the null-body case.

* fix(github): fix workflow_run event matching and formatInput/output-schema mismatch

isGitHubEventMatch's eventMap was missing an entry for github_workflow_run,
so unknown-trigger fallthrough caused that trigger to fire on every GitHub
event type, not just workflow_run.

The provider's formatInput did a raw passthrough of the webhook body, but
the trigger output schemas rename GitHub's reserved `type` field (a
TriggerOutput meta-key) to `user_type`/`owner_type`, and `description` to
`repo_description` for the repository object. Since formatInput never
performed those renames, the declared output fields never matched real
delivered data. Added alias renaming (keeping the raw keys for back-compat,
matching the existing GitLab work_item_type precedent) plus null-body
guards in formatInput/matchEvent and a content-based extractIdempotencyId
fallback.

* fix(jira): dedupe trigger-type dropdown, guard against null webhook bodies

- All 15 Jira trigger files hand-rolled their own selectedTriggerId
  dropdown subBlock instead of using the shared buildTriggerSubBlocks
  helper. Since blocks/blocks/jira.ts merges every trigger's subBlocks
  into one array, the Jira block ended up with 15 duplicate,
  unconditional selectedTriggerId dropdowns. Refactored every trigger to
  use buildTriggerSubBlocks, with includeDropdown only on the primary
  jira_issue_created trigger (matches the jsm sibling module's pattern).
- Removed the dead, unused fieldFilters subBlock on issue_updated (never
  read by matchEvent/formatInput and has no analog in Jira's own webhook
  admin UI, unlike jqlFilter).
- Guarded all `body as Record<string, unknown>` casts in the provider
  handler (matchEvent, formatInput, extractIdempotencyId) and in
  triggers/jira/utils.ts's extract* helpers with isRecordLike, so a
  malformed/scalar JSON body (e.g. literal `null`) degrades gracefully
  instead of throwing.
- jira_webhook (generic, all-events) trigger's output schema was missing
  sprint/project/version keys that its formatInput branch already
  returns, and duplicated comment/worklog shapes that drifted from the
  shared builders (e.g. comment.body typed as plain string instead of
  ADF json). Now composed from the same buildXOutputs() helpers used by
  the dedicated triggers.

Verified against live Atlassian webhook docs: X-Hub-Signature HMAC
signing and the X-Atlassian-Webhook-Identifier header (stable across
retries) are both real and already correctly implemented/allowlisted;
no changes needed there.

* fix(salesforce): correct setup instructions for Flow HTTP Callout auth

Salesforce Flow's HTTP Callout action requires a Named Credential
(and an External Credential for auth headers) pointing at the target
URL — it cannot call an arbitrary URL with inline headers as the
previous instructions implied. Update both the generic and per-event
setup instructions to walk through creating the External/Named
Credential first, and drop the inaccurate "connectivity checks" claim.

* fix(hubspot): cap advanced filters to stay within HubSpot's per-group limit

HubSpot's Search API rejects any filterGroup with more than 6 filters.
buildUserFilters combines pipeline/stage/owner shortcuts with user-supplied
JSON filters and spread them into both filter groups uncapped; Group B
reserves 2 slots for the timestamp/id tie-break, so as few as 3 shortcuts
plus 2 advanced filters silently broke every poll with an opaque 400 from
HubSpot. Cap combined filters at 4 and warn when truncating.

Added hubspot.test.ts covering buildUserFilters (shortcuts, JSON parsing,
invalid-operator drop, malformed JSON, and the new cap) since the file had
no prior test coverage.

* test(zendesk): add handler tests for webhook trigger signature/idempotency/format-input

Audited the Zendesk webhook trigger (signature verification, event
matching, output-schema mapping, idempotency) against live Zendesk
webhook docs and the repo's trigger conventions. No bugs found — the
existing implementation already correctly uses base64 HMAC-SHA256 over
timestamp+body (not hex), safeCompare, fail-closed auth, the native
event-subscription payload shape (not the admin-configurable
Trigger/Automation payload), and null-safe body handling. Added
colocated tests to lock in this behavior, matching the gitlab/linear
test pattern.

* fix(microsoft-teams): persist subscription expiration, close auth/idempotency gaps

- createSubscription never wrote subscriptionExpiration into providerConfig,
  so the renewal cron's `if (!expirationStr) continue` guard permanently
  skipped every Teams chat subscription — they silently expired after
  ~3 days (Graph's chatMessage max lifetime) and were never renewed.
  Persist it on both initial creation and the existing-subscription reuse
  path.
- verifyAuth only checked HMAC when providerConfig.hmacSecret happened to
  be present, silently accepting unauthenticated requests for outgoing
  webhook triggers if it was ever missing. Fail closed instead.
- extractIdempotencyId/enrichHeaders only handled Graph notification
  payloads (the `value` array shape), so outgoing webhook channel messages
  never got a stable idempotency key and fell back to a random one on
  every delivery. Extend to key off the Bot Framework Activity `id`, and
  guard the parsing with isRecordLike instead of an unchecked cast.
- formatInput declared channelData.teamsTeamId/teamsChannelId in the
  trigger's output schema but never populated them (Teams doesn't send
  those as literal wire keys). Compute them from channelData.team.id /
  channelData.channel.id.
- The block's triggers.available list only listed microsoftteams_webhook,
  hiding the chat subscription trigger from Copilot's block metadata tool
  and other consumers of that list. Add it, matching the Jira/Linear
  pattern for multi-trigger blocks.

* fix(sentry): guard webhook handler against null/non-object body

matchEvent cast body to Record<string, unknown> without a null check,
so a validly-signed webhook delivery with a null (or non-object) JSON
body threw inside the shared webhook loop, aborting processing for
any other webhooks sharing the same path. formatInput/extractIdempotencyId
already tolerated null via `|| {}`/optional chaining but didn't match
the isRecordLike convention used by sibling providers (linear, sendblue,
instantly). Aligned all three methods on isRecordLike and added
regression tests for a null body.

* fix(twilio): guard SMS and Voice webhook handlers against non-object body

matchEvent, extractIdempotencyId, and formatInput cast body directly to
Record<string, unknown> without checking it was actually an object, so a
malformed or non-form-encoded request (e.g. JSON body "null") would throw
instead of degrading gracefully. Use isRecordLike, matching the pattern
already used in linear.ts/sendblue.ts/instantly.ts.

* chore(slack): convert inline rationale comments to TSDoc

Adversarial re-audit of the null-body-guard fix: no correctness issues
found, backwards compatibility confirmed strictly additive. Per repo
convention, converts non-TSDoc inline // comments in slack.ts to TSDoc
blocks on the relevant declarations (formatSlackInteractive,
extractIdempotencyId, formatInput) and drops two low-value inline
comments in slack.test.ts that just restated adjacent assertions.

* test(github): add negative alias test, remove extraneous inline comments

Adversarial re-audit of the workflow_run/formatInput fix: eventMap now
covers all 11 GitHub trigger IDs (verified exhaustively against every
file in triggers/github/), and withGitHubUserTypeAliases only augments
objects shaped like a GitHub user (login+type strings) rather than
renaming every `type` key in the tree. Added a test proving an
unrelated nested `type` field (e.g. an issue label) is left untouched.
Removed inline // comments that only restated the code; folded the
two genuinely non-obvious GitHub semantics (issue_comment firing for
both issues and PRs, closed vs merged pull requests) into the
function's TSDoc.

* chore(jira): drop extraneous inline comments from second-pass re-audit

Removed self-evident line comments in isJiraEventMatch() and the
jira_webhook output-schema composition — the code (variable/function
names) already says what these restated.

* fix(salesforce): add missing permission-set and async-path steps to Flow setup instructions

Re-verified the Named Credential setup instructions against live Salesforce
docs. Two required steps were still missing: (1) the Flow's running user
(Automated Process user for record-triggered flows) needs a permission set
granting External Credential Principal Access, or the callout fails auth
even with a correctly configured Named Credential; (2) record-triggered
flows can only perform callouts on the Run Asynchronously path.

* fix(hubspot): fail loudly instead of silently truncating over-limit filters

Filters within a HubSpot Search API filterGroup are AND-combined, so
silently dropping the last N filters when the combined shortcut +
advanced-filter count exceeded MAX_USER_FILTERS widened the match set
instead of narrowing it — a poll could start matching records the
user's config meant to exclude, firing the workflow on unintended
records with no visible error. That's worse than the original
hard-400 bug, which was at least loud. buildUserFilters now throws
when the combined count exceeds the limit, which pollWebhook's
existing catch turns into a visible markWebhookFailed, matching how
every other misconfiguration in this file (missing objectType,
invalid eventType, corrupt watermark) is already handled.

Re-derived the cap arithmetic against HubSpot's live docs
(developers.hubspot.com/docs/api/crm/search): max 6 filters per
filterGroup, 5 groups, 18 total. Group B reserves 2 slots (filterProperty
EQ + hs_object_id GT tie-break), so MAX_USER_FILTERS = 4 is exact — not
off by one in either direction.

* fix(zendesk): validate subdomain as a bare hostname label

createSubscription/deleteSubscription interpolate the user-supplied
subdomain directly into the Zendesk API URL (`https://${subdomain}.zendesk.com`).
An unvalidated value containing a '/' (e.g. "evil.example.com/x") escapes
the host portion of the URL, redirecting the request — and its Basic-auth
admin credentials — to an attacker-controlled host. Unlike the equivalent
pattern in apps/sim/tools/zendesk (invoked only when a user explicitly runs
a block with their own credentials), this fires automatically on deploy and
undeploy using admin-scoped API tokens, making it the more acute instance of
the pattern. Reject anything but letters, digits, and internal hyphens.

Also drops a few restated-in-code inline comments per repo convention.

* fix(triggers): restore jira fieldFilters as a real feature, fix twilio-voice status collision

jira: the second-pass audit removed the `fieldFilters` subBlock on
issue_updated as "dead code, never read" — true, but that meant the
feature was already non-functional before removal (a UI control
promising field-level filtering that did nothing), and removing it
silently dropped an already-visible control from the merged block UI
(flagged independently by both Greptile and Cursor). Rather than
either reinstating a broken control or leaving it removed, implement
the feature for real: matchEvent now checks the comma-separated field
list against Jira's changelog.items[].field on issue_updated
deliveries, matching Jira's actual webhook payload shape.

twilio-voice: extractIdempotencyId keyed on CallSid alone, so every
status callback for a call (ringing/in-progress/completed/etc) shared
one idempotency key and only the first was ever processed — later
CallStatus transitions were silently dropped as duplicates. Fixed to
include CallStatus in the key, matching the SMS handler's existing
SID:status pattern. Also converted an inline rationale comment in the
SMS handler to TSDoc for consistency with the rest of this cleanup.

* fix(twilio-voice): key idempotency on all callback discriminators, not just status

CallStatus/RecordingStatus/TranscriptionStatus share overlapping values
(e.g. 'completed'), and Gather turns / recording events fan out multiple
distinct deliveries under one CallSid while CallStatus itself stays
unchanged. Build the key from field=value pairs across every known
discriminator (CallStatus, Digits, SpeechResult, RecordingSid,
RecordingStatus, TranscriptionSid, TranscriptionStatus) so callback kinds
can never collide on a shared value, while identical retries still dedupe.

* chore(github-trigger): remove extraneous inline comment

Round 3 re-audit: verified push event handling (commits[].author/
committer, head_commit, pusher) is correctly excluded from the
GitHub-user type-alias walk since those objects lack login+type,
so no output-schema drift. No functional issues found. Removed a
leftover inline // comment in the generic webhook trigger's output
schema per repo comment conventions.

* fix(stripe-trigger): add missing 2025 event types to eventTypes allowlist

invoice.payment_attempt_required and balance_settings.updated shipped in
Stripe's 2025-10-29 API update but were missing from the trigger's
curated eventTypes dropdown despite their categories (Invoices, Balance)
already being represented.

* fix(salesforce-trigger): document required Allow Formulas in HTTP Header checkbox

Setup instructions told admins to add a Named Credential custom header
using a $Credential formula, but never mentioned that the "Allow Formulas
in HTTP Header" checkbox must be checked when adding that header.
Left unchecked, Salesforce sends the literal "{!\$Credential...}" text
instead of evaluating it, so the shared-secret auth silently fails.

* fix(slack): skip block_suggestion payloads instead of wastefully executing workflows

block_suggestion (external select option loading) requires Slack to receive a
synchronous JSON options response within 3 seconds, which this trigger's
async fire-and-forget webhook execution model can never provide. It was
previously routed through the generic interactivity handler like
block_actions/shortcut/view_submission, meaning every keystroke in an
external-select typeahead would silently trigger a full (useless) workflow
execution. Now explicitly skipped via the existing skip mechanism.

* fix(microsoftteams): recreate Teams subscription when renewal PATCH 404s

If every renewal attempt in a subscription's 48h renewal window failed
(revoked consent, prolonged Graph outage), the subscription actually
expired on Microsoft's side and the cron kept PATCHing a deleted
subscription forever, always 404ing, with the webhook silently dead.
Now a 404/410 from the renewal PATCH triggers a POST to recreate the
subscription from the stored chatId, closing the same "never comes
back" failure mode the original bug had, for the narrower case where
renewal itself keeps failing. Also dedupes getCredentialOwner onto the
shared provider-subscription-utils helper instead of a local copy, and
drops a couple of restated-in-code comments.
…5527)

* feat(jupyter): add Jupyter integration (contents, kernels, sessions)

- 16 tools covering Contents, Kernels, Kernelspecs, and Sessions REST APIs
- File upload/download via UserFile, following the Box upload pattern
- Block with operation dropdown, token auth, and 8 catalog templates
- Registered tools + block, generated docs, bumped API validation baseline

* fix(jupyter): address Greptile review — SSRF guard, upload path ambiguity, silent notebook fallback

- Route uploads through validateUrlWithDNS + secureFetchWithPinnedIP (matches Grafana/1Password pattern) instead of a raw fetch to the user-supplied server URL
- Replace the upload path/trailing-slash heuristic with an unambiguous directory + filename split
- create_file no longer silently writes an empty notebook when notebook content is malformed JSON — it now errors clearly

* fix(jupyter): request content=1 when listing directory contents

Without it, Jupyter Server returns directory metadata with content:
null, so jupyter_list_contents always reported an empty items array.

* fix(jupyter): reject path-traversal segments in Jupyter content paths

encodeJupyterPath now rejects '.'/'..' segments across the whole path
(shared by all 16 tools, not just upload); the upload route returns a
clean 400 when it's hit.

* fix(jupyter): close remaining path-traversal and redirect-credential gaps

- extract the traversal check out of encodeJupyterPath into a shared
  assertion, and apply it to body-only path fields (rename newPath,
  copy copyFromPath, session path) that never flowed through URL
  encoding and so skipped the check
- pass stripAuthOnRedirect to the upload route's secureFetchWithPinnedIP
  call so a malicious Jupyter server can't redirect the PUT to another
  origin and receive the caller's token

* fix(jupyter): also reject percent-encoded traversal segments

A segment like %2e%2e wouldn't match the literal '..' check. Now
decodes each segment before comparing, in addition to the literal
check, so an already-encoded traversal attempt is caught too.

* fix(jupyter): route all 15 remaining tools through an internal proxy for HTTP/private-host support and no redirects

The generic external tool executor blocks plain-HTTP and non-localhost
private-IP hosts by default, so every non-upload Jupyter operation
could fail against typical self-hosted setups (LAN IP, docker
hostname, or even literal localhost on a hosted deployment) even
though the upload route worked via its own internal route.

Added /api/tools/jupyter/proxy (DNS-pinned, allowHttp, maxRedirects: 0)
that mirrors the upstream Jupyter response verbatim, matching the
established pattern for self-hosted-arbitrary-host integrations
(Grafana, 1Password) instead of the generic executor path. Each tool's
request block now posts to the proxy instead of building a direct
external URL; transformResponse and outputs are unchanged since the
proxy response mirrors upstream status/body exactly.

Also switches the upload route from stripAuthOnRedirect to
maxRedirects: 0 — stronger, since it stops the uploaded file body
(not just the token) from ever reaching a redirect target.

* fix(jupyter): validate proxy path at the trust boundary, reject path separators in upload filename

- The proxy route now independently validates the incoming path field
  for traversal segments instead of only relying on tool-side
  validation before the request reaches it — the route is a shared
  internal boundary, not something only our own tool code can call
- The upload route's fileName can come from an advanced override or
  the legacy fileContent path and could itself contain '/' or '\',
  silently nesting the upload deeper than the directory param
  specified. Now rejected outright before joining.

* fix(jupyter): decode the whole path before splitting, not per-already-split segment

A segment like foo%2f..%2fsecret has no literal slash, so splitting on
literal '/' first and decoding each piece in isolation treats it as
one opaque segment and never notices the '..' hiding behind the
encoded slash. Decode the full path once, then split and check every
segment the target server's own single URL-decode pass would see.
…ng (#5529)

* chore(ship-skill): check branch is synced with origin/staging before pushing

Prevents PRs from silently picking up extraneous commits when a
branch/worktree is cut from a stale or diverged local staging/main.

* feat(babysit): add skill to drive a PR through review to a clean 5/5

Ships (via /ship's sync check), waits for Greptile/Cursor Bugbot,
triages every open thread (fix real findings, push back on false
positives), replies + resolves each thread, re-triggers review, and
loops until Greptile is 5/5 with zero open threads.

* chore(cursor-commands): mirror sync-check into ship.md, add babysit.md

Keeps the Cursor /commands mirrors of ship and babysit in sync with
the Claude Code .agents/skills versions so they don't drift.

* fix(ship-skill): compare commit content not count, handle WIP before rebase, avoid fixed temp branch name

- Final verify now diffs actual commit subjects between git log and
  the PR, not just a count — a corrupted branch's inflated count could
  coincidentally match a later count check
- The pre-commit count in step 2 was being compared against the
  post-commit PR in step 9, which would mismatch even on success; now
  compares content at the point it's actually available
- Recovery flow stashes uncommitted work before rebasing so it isn't
  blocked by a dirty tree, and checks for an existing tmp branch
  before reusing the name

* fix(ship-skill,babysit): clean rebase can still hide drift, fixed temp-branch name blocks recovery

- A clean git rebase can replay stray commits with zero conflicts, so
  'rebase succeeded' was being treated as sufficient — now the log is
  always re-checked after rebasing, clean or not, before trusting it
- The recovery text told the agent to 'pick another name' if
  ship-sync-tmp was taken but then hardcoded that literal name anyway;
  now it just deletes the leftover (disposable, single-purpose) branch
  instead of introducing a naming scheme
- babysit's step 5 pointed at re-running the log check alone; it now
  points at the full /ship step 2 recovery flow, since a review loop
  spanning a long session is exactly where drift compounds silently

* fix(ship-skill,babysit): guard tmp-branch delete, fetch before final verify, paginate threads, verify after every push

- ship-sync-tmp deletion is now conditional on the branch existing —
  a bare 'git branch -D' on a first recovery attempt with no leftover
  would fail and block the rebuild before it started
- step 9's final commit-content verify now fetches origin/staging
  first, matching step 2; a stale local ref could mask real drift
- babysit's reviewThreads query now checks pageInfo.hasNextPage and
  pages through all threads instead of assuming 50 is always enough
- babysit now re-runs the sync-content verify after every push, not
  just before it, so a bad push or a PR that drifted mid-loop doesn't
  go unnoticed across review rounds

* fix(ship-skill,babysit): fix reversed commit-list order, tail-1 grabbing wrong line, stale step reference

- git log --oneline is newest-first, gh pr view commits is oldest-first
  — the two content-verify commands now use --reverse so a positional
  comparison doesn't spuriously fail on any multi-commit branch
- 'select(...) | .body | tail -1' pipes every matching comment's full
  body through the pipeline and keeps only the last LINE of the
  combined output (the review-count footer), not the last COMMENT —
  demonstrated this myself this session reading the footer instead of
  the actual score. Now uses '[.comments[]] | last | .body'
- Cursor's babysit.md pointed at '/ship step 9', but Cursor's ship.md
  only has 7 steps (no cleanup/migration steps) — now points at step 7
  and cross-references the 9-step Claude Code skill copy

* fix(ship-skill): checkout before deleting ship-sync-tmp, cherry-pick oldest-first

- git refuses to delete the branch currently checked out, so if an
  earlier interrupted recovery left the agent sitting on ship-sync-tmp,
  the conditional delete would find the branch and then fail to remove
  it, blocking the rest of the rebuild. Now checks out the original
  branch first (a no-op if already there).
- git log's default order is newest-first; cherry-picking session SHAs
  in that displayed order applies the newest commit before older ones,
  which can fail or produce the wrong history with 2+ session commits.
  Now explicit: cherry-pick oldest-first, using --reverse to get them
  in that order directly.

* fix(ship-skill): capture cherry-pick SHAs before switching to the temp branch, make tmp delete idempotent

- Step 4 of the rebuild checks out ship-sync-tmp at origin/staging, so
  HEAD no longer contains the session's commits by the time the old
  step 4 (now step 5) ran 'origin/staging..HEAD' to find them — that
  range was always empty by then, following the steps literally would
  cherry-pick nothing. Now captures the SHA list first, against the
  original branch name explicitly, before any branch switch.
- The show-ref-guarded delete still exits 1 (git show-ref's own
  failure) on the common first-attempt case where there's no leftover
  branch, which could halt a caller that stops on nonzero exit before
  ever reaching the rebuild. Replaced with a plain delete + || true,
  which always succeeds whether or not there was anything to delete.

* fix(ship-skill,babysit): rebuild was replaying the exact stray commits it was meant to leave behind

- The 'capture the range' step from the previous round's fix captured
  origin/staging..<original-branch> wholesale — but in exactly the
  scenario that triggers this rebuild, that range also contains the
  unrecognized/stray commits, so cherry-picking 'all of it' recreated
  the same polluted branch. Now explicit: read the log, write down
  only the SHA(s) you recognize as your own, never the whole range.
- babysit's pre-push gate list named lint/typecheck/boundary-validation
  but dropped ship's conditional /cleanup and /db-migrate steps, which
  a review-fix round can trip just as easily as the original commit.
- babysit's force-with-lease condition only covered the cherry-pick
  rebuild path, but a plain 'git rebase origin/staging' that completes
  with no conflicts also rewrites already-published history and needs
  the same force push.

* fix(ship-skill): push step needs force-with-lease too, not just babysit's

Step 2's sync recovery rewrites already-pushed commits on a branch
that's been pushed before, but the push step only said 'push using
the current branch name' with no force-with-lease — a plain push
would be rejected in exactly the polluted-remote case step 2 exists
to fix. babysit already got this fix; ship's own push step didn't.
@waleedlatif1 waleedlatif1 requested a review from a team as a code owner July 9, 2026 02:48
@greptile-apps

greptile-apps Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Too many files changed for review. (578 files found, 100 file limit)

@vercel

vercel Bot commented Jul 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jul 9, 2026 6:24am

Request Review

@cursor

cursor Bot commented Jul 9, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Large cross-cutting release touching enterprise forking/custom blocks, user-supplied Jupyter proxying, PII engine selection, auth-adjacent trigger dedup, and a TypeScript 7 compiler upgrade—any regression affects shipping, security, or CI broadly.

Overview
v0.7.27 bundles enterprise workflow tooling, new integrations, PII/CI hardening, and a TypeScript 7 migration across the monorepo.

Enterprise & product: Workspace forking (linked child workspaces, push/pull of deployed workflows, mappings for credentials/secrets/resources) and custom blocks (publish deployed workflows as org-wide reusable blocks) ship with new docs and comparison-page facts. Landing blog routes are refactored onto shared Content* components so AEO/GEO /library can reuse the same content engine.

Integrations & providers: New Jupyter workflow integration (contents, kernels, sessions) with docs/icons and DNS-pinned server-side proxy/upload routes for user-supplied Jupyter URLs. xAI Grok 4.5 is added as a provider; knowledge document tags open from the row context menu.

PII service: Presidio NER is split into engines.py with an opt-in PII_ENGINE=gliner path (shared GLiNER model, same regex recognizers), plus bench/tests and Docker extras.

Quality & DX: TypeScript 7 (including docs/realtime tsconfig tweaks). /ship and new /babysit agent skills/commands add origin/staging sync, cherry-pick recovery, and Greptile/Cursor review loops. CI adds bun run check:utils; login/resume use shared @sim/utils helpers (normalizeEmail, formatDateTime). Trigger idempotency dedup fixes (Ashby + other webhooks/polling), rich-markdown editor structural fixes, landing CWV work, and assorted comparison/docs updates round out the release.

Reviewed by Cursor Bugbot for commit 472457b. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 472457b. Configure here.

Comment thread apps/pii/tests/test_engines.py
Comment thread apps/sim/lib/content/registry-factory.ts Dismissed
Comment thread apps/sim/lib/content/seo.ts Dismissed
* improvement(forking): minor ui improvements

* fix warning
…coverage (#5269)

* feat(observability): extend audit log, PostHog, and storage metering coverage

Instruments previously-uncaptured resources and actions across the three
observability layers (audit log, PostHog product analytics, usage metering):

- Exfiltration audit: file downloads (workspace/public-share/v1), table,
  workflow, and workspace exports.
- Credential access audited at the token-issuance boundary (success-only).
- Full revenue trail: invoice paid/failed, overage billed, disputes, credit
  fulfillment, subscription lifecycle, plan/seat changes.
- Session/login lifecycle audit via Better Auth hooks (login, blocked sign-in,
  logout, session revoke, account delete).
- v1/admin programmatic surface and copilot tool handlers instrumented.
- Dead audit/PostHog constants wired (lock/unlock, table, custom tool, etc.).
- Storage metering extended to KB documents and copilot files.
- PostHog person identify + workspace/organization group hygiene.

Audit package hardened to null the actor FK for system actors (admin-api) and
adds an awaitable recordAuditNow for pre-delete hooks. All instrumentation is
fire-and-forget and never blocks or breaks the primary operation.

* fix(observability): audit file/credential egress only on success

Address Cursor Bugbot review:
- GET OAuth token: emit CREDENTIAL_ACCESSED/credential_used after
  refreshTokenIfNeeded succeeds (matches POST), not before.
- File export: audit on each actual success exit via a shared helper —
  including the non-markdown serve redirect (previously unaudited) and
  after the zip is generated (previously before asset fetch).

* fix(audit): record null actor for anonymous public-share downloads

Address Greptile P1: setting actorId to the file owner made every anonymous
external download read as a self-download, undermining the exfiltration trail.
recordAudit now accepts a null actor; the public content/inline routes record
actorId=null with the owner in metadata.sharedByUserId and rely on ip/user-agent
for the forensic trail. The misleading owner-attributed file_downloaded PostHog
event is dropped on these anonymous paths.

* fix(observability): audit admin exports after zip; tidy comments

- Admin workflow/workspace ZIP exports now audit only after the archive is
  built (via a local helper), so a zip/build failure no longer logs an export.
- Remove redundant 'success-only placement' inline comments and tighten the
  remaining design-rationale notes (export helper now TSDoc).

* fix(billing): complete the chargeback + overage financial trail

Address Cursor review:
- handleDisputeClosed now records CHARGE_DISPUTE_CLOSED for every closed
  dispute (won/lost/warning_closed), unblocking only on favorable outcomes.
  dispute.status in the metadata distinguishes the outcome, so lost
  chargebacks are no longer missing from the trail.
- Threshold overage now emits OVERAGE_BILLED + overage_billed even when credits
  fully cover the overage (settledVia: 'credits' vs 'stripe'), so credit-settled
  overages are audited instead of silently returning null.

* fix(storage): decrement copilot quota before deleting metadata

Address Greptile P1: deleting the metadata row before the decrement meant a
decrement failure left the quota permanently inflated with no record to retry
from. Decrement first; only remove the metadata row once it succeeds.

* fix(observability): atomic copilot storage release; signup-blocked action

- Copilot file delete now releases storage via a single transaction
  (releaseDeletedFileStorage): the soft-delete is the idempotency claim and the
  decrement shares the transaction, so neither a partial failure (inflated
  counter) nor a retry (double-decrement) can desync the quota. Resolves the
  conflicting Greptile/Cursor ordering findings.
- Policy-blocked sign-ups now record USER_SIGNUP_BLOCKED instead of
  USER_SIGNIN_BLOCKED, so account-lifecycle events aren't mislabeled.

* fix(storage): meter copilot ingest centrally for path symmetry

Address Cursor review: only uploadCopilotFile incremented storage, but copilot
files also enter via the generic upload route and presigned uploads — all of
which persist metadata through insertFileMetadata. Move the increment into
insertFileMetadata (scoped to context='copilot', on genuine insert/restore) so
every ingest path is symmetric with the delete-time decrement, and drop the now
redundant per-path increment in uploadCopilotFile. Other contexts are metered by
their own managers and remain unaffected.

* fix(audit): skip WORKFLOW_EXPORTED when admin export is empty

Address Cursor review: when every requested workflow fails to load, the admin
export still returned 200 but recorded a successful export of zero workflows.
Guard auditExport so an empty result records nothing.

* fix(storage): settle copilot accounting before deleting the blob

Address Cursor review: the blob was removed before releaseDeletedFileStorage, so
a release failure left the counter inflated and the metadata active with the blob
gone. Now the atomic soft-delete + decrement runs first and the blob is deleted
only if it succeeds, so a failure leaves the file fully intact and retryable.

* fix(storage): decrement KB document storage atomically with deletion

Address Cursor review: hardDeleteDocuments deleted the rows then decremented
best-effort, so a decrement failure left billed storage inflated with no row to
reconcile. Resolve each owner's subscription up front, then decrement inside the
same transaction that deletes the embeddings/documents (decrementStorageUsageInTx,
also now shared by releaseDeletedFileStorage), so the counter and the content
commit or roll back together. Connector docs remain excluded.

* fix(audit): don't treat email verification as a login

Address Cursor review: /verify-email could emit USER_LOGIN when verification
mints or refreshes a session, mislabeling (or double-counting) a non-sign-in as
a login. Restrict isLoginPath to genuine sign-in entrypoints.

* fix(audit): null actor FK when the user lookup throws

Address Greptile P1: the catch branch left the original actorId, so a system
actor like 'admin-api' (or a since-deleted user) would FK-violate the insert and
lose the row when the existence lookup errored. Mirror the not-found branch —
null the FK with a readable label — so the audit row always persists.

* revert(audit): drop session/account-lifecycle auth instrumentation

Remove the Better Auth login/logout/session-revoke/account-delete/blocked-signin
audit hooks from auth.ts — they touch sensitive auth paths and are noisy. Also
removes the now-unused taxonomy (USER_LOGIN/_FAILED, USER_SIGNIN_BLOCKED,
USER_SIGNUP_BLOCKED, USER_LOGOUT, SESSION_REVOKED, ACCOUNT_DELETED,
ACCOUNT_EMAIL_CHANGED actions; SESSION/USER resource types) and the awaitable
recordAuditNow helper that only the pre-delete hook used. auth.ts is back to the
staging baseline.

* fix(storage): harden copilot+KB delete accounting against read errors and concurrency

Final-audit follow-ups:
- deleteCopilotFile: a failed metadata *read* (vs a genuine missing row) now
  blocks the blob delete too, so a transient read error can't leave an active
  row un-decremented with the blob gone.
- hardDeleteDocuments: drive the per-user decrement from the delete's
  returning() (the rows this tx actually removed), so two concurrent deletes of
  the same ids can't both decrement.

* chore(observability): drop two unused definitions

Final-audit cleanup: remove the orphaned knowledge_base_searched PostHog event
(never wired; KB search analytics already flow through the OpenTelemetry channel)
and the redundant AuditAction.SUBSCRIPTION_UPDATED (subscription/plan changes are
audited via ORG_PLAN_CONVERTED). Every remaining new action/event has a real
emit site.

* fix(knowledge): key hard-delete result off rows actually deleted

Address Cursor review: hardDeleteDocuments returned existingIds.length (the
requested count) and cleaned storage for the full pre-tx set, even though the
decrement was driven by the rows the transaction actually deleted. Under a
concurrent delete that claimed some ids first, that overstated the result and
re-touched storage for rows this call didn't delete. Drive the storage cleanup,
log, and return value from deletedDocs (the returning() rows) so all four are
consistent.

* fix(storage): gate copilot quota on all ingest paths; tidy inline comments

- Copilot uploads via the generic /api/files/upload route and presigned URLs now
  run checkStorageQuota before writing (matching uploadCopilotFile), so no copilot
  ingest path can grow usage past the plan limit. The central increment stays in
  insertFileMetadata.
- Trim/remove redundant inline comments across the diff; keep only concise notes
  on non-obvious decisions (left pre-existing comments untouched).

* fix(analytics): omit empty workspace_id on workspace-less file downloads

Address Greptile P1: the generic key-based /api/files/download route and the
no-workspace markdown-export path emitted file_downloaded with workspace_id:''
creating a phantom '' bucket in PostHog. Make workspace_id optional on the event
and omit it when there is no workspace (workspace-scoped routes still pass it).

* fix(analytics): omit empty workspace_id/workflow_id on copilot_chat_sent

Final-sweep nit: copilot_chat_sent sent '' for workspace_id/workflow_id in the
agent (workspace-less) branch, same phantom-bucket issue as file_downloaded.
Make both properties optional and omit them when absent.

* fix(analytics): clear stale org PostHog group on personal workspaces

Address Cursor review: switching from a team workspace to a personal one left
the previous organization group set, so later events kept rolling up under it.
When the active workspace has no organizationId, resetGroups() to drop the stale
org group, then re-apply the workspace group.

* fix: address review — export audit timing, copilot delete signal, group reset

- Table export (Cursor MED): audit fires before streaming begins, not after
  controller.close(), so a mid-stream failure still records the partial export.
- deleteCopilotFile (Greptile P1): throw when storage accounting can't be settled
  instead of silently returning, so callers can detect the file was not deleted.
- workspace-scope-sync (Cursor MED): only resetGroups() once workspace metadata is
  loaded (activeWorkspace present), so a team workspace doesn't transiently lose
  its org group while organizationId is still null during load.

* refactor(observability): final line-justification cleanup

Address final audit flags:
- Table import: move the 'columns added' audit OUT of the import transaction
  into a post-commit auditTableColumnsAdded() helper called by the three
  tx-owning callers, so a mid-import row-batch rollback no longer logs a false
  'added N columns' (matches the PR's success-only discipline).
- Omit empty-string analytics dimensions on workflow_lock_toggled (workspace_id)
  and organization_created (name), consistent with file_downloaded/copilot_chat_sent.
- Restore an unrelated capacity-check comment removed incidentally.
- Move copilot_chat_sent emit above the traceparent comment so the comment sits
  with the code it documents.

* fix(table): attribute import column audit to the importing user

Address Cursor review: addImportColumns (async createColumns import path) now
threads the importing userId into auditTableColumnsAdded instead of falling back
to table.createdBy, so column additions are attributed to the actual member who
ran the import rather than the table creator.

* feat(observability): drop copilot from storage accounting

Per design decision: copilot files are working/conversational artifacts, so
gating their materialization on storage quota would fail an agent operation
mid-flow when a user is over limit, and metering them would inflate usage enough
to block KB/workspace uploads indirectly. Remove copilot quota gates + copilot
ingest metering entirely (revert copilot-file-manager, metadata, and the upload
route's copilot branch to baseline) and drop the now-unused releaseDeletedFileStorage.
KB document metering + quota enforcement (the deliberate, persistent storage path)
is unchanged.

* fix(analytics): switch workspace + org PostHog groups together

Address Cursor review: gate the group-sync effect on workspace metadata being
loaded (activeWorkspace present) so the workspace and organization groups always
update atomically. Acting during the load window paired the new workspace group
with the previous workspace's org group; until metadata loads, events stay
consistently attributed to the previous workspace.

* fix(table): audit async export at authorization, not job completion

Address Cursor review: async exports only emitted TABLE_EXPORTED when the
background job reached 'ready', so an authorized export whose job later failed or
was abandoned left no audit trail — inconsistent with the sync export route,
which audits before streaming. Move the audit + analytics to the async route's
authorization point (after the job is claimed/dispatched) and remove it from the
runner. Drop the now-unused userId from TableExportPayload.

* fix(billing): never let payment_failed instrumentation skip user blocking

Address Greptile P1: the payment_failed audit hoisted an unguarded
isSubscriptionOrgScoped DB read (for entity_type) directly before the
attempt-count user-blocking block. A transient failure of that read would throw
out of the handler and skip blocking. Wrap the whole audit/analytics block in
try/catch (best-effort), and let the blocking compute its own isSubscriptionOrgScoped
as it did originally — instrumentation can no longer abort payment processing.

* chore(observability): trim verbose inline comments to concise notes

* fix(billing): wrap new dispute/enterprise/subscription instrumentation in Stripe webhook idempotency

recordAudit/captureServerEvent calls added for charge disputes, enterprise
subscription provisioning, and free->paid subscription creation ran
unconditionally with no idempotency guard, unlike their sibling handlers
in the same files. Stripe redelivers webhooks at-least-once, so a retry
would double-record the audit row and PostHog event even though the
underlying DB writes were already idempotent.

* fix(observability): tag org-scoped audit metadata with organizationId, guard concurrent table delete

The org-scoped self-service audit-log endpoint matches org-level rows via
metadata.organizationId (or resourceType=organization). Six recordAudit
call sites (subscription create/cancel, admin credit issuance, threshold
overage billing, charge disputes, credit purchase, invoice payment
succeeded/failed) tagged org-scoped events with a differently-named key
(referenceId/entityId/targetOrgId), making them invisible to org admins
querying their own audit trail despite being stored in the DB. Add the
missing organizationId key everywhere the pattern was missed.

Also guard deleteTable's archive UPDATE with isNull(archivedAt) so a
concurrent duplicate delete request is a no-op instead of re-archiving
and re-firing a duplicate TABLE_DELETED audit row.

Adds test coverage for the ORG_MEMBER_ADDED audit/analytics emission in
acceptInvitation, which previously had none.

* fix(test): queue resolveBillingActorId's owner lookup in payment-failure email test

handleInvoicePaymentFailed's new payment_failed audit instrumentation
resolves the billing actor via an extra db.select before
sendPaymentFailureEmails runs. The test's fixed select-response queue
didn't account for it, so the org-admin lookup consumed the wrong
queued row and the assertion saw zero email sends. Production behavior
is unaffected — each query is independent; this was a mock-queue
ordering issue only.

* fix(billing): don't let a post-commit usage-limit sync failure suppress the seat audit

Cursor Bugbot flagged: reconcileOrganizationSeats committed the seat
change and Stripe outbox enqueue in a transaction, then called
syncSubscriptionUsageLimits outside it before recording the audit/
PostHog events. A thrown sync left a genuinely-changed seat count with
no ORG_SEAT_PROVISIONED/DEPROVISIONED trail. Wrap the sync in its own
try/catch (log + continue) so the events always fire for a committed
change, matching the fire-and-forget instrumentation pattern used
elsewhere in this PR.

* fix(billing): guard the new subscription-created instrumentation block

Greptile P1: the org-scope check I added for the audit-metadata fix
(isSubscriptionOrgScoped) was a raw DB call with no error guard, unlike
resolveSubscriptionActorId next to it. Since it ran inside the
idempotency lambda with an unconditional rethrow, a transient DB error
would abort and retry the whole webhook after the free -> paid usage
reset had already committed. Wrap the actor/org-scope resolution +
audit + analytics block in try/catch, matching the same guarded
instrumentation pattern already used in handleInvoicePaymentFailed.
@waleedlatif1 waleedlatif1 changed the title v0.7.27: document tags improvements, grok 4.5, forking, custom blocks, library, typescript 7, jupyter notebook v0.7.27: document tags improvements, grok 4.5, forking, custom blocks, library, typescript 7, jupyter notebook, o11y Jul 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants