Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
45ab0f2
fix(knowledge): simplify connector management (#7788)
waleedlatif1 Sep 11, 2026
8e2623d
fix(search): bound retrieval and progressively read document evidence…
icecrasher321 Sep 11, 2026
29c604d
fix(ci): bake the CLI telemetry token into the build that bun publish…
waleedlatif1 Sep 11, 2026
9d7557a
fix(rss): deliver unseen items published before the last poll (#7792)
TheodoreSpeaks Sep 11, 2026
1d4c004
fix(sidebar): unify navigation menus and stabilize hydration (#7791)
waleedlatif1 Sep 12, 2026
9294beb
fix(search): use compact candidates for filtered vector retrieval (#7…
icecrasher321 Sep 12, 2026
3bdc279
fix(workflows): surface workflow import failures (#7768)
j15z Sep 12, 2026
1a7c827
fix(realtime): compare search replacements independent of object key …
j15z Sep 12, 2026
2799994
fix(desktop): reopen a chat on the browser tab the user left it on (#…
waleedlatif1 Sep 13, 2026
ad11808
fix(sso): require an organization when registering an SSO provider (#…
waleedlatif1 Sep 13, 2026
f75f55a
fix(mcp): always guard the MCP transport and validate on every manage…
waleedlatif1 Sep 13, 2026
ae32a4d
feat(copilot): show model-authored tool activity (#7803)
waleedlatif1 Sep 13, 2026
14c8f36
fix(agent): keep nested tool basic/advanced modes attached to their t…
waleedlatif1 Sep 13, 2026
5e7d17a
fix(workflows): preserve durable execution across repeated human paus…
mzxchandra Sep 14, 2026
85a5938
fix(chat): stabilize inline tool activity updates (#7806)
waleedlatif1 Sep 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions .github/workflows/publish-sim-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ concurrency:

jobs:
publish-npm:
# Job-level, not on the build step: `bun publish` runs `prepublishOnly`,
# which rebuilds `dist` a second time, and that second build is the one
# that ships. A build without the token reports nothing. See
# docs/cli/usage-data.
env:
SIM_CLI_TELEMETRY_KEY: ${{ vars.SIM_CLI_TELEMETRY_KEY }}
SIM_CLI_TELEMETRY_HOST: ${{ vars.SIM_CLI_TELEMETRY_HOST }}
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 15
steps:
Expand Down Expand Up @@ -63,11 +70,6 @@ jobs:

- name: Build package
working-directory: packages/sim-cli
env:
# Public PostHog project token for anonymous CLI usage reporting; a
# build without it reports nothing. See docs/cli/usage-data.
SIM_CLI_TELEMETRY_KEY: ${{ vars.SIM_CLI_TELEMETRY_KEY }}
SIM_CLI_TELEMETRY_HOST: ${{ vars.SIM_CLI_TELEMETRY_HOST }}
run: bun run build

- name: Resolve release channel
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/docs/search/confluence.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ Central sources combine space permissions, page and ancestor restrictions, and g

Open **Settings → Sources → Confluence**, then a source's **Documents**, **Settings**, or **Sync history**. Invite teammates through **Settings → Members → Invite** or SSO, then have them connect through **Integrations**. **People → Request connections** only requests a provider connection; it does not invite people to the organization.

Syncing runs automatically. Admins can use **Sync now** for an immediate update, **Pause syncing** to stop scheduled syncs, or **Resume syncing** to restart them. **Full resync**, available for service-account connections, fetches unchanged content again and asks for confirmation. Successful manual syncs have a one-minute cooldown; failed syncs can be retried immediately.
Syncing runs automatically. Admins can use **Sync now** for an immediate update, **Pause syncing** to stop scheduled syncs, or **Resume syncing** to restart them. Successful manual syncs have a one-minute cooldown; failed syncs can be retried immediately.

## Troubleshooting

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/docs/search/connect-your-account.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ On the main **Integrations** page, select **Reconnect** beside the integration i

Admins manage setup from **Settings → Sources**. Open an integration, then its connection to see **Documents**, **Settings**, and **Sync history**. **People** shows account contributors across integrations and supports filtering by integration. This does not grant the admin access to every document.

Syncing runs automatically. Admins can use **Sync now** when they need an update; another manual run is available 60 seconds after a successful sync finishes. Failed or partial runs can be retried immediately. The connection header also offers **Pause syncing** or **Resume syncing**, and **Remove connection**. Where supported, **Full resync** fetches and reindexes all content and asks for confirmation first.
Syncing runs automatically. Admins can use **Sync now** when they need an update; another manual run is available 60 seconds after a successful sync finishes. Failed or partial runs can be retried immediately. The connection header also offers **Pause syncing** or **Resume syncing**, and **Remove connection**.

Removing a Search connection also removes its indexed documents from Sim. The originals remain in the connected app.

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/docs/search/gitlab.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ Open a project to use these administrator actions:
| **Settings** | Change the token, project, filters, or CSV permissions. |
| **Remove connection** | Confirm removal of the connection and its indexed documents. Documents cannot be retained without the connection that maintains their permissions. |

GitLab does not expose a separate **Full resync** action. Each sync checks the selected content. CSV grants change only when you replace the files.
Each sync checks the selected content. CSV grants change only when you replace the files.

## Troubleshooting

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/docs/search/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ Edit **Settings** to change an existing connection's filters. Adding another con

**Sync using** shows the method selected when the source was created. Add a new connection to change that method. To replace a supported indexing credential, select its replacement and use **Change service account** or **Change account**, as shown.

The connection header offers **Sync now**, **Pause syncing** or **Resume syncing**, **Remove connection**, and, where supported, **Full resync**. Full resync fetches all content again and requires confirmation. Manual runs have a 60-second cooldown after a successful sync finishes; failed or partial runs can be retried immediately. **Pause syncing** becomes available when the current sync finishes.
The connection header offers **Sync now**, **Pause syncing** or **Resume syncing**, and **Remove connection**. Manual runs have a 60-second cooldown after a successful sync finishes; failed or partial runs can be retried immediately. **Pause syncing** becomes available when the current sync finishes.

To deactivate an entire integration, open it from **Settings → Sources**, select **Deactivate**, and confirm. Its content becomes unavailable in Search, Assistant, and MCP; saved connections remain. Select **Activate** on that integration to enable it again.

Expand Down
171 changes: 171 additions & 0 deletions apps/realtime/src/database/operations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/** @vitest-environment node */
import { OPERATION_TARGETS, SUBBLOCK_OPERATIONS } from '@sim/realtime-protocol/constants'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockTransaction, mockSelectWhere, mockSet } = vi.hoisted(() => ({
mockTransaction: vi.fn(),
mockSelectWhere: vi.fn(),
mockSet: vi.fn(),
}))

vi.mock('@sim/audit', () => ({ AuditAction: {}, AuditResourceType: {}, recordAudit: vi.fn() }))
vi.mock('@sim/db', () => ({
instrumentPoolClient: vi.fn(),
resolveDbUrl: vi.fn(() => 'postgres://localhost/test'),
workflow: { id: 'workflow.id' },
workflowBlocks: { id: 'block.id', workflowId: 'block.workflowId' },
workflowEdges: {},
workflowSubflows: {},
}))
vi.mock('@sim/db/timestamps', () => ({ withUtcTimestamps: (options: unknown) => options }))
vi.mock('@sim/logger', () => ({
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
}))
vi.mock('@sim/platform-authz/workflow', () => ({
getActiveWorkflowContext: vi.fn().mockResolvedValue({ id: 'workflow-1' }),
}))
vi.mock('@sim/workflow-persistence/load', () => ({
loadWorkflowFromNormalizedTablesRaw: vi.fn(),
}))
vi.mock('@sim/workflow-persistence/subblocks', () => ({ mergeSubBlockValues: vi.fn() }))
vi.mock('drizzle-orm', () => ({
and: vi.fn(),
eq: vi.fn(),
inArray: vi.fn(),
isNull: vi.fn(),
or: vi.fn(),
sql: vi.fn(),
}))
vi.mock('drizzle-orm/postgres-js', () => ({ drizzle: () => ({ transaction: mockTransaction }) }))
vi.mock('postgres', () => ({ default: vi.fn() }))
vi.mock('@/env', () => ({
env: { DATABASE_URL: 'postgres://localhost/test' },
}))

import { persistWorkflowOperation } from '@/database/operations'

const transaction = {
select: () => ({ from: () => ({ where: mockSelectWhere }) }),
update: () => ({ set: mockSet }),
delete: vi.fn(),
insert: vi.fn(),
}

describe('search replacement persistence', () => {
const expected = [
{
type: 'function',
params: { language: 'javascript', code: 'return 1' },
usageControl: 'none',
},
]
const replacement = [{ ...expected[0], params: { ...expected[0].params, code: 'return 2' } }]

beforeEach(() => {
vi.clearAllMocks()
mockTransaction.mockImplementation(
async (callback: (tx: typeof transaction) => Promise<void>) => callback(transaction)
)
mockSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
})

function replaceTools(stored: unknown, expectedValue: unknown = expected) {
mockSelectWhere.mockResolvedValue([
{
id: 'agent-1',
type: 'agent',
locked: false,
data: {},
subBlocks: { tools: { id: 'tools', type: 'tool-input', value: stored } },
},
])
return persistWorkflowOperation('workflow-1', {
operation: SUBBLOCK_OPERATIONS.BATCH_UPDATE,
target: OPERATION_TARGETS.SUBBLOCK,
timestamp: Date.now(),
payload: {
updates: [{ blockId: 'agent-1', subblockId: 'tools', value: replacement, expectedValue }],
},
})
}

it('accepts equivalent nested tool objects after JSONB changes their key order', async () => {
const stored = [
{
usageControl: 'none',
params: { code: 'return 1', language: 'javascript' },
type: 'function',
},
]

await expect(replaceTools(stored)).resolves.toBeUndefined()
expect(mockSet).toHaveBeenLastCalledWith(
expect.objectContaining({
subBlocks: { tools: { id: 'tools', type: 'tool-input', value: replacement } },
})
)
})

it('still rejects a tool parameter changed by another editor', async () => {
await expect(
replaceTools([{ ...expected[0], params: { ...expected[0].params, code: 'return 3' } }])
).rejects.toThrow('changed since replacement was planned')
expect(mockSet).toHaveBeenCalledTimes(1)
})

it('still rejects reordered tool arrays', async () => {
const another = { ...expected[0], params: { ...expected[0].params, code: 'return 3' } }
await expect(replaceTools([another, expected[0]], [expected[0], another])).rejects.toThrow(
'changed since replacement was planned'
)
expect(mockSet).toHaveBeenCalledTimes(1)
})
})

describe('subblock update with canonical modes persistence', () => {
const tools = [{ type: 'jira', params: { manualProjectId: '{{PROJECT}}' } }]
const canonicalModes = { '0:projectId': 'advanced' as const, model: 'basic' as const }

beforeEach(() => {
vi.clearAllMocks()
mockTransaction.mockImplementation(
async (callback: (tx: typeof transaction) => Promise<void>) => callback(transaction)
)
mockSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
})

function updateTools(block: Record<string, unknown>) {
mockSelectWhere.mockResolvedValue([
{
id: 'agent-1',
locked: false,
data: { width: 350, canonicalModes: { '1:projectId': 'advanced' } },
subBlocks: { tools: { id: 'tools', type: 'tool-input', value: [] } },
...block,
},
])
return persistWorkflowOperation('workflow-1', {
operation: SUBBLOCK_OPERATIONS.UPDATE_WITH_CANONICAL_MODES,
target: OPERATION_TARGETS.SUBBLOCK,
timestamp: Date.now(),
payload: { blockId: 'agent-1', subblockId: 'tools', value: tools, canonicalModes },
})
}

it('writes the subblock value and replaces canonical modes in one block update', async () => {
await expect(updateTools({})).resolves.toBeUndefined()

expect(mockSet).toHaveBeenCalledTimes(2)
expect(mockSet).toHaveBeenLastCalledWith(
expect.objectContaining({
subBlocks: { tools: { id: 'tools', type: 'tool-input', value: tools } },
data: { width: 350, canonicalModes },
})
)
})

it('rejects a locked block without writing either field', async () => {
await expect(updateTools({ locked: true })).rejects.toThrow('is locked')
expect(mockSet).toHaveBeenCalledTimes(1)
})
})
91 changes: 66 additions & 25 deletions apps/realtime/src/database/operations.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isDeepStrictEqual } from 'node:util'
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import * as schema from '@sim/db'
import {
Expand Down Expand Up @@ -25,6 +26,7 @@ import {
import { randomFloat } from '@sim/utils/random'
import { loadWorkflowFromNormalizedTablesRaw } from '@sim/workflow-persistence/load'
import { mergeSubBlockValues } from '@sim/workflow-persistence/subblocks'
import type { DbOrTx } from '@sim/workflow-persistence/types'
import {
filterAcyclicEdges,
filterUniqueWorkflowEdges,
Expand Down Expand Up @@ -1988,8 +1990,36 @@ async function handleSubflowOperationTx(
}
}

function valuesEqual(left: unknown, right: unknown): boolean {
return JSON.stringify(left) === JSON.stringify(right)
/** Every block in the workflow by id, for the locked-container check subblock writes need. */
async function loadSubblockUpdateBlocks(tx: DbOrTx, workflowId: string) {
const allBlocks = await tx
.select({
id: workflowBlocks.id,
subBlocks: workflowBlocks.subBlocks,
locked: workflowBlocks.locked,
data: workflowBlocks.data,
})
.from(workflowBlocks)
.where(eq(workflowBlocks.workflowId, workflowId))
return Object.fromEntries(allBlocks.map((block) => [block.id, block]))
}

/**
* The block a subblock write targets, rejecting one that is missing, locked, or in a locked
* container.
*/
function getWritableSubblockUpdateBlock(
blocksById: Awaited<ReturnType<typeof loadSubblockUpdateBlocks>>,
blockId: string
) {
const block = blocksById[blockId]
if (!block) {
throw new Error(`Block ${blockId} not found`)
}
if (isWorkflowBlockProtected(blockId, blocksById)) {
throw new Error(`Block ${blockId} is locked or inside a locked container`)
}
return block
}

// Subblock operations - targeted value updates without replacing workflow state
Expand All @@ -2006,40 +2036,21 @@ async function handleSubblockOperationTx(
return
}

const allBlocks = await tx
.select({
id: workflowBlocks.id,
subBlocks: workflowBlocks.subBlocks,
locked: workflowBlocks.locked,
data: workflowBlocks.data,
})
.from(workflowBlocks)
.where(eq(workflowBlocks.workflowId, workflowId))

type SubblockUpdateBlockRecord = (typeof allBlocks)[number]
const blocksById: Record<string, SubblockUpdateBlockRecord> = Object.fromEntries(
allBlocks.map((block: SubblockUpdateBlockRecord) => [block.id, block])
)
const blocksById = await loadSubblockUpdateBlocks(tx, workflowId)

for (const update of updates) {
const { blockId, subblockId, value, expectedValue } = update
if (!blockId || !subblockId) {
throw new Error('Missing required fields for subblock batch update')
}

const block = blocksById[blockId]
if (!block) {
throw new Error(`Block ${blockId} not found`)
}

if (isWorkflowBlockProtected(blockId, blocksById)) {
throw new Error(`Block ${blockId} is locked or inside a locked container`)
}
const block = getWritableSubblockUpdateBlock(blocksById, blockId)

const subBlocks = { ...((block.subBlocks as Record<string, any>) || {}) }
const currentSubBlock = subBlocks[subblockId]
const currentValue = currentSubBlock?.value
if (expectedValue !== undefined && !valuesEqual(currentValue, expectedValue)) {
/** JSONB can reorder object keys; changed values and array order must still conflict. */
if (expectedValue !== undefined && !isDeepStrictEqual(currentValue, expectedValue)) {
throw new Error(`Subblock ${blockId}.${subblockId} changed since replacement was planned`)
}

Expand All @@ -2062,6 +2073,36 @@ async function handleSubblockOperationTx(
break
}

case SUBBLOCK_OPERATIONS.UPDATE_WITH_CANONICAL_MODES: {
const { blockId, subblockId, value, canonicalModes } = payload
if (!blockId || !subblockId || !canonicalModes) {
throw new Error('Missing required fields for subblock update with canonical modes')
}

const blocksById = await loadSubblockUpdateBlocks(tx, workflowId)
const block = getWritableSubblockUpdateBlock(blocksById, blockId)

const subBlocks = {
...((block.subBlocks as Record<string, Record<string, unknown>> | null) || {}),
}
const currentSubBlock = subBlocks[subblockId]
subBlocks[subblockId] = currentSubBlock
? { ...currentSubBlock, value }
: { id: subblockId, type: 'unknown', value }

await tx
.update(workflowBlocks)
.set({
subBlocks,
data: { ...((block.data as Record<string, unknown>) || {}), canonicalModes },
updatedAt: new Date(),
})
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))

logger.debug(`Updated subblock ${blockId}.${subblockId} with canonical modes`)
break
}

default:
logger.warn(`Unknown subblock operation: ${operation}`)
throw new Error(`Unsupported subblock operation: ${operation}`)
Expand Down
10 changes: 10 additions & 0 deletions apps/realtime/src/middleware/permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@ describe('checkRolePermission', () => {
const result = checkRolePermission('write', 'subblock-batch-update')
expectPermissionAllowed(result)
})

it('should allow subblock-update-with-canonical-modes operation', () => {
const result = checkRolePermission('write', 'subblock-update-with-canonical-modes')
expectPermissionAllowed(result)
})
})

describe('read role', () => {
Expand Down Expand Up @@ -155,6 +160,11 @@ describe('checkRolePermission', () => {
expectPermissionDenied(result, 'read')
})

it('should deny subblock-update-with-canonical-modes operation for read role', () => {
const result = checkRolePermission('read', 'subblock-update-with-canonical-modes')
expectPermissionDenied(result, 'read')
})

it('should deny toggle-enabled operation for read role', () => {
const result = checkRolePermission('read', 'toggle-enabled')
expectPermissionDenied(result, 'read')
Expand Down
1 change: 1 addition & 0 deletions apps/realtime/src/middleware/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const WRITE_OPERATIONS: string[] = [
// Subblock operations
SUBBLOCK_OPERATIONS.UPDATE,
SUBBLOCK_OPERATIONS.BATCH_UPDATE,
SUBBLOCK_OPERATIONS.UPDATE_WITH_CANONICAL_MODES,
// Variable operations
VARIABLE_OPERATIONS.UPDATE,
// Workflow operations
Expand Down
Loading
Loading