feat(mothership): add superuser env selection (#4558)

* feat(table): live cell updates via SSE + per-table event buffer

Replaces the polling-based row refetch with a push-based SSE stream that
patches the React Query cache directly as cell-state events arrive.

Architecture:
- New per-table event buffer in apps/sim/lib/table/events.ts. Redis sorted-set
  with monotonic eventId, 1h TTL, 5000-event cap, in-memory fallback. Modeled
  after apps/sim/lib/execution/event-buffer.ts but stripped of complexity
  tables don't need (no per-execution lifecycle, no id-batching, no write
  queue serialization). ~150 lines instead of 700.
- writeWorkflowGroupState appends a fat event after each successful 'wrote'.
  Status transitions carry executionId + jobId; terminal/partial transitions
  also include the new output values inline so the client can patch row data
  without a follow-up refetch.
- New SSE route at /api/table/[tableId]/events/stream?from=<lastEventId>.
  Replays from buffer on connect, polls at 500ms (mirrors workflow execution
  stream), heartbeat every 15s, signals 'pruned' if the caller fell off the
  back of the buffer.
- Client hook useTableEventStream subscribes via EventSource. Reconnect-resume
  with last-seen eventId. On 'pruned', invalidates the rows query and resumes
  from the new earliest. Cache patches walk every cached query under
  rowsRoot(tableId) so filter/sort variants all stay live.
- Removes refetchInterval from useTableRows and the per-page polling effect
  from useInfiniteTableRows. React Query's refetchOnWindowFocus +
  refetchOnReconnect cover the durability gap if any push is dropped.

Out of scope:
- Bulk-cancel events (cancellation path is being redesigned separately).
- Generalizing the workflow event-buffer module to a shared primitive (defer
  until a third use case appears; for now the table buffer is the simpler
  cousin of the workflow one).

* fix(table): drop run-mutation refetch so SSE patches aren't overwritten

useRunColumn.onSettled was canceling in-flight queries and invalidating the
rows query — leftover behavior from the polling era. With the SSE stream
now keeping the cache live via incremental patches, this refetch races the
stream and snaps the cache back to whatever DB shows at the refetch moment,
which can lag the just-arrived queued/running events. Cells appeared stuck
on the optimistic 'pending' even though the SSE was delivering the real
transitions.

* chore(table): simplify SSE plumbing — reuse helpers, drop dead polling code

- Reuse snapshotAndMutateRows for SSE cache patches instead of reimplementing
  the page-walk + cache-shape detection. Adds a {cancelInFlight: false} opt
  for the SSE caller (mutations still cancel as before).
- Drop client-side type duplication in use-table-event-stream — import
  TableEvent and TableEventEntry from lib/table/events directly.
- Drop the now-dead mergePagePreservingIdentity + rowEqual from tables.ts;
  their only caller was the polling effect that was removed earlier.
- Drop the defensive try/catch around appendTableEvent in cell-write — the
  function is documented as never-throwing (returns null on failure).
- Combine INCR + ZADD into one Lua eval in events.ts. Halves Redis RTT per
  cell-write. Lua returns the new eventId; the script splices it into the
  pre-built entry JSON.
- Trim refs to plain let bindings inside the effect; trim stale
  comments referencing the old polling implementation.

* fix(table): address PR review on SSE buffer

- TTL-expiry silent miss: when all keys expire, hgetall(meta) returns empty
  so earliestEventId is undefined and the prune branch was skipped. Reconnect
  with non-zero afterEventId now checks the seq counter — its absence (TTL
  expired) signals pruned so the client refetches. Memory fallback mirrors.
- Unbounded ZRANGEBYSCORE: cap reads at TABLE_EVENT_READ_CHUNK = 500 events
  per call. The route's 500ms poll loop drains chunks across ticks instead of
  flushing 5000 entries (multi-MB) in one tick after a long disconnect.
- Pruned handler closes EventSource client-side: server-side close was firing
  onerror and routing through the 500ms backoff path. Now we close
  proactively, reset the reconnect attempt counter, and reconnect immediately
  from the new earliest.

* Cross env copilot

* Force deploy

* Run migration

* Updates

* Fix migration

* Redeploy

* Make dev db push

* restore old migs

* Cross env copilot

* Add custom tools, skills, mcps to mothership

* Update migration

* Fix migs

* UPdate

* Fix types

* Fix

---------

Co-authored-by: Theodore Li <theo@sim.ai>
This commit is contained in:
Siddharth Ganesan
2026-05-11 16:58:48 -07:00
committed by GitHub
parent 8774f5c341
commit 0b2cfaf7f7
48 changed files with 17729 additions and 98 deletions
+27 -11
View File
@@ -1,10 +1,12 @@
import { db } from '@sim/db'
import { user } from '@sim/db/schema'
import { settings, user } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { adminMothershipQuerySchema } from '@/lib/api/contracts/mothership-tasks'
import { mothershipEnvironmentSchema } from '@/lib/api/contracts/user'
import { searchParamsToObject, validationErrorResponse } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { getMothershipBaseURL } from '@/lib/copilot/server/agent-url'
import { env } from '@/lib/core/config/env'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -14,8 +16,15 @@ const ENV_URLS: Record<string, string | undefined> = {
prod: env.MOTHERSHIP_PROD_URL,
}
function getMothershipUrl(environment: string): string | null {
return ENV_URLS[environment] ?? null
async function getMothershipUrl(environment: string, userId: string): Promise<string | null> {
const parsedEnvironment = mothershipEnvironmentSchema.safeParse(environment)
if (!parsedEnvironment.success) return ENV_URLS[environment] ?? null
return getMothershipBaseURL({
userId,
environment: parsedEnvironment.data,
fallbackUrl: ENV_URLS[environment],
})
}
const ENDPOINT_PATTERN = /^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/
@@ -26,17 +35,22 @@ function isValidEndpoint(endpoint: string): boolean {
return ENDPOINT_PATTERN.test(endpoint)
}
async function isAdminRequestAuthorized() {
async function getAuthorizedAdminUserId() {
const session = await getSession()
if (!session?.user?.id) return false
if (!session?.user?.id) return null
const [currentUser] = await db
.select({ role: user.role })
.select({
role: user.role,
superUserModeEnabled: settings.superUserModeEnabled,
})
.from(user)
.leftJoin(settings, eq(settings.userId, user.id))
.where(eq(user.id, session.user.id))
.limit(1)
return currentUser?.role === 'admin'
const authorized = currentUser?.role === 'admin' && (currentUser.superUserModeEnabled ?? false)
return authorized ? session.user.id : null
}
/**
@@ -50,7 +64,8 @@ async function isAdminRequestAuthorized() {
* (e.g. requestId for GET /traces) are forwarded.
*/
export const POST = withRouteHandler(async (req: NextRequest) => {
if (!(await isAdminRequestAuthorized())) {
const userId = await getAuthorizedAdminUserId()
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
@@ -68,7 +83,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
return NextResponse.json({ error: 'invalid endpoint' }, { status: 400 })
}
const baseUrl = getMothershipUrl(environment)
const baseUrl = await getMothershipUrl(environment, userId)
if (!baseUrl) {
return NextResponse.json(
{ error: `No URL configured for environment: ${environment}` },
@@ -102,7 +117,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
})
export const GET = withRouteHandler(async (req: NextRequest) => {
if (!(await isAdminRequestAuthorized())) {
const userId = await getAuthorizedAdminUserId()
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
@@ -120,7 +136,7 @@ export const GET = withRouteHandler(async (req: NextRequest) => {
return NextResponse.json({ error: 'invalid endpoint' }, { status: 400 })
}
const baseUrl = getMothershipUrl(environment)
const baseUrl = await getMothershipUrl(environment, userId)
if (!baseUrl) {
return NextResponse.json(
{ error: `No URL configured for environment: ${environment}` },
@@ -2,9 +2,9 @@ import { type NextRequest, NextResponse } from 'next/server'
import { generateCopilotApiKeyContract } from '@/lib/api/contracts'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { fetchGo } from '@/lib/copilot/request/go/fetch'
import { getMothershipBaseURL } from '@/lib/copilot/server/agent-url'
import { env } from '@/lib/core/config/env'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -16,13 +16,14 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
}
const userId = session.user.id
const mothershipBaseURL = await getMothershipBaseURL({ userId })
const parsed = await parseRequest(generateCopilotApiKeyContract, req, {})
if (!parsed.success) return parsed.response
const { name } = parsed.data.body
const res = await fetchGo(`${SIM_AGENT_API_URL}/api/validate-key/generate`, {
const res = await fetchGo(`${mothershipBaseURL}/api/validate-key/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -7,8 +7,9 @@ import { authMockFns, createEnvMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockFetch } = vi.hoisted(() => ({
const { mockFetch, mockGetMothershipBaseURL } = vi.hoisted(() => ({
mockFetch: vi.fn(),
mockGetMothershipBaseURL: vi.fn(),
}))
vi.mock('@/lib/copilot/constants', () => ({
@@ -18,6 +19,10 @@ vi.mock('@/lib/copilot/constants', () => ({
COPILOT_REQUEST_MODES: ['ask', 'build', 'plan', 'agent'] as const,
}))
vi.mock('@/lib/copilot/server/agent-url', () => ({
getMothershipBaseURL: mockGetMothershipBaseURL,
}))
vi.mock('@/lib/core/config/env', () => createEnvMock({ COPILOT_API_KEY: 'test-api-key' }))
import { DELETE, GET } from '@/app/api/copilot/api-keys/route'
@@ -41,6 +46,7 @@ function buildMockResponse(init: {
describe('Copilot API Keys API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetMothershipBaseURL.mockResolvedValue('https://agent.sim.example.com')
global.fetch = mockFetch
})
+5 -3
View File
@@ -1,9 +1,9 @@
import { type NextRequest, NextResponse } from 'next/server'
import { deleteCopilotApiKeyQuerySchema } from '@/lib/api/contracts'
import { getSession } from '@/lib/auth'
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { fetchGo } from '@/lib/copilot/request/go/fetch'
import { getMothershipBaseURL } from '@/lib/copilot/server/agent-url'
import { env } from '@/lib/core/config/env'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -15,8 +15,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
}
const userId = session.user.id
const mothershipBaseURL = await getMothershipBaseURL({ userId })
const res = await fetchGo(`${SIM_AGENT_API_URL}/api/validate-key/get-api-keys`, {
const res = await fetchGo(`${mothershipBaseURL}/api/validate-key/get-api-keys`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -67,6 +68,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => {
}
const userId = session.user.id
const mothershipBaseURL = await getMothershipBaseURL({ userId })
const queryResult = deleteCopilotApiKeyQuerySchema.safeParse(
Object.fromEntries(new URL(request.url).searchParams)
)
@@ -75,7 +77,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => {
}
const { id } = queryResult.data
const res = await fetchGo(`${SIM_AGENT_API_URL}/api/validate-key/delete`, {
const res = await fetchGo(`${mothershipBaseURL}/api/validate-key/delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -6,9 +6,9 @@ import {
} from '@/lib/api/contracts/copilot'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { fetchGo } from '@/lib/copilot/request/go/fetch'
import { getMothershipBaseURL } from '@/lib/copilot/server/agent-url'
import { env } from '@/lib/core/config/env'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -37,9 +37,10 @@ export const GET = withRouteHandler(async () => {
}
const userId = session.user.id
const mothershipBaseURL = await getMothershipBaseURL({ userId })
const res = await fetchGo(
`${SIM_AGENT_API_URL}/api/tool-preferences/auto-allowed?userId=${encodeURIComponent(userId)}`,
`${mothershipBaseURL}/api/tool-preferences/auto-allowed?userId=${encodeURIComponent(userId)}`,
{
method: 'GET',
headers: copilotHeaders(),
@@ -74,6 +75,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}
const userId = session.user.id
const mothershipBaseURL = await getMothershipBaseURL({ userId })
const parsed = await parseRequest(
addCopilotAutoAllowedToolContract,
request,
@@ -88,7 +90,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (!parsed.success) return parsed.response
const { toolId } = parsed.data.body
const res = await fetchGo(`${SIM_AGENT_API_URL}/api/tool-preferences/auto-allowed`, {
const res = await fetchGo(`${mothershipBaseURL}/api/tool-preferences/auto-allowed`, {
method: 'POST',
headers: copilotHeaders(),
body: JSON.stringify({ userId, toolId }),
@@ -125,6 +127,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => {
}
const userId = session.user.id
const mothershipBaseURL = await getMothershipBaseURL({ userId })
const parsed = await parseRequest(
removeCopilotAutoAllowedToolContract,
request,
@@ -138,7 +141,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => {
const { toolId } = parsed.data.query
const res = await fetchGo(
`${SIM_AGENT_API_URL}/api/tool-preferences/auto-allowed?userId=${encodeURIComponent(userId)}&toolId=${encodeURIComponent(toolId)}`,
`${mothershipBaseURL}/api/tool-preferences/auto-allowed?userId=${encodeURIComponent(userId)}&toolId=${encodeURIComponent(toolId)}`,
{
method: 'DELETE',
headers: copilotHeaders(),
+4 -2
View File
@@ -3,7 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { copilotChatAbortBodySchema } from '@/lib/api/contracts/copilot'
import { validationErrorResponse } from '@/lib/api/server'
import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository'
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import { CopilotAbortOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
@@ -11,6 +10,7 @@ import { fetchGo } from '@/lib/copilot/request/go/fetch'
import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request/http'
import { withCopilotSpan, withIncomingGoSpan } from '@/lib/copilot/request/otel'
import { abortActiveStream, waitForPendingChatStream } from '@/lib/copilot/request/session'
import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url'
import { env } from '@/lib/core/config/env'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -85,12 +85,14 @@ export const POST = withRouteHandler((request: NextRequest) =>
if (env.COPILOT_API_KEY) {
headers['x-api-key'] = env.COPILOT_API_KEY
}
Object.assign(headers, getMothershipSourceEnvHeaders())
const controller = new AbortController()
const timeout = setTimeout(
() => controller.abort('timeout:go_explicit_abort_fetch'),
GO_EXPLICIT_ABORT_TIMEOUT_MS
)
const response = await fetchGo(`${SIM_AGENT_API_URL}/api/streams/explicit-abort`, {
const mothershipBaseURL = await getMothershipBaseURL({ userId: authenticatedUserId })
const response = await fetchGo(`${mothershipBaseURL}/api/streams/explicit-abort`, {
method: 'POST',
headers,
signal: controller.signal,
+3 -2
View File
@@ -3,9 +3,9 @@ import { toError } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { copilotModelsContract } from '@/lib/api/contracts/copilot'
import { parseRequest } from '@/lib/api/server'
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import { fetchGo } from '@/lib/copilot/request/go/fetch'
import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request/http'
import { getMothershipBaseURL } from '@/lib/copilot/server/agent-url'
import { env } from '@/lib/core/config/env'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -50,7 +50,8 @@ export const GET = withRouteHandler(async (req: NextRequest) => {
}
try {
const response = await fetchGo(`${SIM_AGENT_API_URL}/api/get-available-models`, {
const mothershipBaseURL = await getMothershipBaseURL({ userId })
const response = await fetchGo(`${mothershipBaseURL}/api/get-available-models`, {
method: 'GET',
headers,
cache: 'no-store',
+7 -1
View File
@@ -7,8 +7,9 @@ import { copilotHttpMock, copilotHttpMockFns, createEnvMock, createMockRequest }
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockFetch } = vi.hoisted(() => ({
const { mockFetch, mockGetMothershipBaseURL } = vi.hoisted(() => ({
mockFetch: vi.fn(),
mockGetMothershipBaseURL: vi.fn(),
}))
vi.mock('@/lib/copilot/request/http', () => copilotHttpMock)
@@ -20,6 +21,10 @@ vi.mock('@/lib/copilot/constants', () => ({
COPILOT_REQUEST_MODES: ['ask', 'build', 'plan', 'agent'] as const,
}))
vi.mock('@/lib/copilot/server/agent-url', () => ({
getMothershipBaseURL: mockGetMothershipBaseURL,
}))
vi.mock('@/lib/core/config/env', () => createEnvMock({ COPILOT_API_KEY: 'test-api-key' }))
import { POST } from '@/app/api/copilot/stats/route'
@@ -43,6 +48,7 @@ function buildMockResponse(init: {
describe('Copilot Stats API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetMothershipBaseURL.mockResolvedValue('https://agent.sim.example.com')
global.fetch = mockFetch
})
+3 -2
View File
@@ -1,7 +1,6 @@
import { type NextRequest, NextResponse } from 'next/server'
import { copilotStatsContract } from '@/lib/api/contracts/copilot'
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import { fetchGo } from '@/lib/copilot/request/go/fetch'
import {
authenticateCopilotRequestSessionOnly,
@@ -9,6 +8,7 @@ import {
createRequestTracker,
createUnauthorizedResponse,
} from '@/lib/copilot/request/http'
import { getMothershipBaseURL } from '@/lib/copilot/server/agent-url'
import { env } from '@/lib/core/config/env'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -44,7 +44,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
diffAccepted,
}
const agentRes = await fetchGo(`${SIM_AGENT_API_URL}/api/stats`, {
const mothershipBaseURL = await getMothershipBaseURL({ userId })
const agentRes = await fetchGo(`${mothershipBaseURL}/api/stats`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -7,7 +7,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { forkMothershipChatContract } from '@/lib/api/contracts/mothership-tasks'
import { parseRequest } from '@/lib/api/server'
import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message'
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import { fetchGo } from '@/lib/copilot/request/go/fetch'
import {
authenticateCopilotRequestSessionOnly,
@@ -17,6 +16,7 @@ import {
createUnauthorizedResponse,
} from '@/lib/copilot/request/http'
import type { MothershipResource } from '@/lib/copilot/resources/types'
import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url'
import { taskPubSub } from '@/lib/copilot/tasks'
import { env } from '@/lib/core/config/env'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -109,7 +109,9 @@ export const POST = withRouteHandler(
if (env.COPILOT_API_KEY) {
copilotHeaders['x-api-key'] = env.COPILOT_API_KEY
}
const copilotRes = await fetchGo(`${SIM_AGENT_API_URL}/api/chats/fork`, {
Object.assign(copilotHeaders, getMothershipSourceEnvHeaders())
const mothershipBaseURL = await getMothershipBaseURL({ userId })
const copilotRes = await fetchGo(`${mothershipBaseURL}/api/chats/fork`, {
method: 'POST',
headers: copilotHeaders,
body: JSON.stringify({
+18 -6
View File
@@ -10,6 +10,7 @@ import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context'
import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless'
import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explicit-abort'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { buildMothershipToolsForRequest } from '@/lib/mothership/settings/runtime'
import {
assertActiveWorkspaceAccess,
getUserEntityPermissions,
@@ -65,11 +66,19 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
workflowId,
executionId,
})
const [workspaceContext, integrationTools, userPermission] = await Promise.all([
generateWorkspaceContext(workspaceId, userId),
buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId),
getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null),
])
const [workspaceContext, integrationTools, mothershipToolRuntime, userPermission] =
await Promise.all([
generateWorkspaceContext(workspaceId, userId),
buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId),
buildMothershipToolsForRequest({ workspaceId, userId }),
getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null),
])
const workspaceContextWithMothershipTools = [
workspaceContext,
mothershipToolRuntime.catalogContext,
]
.filter(Boolean)
.join('\n\n')
const requestPayload: Record<string, unknown> = {
messages,
@@ -79,8 +88,11 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
mode: 'agent',
messageId,
isHosted: true,
workspaceContext,
workspaceContext: workspaceContextWithMothershipTools,
...(integrationTools.length > 0 ? { integrationTools } : {}),
...(mothershipToolRuntime.tools.length > 0
? { mothershipTools: mothershipToolRuntime.tools }
: {}),
...(userPermission ? { userPermission } : {}),
}
@@ -0,0 +1,91 @@
import { db, settings, user } from '@sim/db'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
getMothershipSettingsContract,
updateMothershipSettingsContract,
} from '@/lib/api/contracts/mothership-settings'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
getMothershipSettings,
updateMothershipSettings,
} from '@/lib/mothership/settings/operations'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('MothershipSettingsAPI')
async function isEffectiveSuperUser(userId: string): Promise<boolean> {
const [row] = await db
.select({
role: user.role,
superUserModeEnabled: settings.superUserModeEnabled,
})
.from(user)
.leftJoin(settings, eq(settings.userId, user.id))
.where(eq(user.id, userId))
.limit(1)
return row?.role === 'admin' && (row.superUserModeEnabled ?? false)
}
export const GET = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!(await isEffectiveSuperUser(auth.userId))) {
return NextResponse.json({ error: 'Super admin mode required' }, { status: 403 })
}
const parsed = await parseRequest(getMothershipSettingsContract, request, {})
if (!parsed.success) return parsed.response
const { workspaceId } = parsed.data.query
const userPermission = await getUserEntityPermissions(auth.userId, 'workspace', workspaceId)
if (!userPermission) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const settings = await getMothershipSettings(workspaceId)
return NextResponse.json({ data: settings })
} catch (error) {
logger.error(`[${requestId}] Error fetching Mothership settings`, error)
return NextResponse.json({ error: 'Failed to fetch Mothership settings' }, { status: 500 })
}
})
export const PUT = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!(await isEffectiveSuperUser(auth.userId))) {
return NextResponse.json({ error: 'Super admin mode required' }, { status: 403 })
}
const parsed = await parseRequest(updateMothershipSettingsContract, request, {})
if (!parsed.success) return parsed.response
const { workspaceId } = parsed.data.body
const userPermission = await getUserEntityPermissions(auth.userId, 'workspace', workspaceId)
if (!userPermission || (userPermission !== 'admin' && userPermission !== 'write')) {
return NextResponse.json({ error: 'Write permission required' }, { status: 403 })
}
const settings = await updateMothershipSettings(parsed.data.body)
return NextResponse.json({ success: true, data: settings })
} catch (error) {
logger.error(`[${requestId}] Error updating Mothership settings`, error)
return NextResponse.json({ error: 'Failed to update Mothership settings' }, { status: 500 })
}
})
@@ -20,6 +20,7 @@ const defaultSettings = {
billingUsageNotificationsEnabled: true,
showTrainingControls: false,
superUserModeEnabled: false,
mothershipEnvironment: 'default',
errorNotificationsEnabled: true,
snapToGridSize: 0,
showActionBar: true,
@@ -56,6 +57,7 @@ export const GET = withRouteHandler(async () => {
billingUsageNotificationsEnabled: userSettings.billingUsageNotificationsEnabled ?? true,
showTrainingControls: userSettings.showTrainingControls ?? false,
superUserModeEnabled: userSettings.superUserModeEnabled ?? false,
mothershipEnvironment: userSettings.mothershipEnvironment ?? 'default',
errorNotificationsEnabled: userSettings.errorNotificationsEnabled ?? true,
snapToGridSize: userSettings.snapToGridSize ?? 0,
showActionBar: userSettings.showActionBar ?? true,
@@ -1,11 +1,8 @@
'use client'
import { memo, useMemo } from 'react'
import {
Read as ReadTool,
ToolSearchToolRegex,
WorkspaceFile,
} from '@/lib/copilot/generated/tool-catalog-v1'
import { Read as ReadTool, WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1'
import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools'
import { resolveToolDisplay } from '@/lib/copilot/tools/client/store-utils'
import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-call-state'
import type { ContentBlock, MothershipResource, OptionItem, ToolCallData } from '../../types'
@@ -304,7 +301,7 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
if (block.type === 'tool_call') {
if (!block.toolCall) continue
const tc = block.toolCall
if (tc.name === ToolSearchToolRegex.id) continue
if (isToolHiddenInUi(tc.name)) continue
if (tc.name === ReadTool.id && isToolResultRead(tc.params)) continue
const isDispatch = SUBAGENT_KEYS.has(tc.name) && !tc.calledBy
@@ -1,8 +1,20 @@
'use client'
import { useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { WrenchIcon } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Badge, Button, Input as EmcnInput, Label, Skeleton, Switch } from '@/components/emcn'
import {
Badge,
Button,
Combobox,
type ComboboxOptionGroup,
Input as EmcnInput,
Label,
Skeleton,
Switch,
} from '@/components/emcn'
import { AgentSkillsIcon, McpIcon } from '@/components/icons'
import type { MothershipEnvironment, MothershipSettings } from '@/lib/api/contracts'
import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import {
@@ -12,11 +24,34 @@ import {
useSetUserRole,
useUnbanUser,
} from '@/hooks/queries/admin-users'
import { useCustomTools } from '@/hooks/queries/custom-tools'
import { useGeneralSettings, useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
import { useMcpServers, useMcpToolsQuery } from '@/hooks/queries/mcp'
import {
useMothershipSettings,
useUpdateMothershipSettings,
} from '@/hooks/queries/mothership-settings'
import { useSkills } from '@/hooks/queries/skills'
import { useImportWorkflow } from '@/hooks/queries/workflows'
const PAGE_SIZE = 20 as const
const MOTHERSHIP_ENV_OPTIONS: { value: MothershipEnvironment; label: string }[] = [
{ value: 'default', label: 'Default' },
{ value: 'dev', label: 'Dev' },
{ value: 'staging', label: 'Staging' },
{ value: 'prod', label: 'Prod' },
]
function defaultMothershipSettings(workspaceId: string): MothershipSettings {
return {
workspaceId,
mcpTools: [],
customTools: [],
skills: [],
}
}
export function Admin() {
const params = useParams()
const workspaceId = params?.workspaceId as string
@@ -25,6 +60,17 @@ export function Admin() {
const { data: settings } = useGeneralSettings()
const updateSetting = useUpdateGeneralSetting()
const importWorkflow = useImportWorkflow()
const adminMothershipWorkspaceId = settings?.superUserModeEnabled ? workspaceId : ''
const { data: mothershipSettings } = useMothershipSettings(adminMothershipWorkspaceId)
const updateMothershipSettings = useUpdateMothershipSettings()
const { data: mcpTools = [], isLoading: mcpToolsLoading } = useMcpToolsQuery(
adminMothershipWorkspaceId
)
const { data: mcpServers = [] } = useMcpServers(adminMothershipWorkspaceId)
const { data: customTools = [], isLoading: customToolsLoading } = useCustomTools(
adminMothershipWorkspaceId
)
const { data: skills = [], isLoading: skillsLoading } = useSkills(adminMothershipWorkspaceId)
const setUserRole = useSetUserRole()
const banUser = useBanUser()
@@ -56,6 +102,20 @@ export function Admin() {
[usersData?.total]
)
const currentPage = useMemo(() => Math.floor(usersOffset / PAGE_SIZE) + 1, [usersOffset])
const currentMothershipSettings = mothershipSettings ?? defaultMothershipSettings(workspaceId)
const selectedMothershipToolValues = useMemo(
() => [
...currentMothershipSettings.mcpTools.map((tool) => `mcp:${tool.serverId}:${tool.toolName}`),
...currentMothershipSettings.customTools.map((tool) => `custom:${tool.customToolId}`),
...currentMothershipSettings.skills.map((s) => `skill:${s.skillId}`),
],
[
currentMothershipSettings.customTools,
currentMothershipSettings.mcpTools,
currentMothershipSettings.skills,
]
)
const selectedMothershipToolCount = selectedMothershipToolValues.length
const handleSuperUserModeToggle = async (checked: boolean) => {
if (checked !== settings?.superUserModeEnabled && !updateSetting.isPending) {
@@ -63,6 +123,143 @@ export function Admin() {
}
}
const handleMothershipEnvironmentChange = useCallback(
async (nextEnvironment: MothershipEnvironment) => {
if (nextEnvironment !== settings?.mothershipEnvironment && !updateSetting.isPending) {
await updateSetting.mutateAsync({
key: 'mothershipEnvironment',
value: nextEnvironment,
})
}
},
[settings?.mothershipEnvironment, updateSetting]
)
const saveMothershipSettings = useCallback(
(next: Partial<Omit<MothershipSettings, 'workspaceId'>>) => {
updateMothershipSettings.mutate({
...currentMothershipSettings,
...next,
workspaceId,
})
},
[currentMothershipSettings, updateMothershipSettings, workspaceId]
)
const connectedServerIds = useMemo(
() =>
new Set(
mcpServers
.filter((server) => server.connectionStatus === 'connected')
.map((server) => server.id)
),
[mcpServers]
)
const mothershipToolOptions = useMemo(() => {
const groups: ComboboxOptionGroup[] = []
const refs = new Map<
string,
| {
type: 'mcp'
serverId: string
serverName?: string
toolName: string
title?: string
}
| { type: 'custom'; customToolId: string; title?: string }
| { type: 'skill'; skillId: string; name?: string }
>()
const availableMcpTools = mcpTools.filter((tool) => connectedServerIds.has(tool.serverId))
if (availableMcpTools.length > 0) {
groups.push({
section: 'MCP Tools',
items: availableMcpTools.map((tool) => {
const value = `mcp:${tool.serverId}:${tool.name}`
refs.set(value, {
type: 'mcp',
serverId: tool.serverId,
serverName: tool.serverName,
toolName: tool.name,
title: tool.name,
})
return {
label: `${tool.serverName}: ${tool.name}`,
value,
icon: McpIcon,
}
}),
})
}
if (customTools.length > 0) {
groups.push({
section: 'Custom Tools',
items: customTools.map((tool) => {
const value = `custom:${tool.id}`
refs.set(value, { type: 'custom', customToolId: tool.id, title: tool.title })
return {
label: tool.title,
value,
icon: WrenchIcon,
}
}),
})
}
if (skills.length > 0) {
groups.push({
section: 'Skills',
items: skills.map((skill) => {
const value = `skill:${skill.id}`
refs.set(value, { type: 'skill', skillId: skill.id, name: skill.name })
return {
label: skill.name,
value,
icon: AgentSkillsIcon,
}
}),
})
}
return { groups, refs }
}, [connectedServerIds, customTools, mcpTools, skills])
const handleMothershipToolSelectionChange = useCallback(
(values: string[]) => {
const mcpTools: MothershipSettings['mcpTools'] = []
const customTools: MothershipSettings['customTools'] = []
const skills: MothershipSettings['skills'] = []
for (const value of values) {
const ref = mothershipToolOptions.refs.get(value)
if (!ref) continue
if (ref.type === 'mcp') {
mcpTools.push({
serverId: ref.serverId,
serverName: ref.serverName,
toolName: ref.toolName,
title: ref.title,
})
} else if (ref.type === 'custom') {
customTools.push({
customToolId: ref.customToolId,
title: ref.title,
})
} else {
skills.push({
skillId: ref.skillId,
name: ref.name,
})
}
}
saveMothershipSettings({ mcpTools, customTools, skills })
},
[mothershipToolOptions.refs, saveMothershipSettings]
)
const handleImport = () => {
if (!workflowId.trim()) return
importWorkflow.mutate(
@@ -119,13 +316,81 @@ export function Admin() {
])
return (
<div className='flex h-full flex-col gap-6'>
<div className='flex items-center justify-between'>
<Label htmlFor='super-user-mode'>Super admin mode</Label>
<Switch
id='super-user-mode'
checked={settings?.superUserModeEnabled ?? false}
onCheckedChange={handleSuperUserModeToggle}
/>
<div className='flex flex-col gap-4'>
<div className='flex items-center justify-between'>
<Label htmlFor='super-user-mode'>Super admin mode</Label>
<Switch
id='super-user-mode'
checked={settings?.superUserModeEnabled ?? false}
disabled={updateSetting.isPending}
onCheckedChange={handleSuperUserModeToggle}
/>
</div>
{settings?.superUserModeEnabled && (
<>
<div className='flex items-center justify-between gap-3'>
<div className='flex flex-col gap-1'>
<Label className='text-[var(--text-primary)] text-sm'>Mothership Environment</Label>
<p className='text-[var(--text-secondary)] text-xs'>
Default uses the configured Sim agent URL.
</p>
</div>
<div className='w-[160px]'>
<Combobox
size='sm'
align='end'
dropdownWidth={160}
value={settings?.mothershipEnvironment ?? 'default'}
onChange={(value) =>
handleMothershipEnvironmentChange(value as MothershipEnvironment)
}
placeholder='Select environment'
disabled={updateSetting.isPending}
options={MOTHERSHIP_ENV_OPTIONS}
/>
</div>
</div>
<div className='flex items-center justify-between gap-3'>
<div className='flex flex-col gap-1'>
<Label className='text-[var(--text-primary)] text-sm'>Mothership Tools</Label>
<p className='text-[var(--text-secondary)] text-xs'>
Select workspace MCP tools, custom tools, and skills that Mothership can use.
</p>
</div>
<div className='w-[160px]'>
<Combobox
size='sm'
align='end'
dropdownWidth={320}
options={[]}
groups={mothershipToolOptions.groups}
multiSelect
multiSelectValues={selectedMothershipToolValues}
onMultiSelectChange={handleMothershipToolSelectionChange}
overlayContent={
selectedMothershipToolCount > 0
? `${selectedMothershipToolCount} selected`
: undefined
}
placeholder={
mcpToolsLoading || customToolsLoading || skillsLoading ? 'Loading...' : 'Select'
}
searchPlaceholder='Search tools and skills...'
emptyMessage='No tools or skills available'
disabled={
updateMothershipSettings.isPending ||
mcpToolsLoading ||
customToolsLoading ||
skillsLoading
}
searchable
/>
</div>
</div>
</>
)}
</div>
<div className='h-px bg-[var(--border-secondary)]' />
@@ -73,6 +73,31 @@ export async function resolveSkillContent(
}
}
export async function resolveSkillContentById(
skillId: string,
workspaceId: string
): Promise<{ name: string; content: string } | null> {
if (!skillId || !workspaceId) return null
try {
const rows = await db
.select({ content: skill.content, name: skill.name })
.from(skill)
.where(and(eq(skill.workspaceId, workspaceId), eq(skill.id, skillId)))
.limit(1)
if (rows.length === 0) {
logger.warn('Skill not found', { skillId, workspaceId })
return null
}
return rows[0]
} catch (error) {
logger.error('Failed to resolve skill content', { error, skillId, workspaceId })
return null
}
}
/**
* Build the system prompt section that lists available skills.
* Uses XML format per the agentskills.io integration guide.
@@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import {
getUserSettingsContract,
type MothershipEnvironment,
type UserSettingsApi,
updateUserSettingsContract,
} from '@/lib/api/contracts'
@@ -26,6 +27,7 @@ export interface GeneralSettings {
autoConnect: boolean
showTrainingControls: boolean
superUserModeEnabled: boolean
mothershipEnvironment: MothershipEnvironment
theme: 'light' | 'dark' | 'system'
telemetryEnabled: boolean
billingUsageNotificationsEnabled: boolean
@@ -43,6 +45,7 @@ export function mapGeneralSettingsResponse(data: UserSettingsApi): GeneralSettin
autoConnect: data.autoConnect,
showTrainingControls: data.showTrainingControls,
superUserModeEnabled: data.superUserModeEnabled,
mothershipEnvironment: data.mothershipEnvironment,
theme: data.theme,
telemetryEnabled: data.telemetryEnabled,
billingUsageNotificationsEnabled: data.billingUsageNotificationsEnabled,
+1 -1
View File
@@ -1,6 +1,6 @@
import { keepPreviousData, useMutation, useQuery } from '@tanstack/react-query'
export type MothershipEnv = 'dev' | 'staging' | 'prod'
export type MothershipEnv = 'default' | 'dev' | 'staging' | 'prod'
const BASE = '/api/admin/mothership'
@@ -0,0 +1,75 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import {
getMothershipSettingsContract,
type MothershipSettings,
updateMothershipSettingsContract,
} from '@/lib/api/contracts/mothership-settings'
export const mothershipSettingsKeys = {
all: ['mothership-settings'] as const,
detail: (workspaceId: string) => [...mothershipSettingsKeys.all, workspaceId] as const,
}
async function fetchMothershipSettings(
workspaceId: string,
signal?: AbortSignal
): Promise<MothershipSettings> {
const { data } = await requestJson(getMothershipSettingsContract, {
query: { workspaceId },
signal,
})
return data
}
export function useMothershipSettings(workspaceId: string) {
return useQuery({
queryKey: mothershipSettingsKeys.detail(workspaceId),
queryFn: ({ signal }) => fetchMothershipSettings(workspaceId, signal),
enabled: !!workspaceId,
staleTime: 60 * 1000,
})
}
export function useUpdateMothershipSettings() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (settings: MothershipSettings) => {
const { data } = await requestJson(updateMothershipSettingsContract, {
body: {
workspaceId: settings.workspaceId,
mcpTools: settings.mcpTools,
customTools: settings.customTools,
skills: settings.skills,
},
})
return data
},
onMutate: async (settings) => {
await queryClient.cancelQueries({
queryKey: mothershipSettingsKeys.detail(settings.workspaceId),
})
const previous = queryClient.getQueryData<MothershipSettings>(
mothershipSettingsKeys.detail(settings.workspaceId)
)
queryClient.setQueryData(mothershipSettingsKeys.detail(settings.workspaceId), settings)
return { previous }
},
onError: (_error, settings, context) => {
if (context?.previous) {
queryClient.setQueryData(
mothershipSettingsKeys.detail(settings.workspaceId),
context.previous
)
}
},
onSettled: (_data, _error, settings) => {
queryClient.invalidateQueries({
queryKey: mothershipSettingsKeys.detail(settings.workspaceId),
})
},
})
}
+4
View File
@@ -1140,6 +1140,10 @@ function isInfiniteRowsCache(value: unknown): value is InfiniteRowsCache {
* patchers pass `false` so live cell updates don't kick the row query off the
* network.
*/
/** Walks every cached query under `rowsRoot(tableId)` and applies `transform`
* to each row. Transform returns the new row or `null` to skip. Returns the
* list of [queryKey, prior data] entries so optimistic-update callers can
* roll back. SSE patchers can ignore the return value. */
export async function snapshotAndMutateRows(
queryClient: ReturnType<typeof useQueryClient>,
tableId: string,
+1
View File
@@ -16,6 +16,7 @@ export * from './folders'
export * from './hotspots'
export * from './inbox'
export * from './media'
export * from './mothership-settings'
export * from './notifications'
export * from './permission-groups'
export * from './primitives'
@@ -0,0 +1,74 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
const dateStringSchema = z.preprocess(
(value) => (value instanceof Date ? value.toISOString() : value),
z.string()
)
export const mothershipMcpToolRefSchema = z.object({
serverId: z.string().min(1),
serverName: z.string().optional(),
toolName: z.string().min(1),
title: z.string().optional(),
})
export const mothershipCustomToolRefSchema = z.object({
customToolId: z.string().min(1),
title: z.string().optional(),
})
export const mothershipSkillRefSchema = z.object({
skillId: z.string().min(1),
name: z.string().optional(),
})
export const mothershipSettingsSchema = z.object({
workspaceId: z.string().min(1),
mcpTools: z.array(mothershipMcpToolRefSchema).default([]),
customTools: z.array(mothershipCustomToolRefSchema).default([]),
skills: z.array(mothershipSkillRefSchema).default([]),
createdAt: dateStringSchema.optional(),
updatedAt: dateStringSchema.optional(),
})
export type MothershipMcpToolRef = z.output<typeof mothershipMcpToolRefSchema>
export type MothershipCustomToolRef = z.output<typeof mothershipCustomToolRefSchema>
export type MothershipSkillRef = z.output<typeof mothershipSkillRefSchema>
export type MothershipSettings = z.output<typeof mothershipSettingsSchema>
export const getMothershipSettingsQuerySchema = z.object({
workspaceId: z.string().min(1),
})
export const updateMothershipSettingsBodySchema = z.object({
workspaceId: z.string().min(1),
mcpTools: z.array(mothershipMcpToolRefSchema).default([]),
customTools: z.array(mothershipCustomToolRefSchema).default([]),
skills: z.array(mothershipSkillRefSchema).default([]),
})
export const getMothershipSettingsContract = defineRouteContract({
method: 'GET',
path: '/api/mothership/settings',
query: getMothershipSettingsQuerySchema,
response: {
mode: 'json',
schema: z.object({
data: mothershipSettingsSchema,
}),
},
})
export const updateMothershipSettingsContract = defineRouteContract({
method: 'PUT',
path: '/api/mothership/settings',
body: updateMothershipSettingsBodySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
data: mothershipSettingsSchema,
}),
},
})
+5
View File
@@ -61,6 +61,9 @@ export const userSettingsEmailPreferencesSchema = z.object({
unsubscribeNotifications: z.boolean().optional(),
})
export const mothershipEnvironmentSchema = z.enum(['default', 'dev', 'staging', 'prod'])
export type MothershipEnvironment = z.infer<typeof mothershipEnvironmentSchema>
export const userSettingsSchema = z.object({
theme: z.enum(['system', 'light', 'dark']).default('system'),
autoConnect: z.boolean().default(true),
@@ -69,6 +72,7 @@ export const userSettingsSchema = z.object({
billingUsageNotificationsEnabled: z.boolean().default(true),
showTrainingControls: z.boolean().default(false),
superUserModeEnabled: z.boolean().default(false),
mothershipEnvironment: mothershipEnvironmentSchema.default('default'),
errorNotificationsEnabled: z.boolean().default(true),
snapToGridSize: z.number().min(0).max(50).default(0),
showActionBar: z.boolean().default(true),
@@ -85,6 +89,7 @@ export const updateUserSettingsBodySchema = z.object({
billingUsageNotificationsEnabled: z.boolean().optional(),
showTrainingControls: z.boolean().optional(),
superUserModeEnabled: z.boolean().optional(),
mothershipEnvironment: mothershipEnvironmentSchema.optional(),
errorNotificationsEnabled: z.boolean().optional(),
snapToGridSize: z.number().min(0).max(50).optional(),
showActionBar: z.boolean().optional(),
+19 -23
View File
@@ -5,7 +5,7 @@ import { isPaid } from '@/lib/billing/plan-helpers'
import { getToolEntry } from '@/lib/copilot/tool-executor/router'
import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions'
import { isHosted } from '@/lib/core/config/feature-flags'
import { createMcpToolId } from '@/lib/mcp/utils'
import { buildMothershipToolsForRequest } from '@/lib/mothership/settings/runtime'
import { trackChatUpload } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { tools } from '@/tools/registry'
import { getLatestVersionTools, stripVersionSuffix } from '@/tools/utils'
@@ -40,6 +40,7 @@ interface BuildPayloadParams {
workspaceContext?: string
userPermission?: string
userTimezone?: string
includeMothershipTools?: boolean
}
export interface ToolSchema {
@@ -48,6 +49,7 @@ export interface ToolSchema {
input_schema: Record<string, unknown>
defer_loading?: boolean
executeLocally?: boolean
params?: Record<string, unknown>
oauth?: { required: boolean; provider: string }
}
@@ -274,6 +276,8 @@ export async function buildCopilotRequestPayload(
const allContexts = [...(contexts ?? []), ...uploadContexts]
let integrationTools: ToolSchema[] = []
let mothershipTools: ToolSchema[] = []
let workspaceContext = params.workspaceContext
const payloadLogger = logger.withMetadata({ messageId: userMessageId })
@@ -285,32 +289,23 @@ export async function buildCopilotRequestPayload(
params.workspaceId
)
// Discover MCP tools from workspace servers and include as deferred tools
if (params.workspaceId) {
if (params.includeMothershipTools && params.workspaceId) {
try {
const { mcpService } = await import('@/lib/mcp/service')
const mcpTools = await mcpService.discoverTools(userId, params.workspaceId)
for (const mcpTool of mcpTools) {
integrationTools.push({
name: createMcpToolId(mcpTool.serverId, mcpTool.name),
description: mcpTool.description || `MCP tool: ${mcpTool.name} (${mcpTool.serverName})`,
input_schema: { ...mcpTool.inputSchema },
executeLocally: false,
})
}
if (mcpTools.length > 0) {
logger.error(
userMessageId
? `Added MCP tools to copilot payload [messageId:${userMessageId}]`
: 'Added MCP tools to copilot payload',
{ count: mcpTools.length }
)
const runtimeTools = await buildMothershipToolsForRequest({
workspaceId: params.workspaceId,
userId,
})
mothershipTools = runtimeTools.tools
if (runtimeTools.catalogContext) {
workspaceContext = [workspaceContext, runtimeTools.catalogContext]
.filter(Boolean)
.join('\n\n')
}
} catch (error) {
logger.warn(
userMessageId
? `Failed to discover MCP tools for copilot [messageId:${userMessageId}]`
: 'Failed to discover MCP tools for copilot',
? `Failed to build Mothership tools [messageId:${userMessageId}]`
: 'Failed to build Mothership tools',
{
error: toError(error).message,
}
@@ -334,8 +329,9 @@ export async function buildCopilotRequestPayload(
...(typeof prefetch === 'boolean' ? { prefetch } : {}),
...(implicitFeedback ? { implicitFeedback } : {}),
...(integrationTools.length > 0 ? { integrationTools } : {}),
...(mothershipTools.length > 0 ? { mothershipTools } : {}),
...(commands && commands.length > 0 ? { commands } : {}),
...(params.workspaceContext ? { workspaceContext: params.workspaceContext } : {}),
...(workspaceContext ? { workspaceContext } : {}),
...(params.userPermission ? { userPermission: params.userPermission } : {}),
...(params.userTimezone ? { userTimezone: params.userTimezone } : {}),
isHosted,
+1
View File
@@ -604,6 +604,7 @@ async function resolveBranch(params: {
workspaceContext: payloadParams.workspaceContext,
userPermission: payloadParams.userPermission,
userTimezone: payloadParams.userTimezone,
includeMothershipTools: true,
},
{ selectedModel: '' }
),
@@ -4,7 +4,7 @@ import { toError } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { generateId } from '@sim/utils/id'
import { createRunSegment, updateRunStatus } from '@/lib/copilot/async-runs/repository'
import { SIM_AGENT_API_URL, SIM_AGENT_VERSION } from '@/lib/copilot/constants'
import { SIM_AGENT_VERSION } from '@/lib/copilot/constants'
import {
MothershipStreamV1EventType,
MothershipStreamV1RunKind,
@@ -33,6 +33,7 @@ import type {
StreamEvent,
StreamingContext,
} from '@/lib/copilot/request/types'
import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url'
import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context'
import { env } from '@/lib/core/config/env'
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
@@ -191,6 +192,7 @@ async function runCheckpointLoop(
let payload: Record<string, unknown> = initialPayload
let resumeAttempt = 0
const callerOnEvent = options.onEvent
const mothershipBaseURL = await getMothershipBaseURL({ userId: options.userId })
for (;;) {
context.streamComplete = false
@@ -245,12 +247,13 @@ async function runCheckpointLoop(
try {
await runStreamLoop(
`${SIM_AGENT_API_URL}${route}`,
`${mothershipBaseURL}${route}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
...getMothershipSourceEnvHeaders(),
'X-Client-Version': SIM_AGENT_VERSION,
},
body: JSON.stringify(payload),
@@ -4,7 +4,6 @@ import { copilotChats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { createRunSegment } from '@/lib/copilot/async-runs/repository'
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import {
MothershipStreamV1EventType,
MothershipStreamV1SessionKind,
@@ -39,6 +38,7 @@ import {
} from '@/lib/copilot/request/session'
import { SSE_RESPONSE_HEADERS } from '@/lib/copilot/request/session/sse'
import { reportTrace, TraceCollector } from '@/lib/copilot/request/trace'
import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url'
import { taskPubSub } from '@/lib/copilot/tasks'
import { env } from '@/lib/core/config/env'
@@ -228,6 +228,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
chatId,
currentChat,
isNewChat,
userId,
message,
titleModel,
titleProvider,
@@ -435,6 +436,7 @@ function fireTitleGeneration(params: {
chatId?: string
currentChat: CurrentChatSummary
isNewChat: boolean
userId?: string
message: string
titleModel: string
titleProvider?: string
@@ -447,6 +449,7 @@ function fireTitleGeneration(params: {
chatId,
currentChat,
isNewChat,
userId,
message,
titleModel,
titleProvider,
@@ -461,6 +464,7 @@ function fireTitleGeneration(params: {
message,
model: titleModel,
provider: titleProvider,
userId,
otelContext,
})
.then(async (title) => {
@@ -491,9 +495,10 @@ export async function requestChatTitle(params: {
message: string
model: string
provider?: string
userId?: string
otelContext?: Context
}): Promise<string | null> {
const { message, model, provider, otelContext } = params
const { message, model, provider, userId, otelContext } = params
if (!message || !model) return null
const headers: Record<string, string> = {
@@ -502,10 +507,12 @@ export async function requestChatTitle(params: {
if (env.COPILOT_API_KEY) {
headers['x-api-key'] = env.COPILOT_API_KEY
}
Object.assign(headers, getMothershipSourceEnvHeaders())
try {
const { fetchGo } = await import('@/lib/copilot/request/go/fetch')
const response = await fetchGo(`${SIM_AGENT_API_URL}/api/generate-chat-title`, {
const mothershipBaseURL = await getMothershipBaseURL({ userId })
const response = await fetchGo(`${mothershipBaseURL}/api/generate-chat-title`, {
method: 'POST',
headers,
body: JSON.stringify({
@@ -1,8 +1,8 @@
import type { Context } from '@opentelemetry/api'
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { fetchGo } from '@/lib/copilot/request/go/fetch'
import { AbortReason } from '@/lib/copilot/request/session/abort'
import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url'
import { env } from '@/lib/core/config/env'
export const DEFAULT_EXPLICIT_ABORT_TIMEOUT_MS = 3000
@@ -28,6 +28,7 @@ export async function requestExplicitStreamAbort(params: {
if (env.COPILOT_API_KEY) {
headers['x-api-key'] = env.COPILOT_API_KEY
}
Object.assign(headers, getMothershipSourceEnvHeaders())
const controller = new AbortController()
const timeout = setTimeout(
@@ -36,7 +37,8 @@ export async function requestExplicitStreamAbort(params: {
)
try {
const response = await fetchGo(`${SIM_AGENT_API_URL}/api/streams/explicit-abort`, {
const mothershipBaseURL = await getMothershipBaseURL({ userId })
const response = await fetchGo(`${mothershipBaseURL}/api/streams/explicit-abort`, {
method: 'POST',
headers,
signal: controller.signal,
+5 -2
View File
@@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context'
import { SIM_AGENT_API_URL, SIM_AGENT_VERSION } from '@/lib/copilot/constants'
import { SIM_AGENT_VERSION } from '@/lib/copilot/constants'
import {
MothershipStreamV1EventType,
MothershipStreamV1SpanPayloadKind,
@@ -20,6 +20,7 @@ import type {
StreamingContext,
ToolCallSummary,
} from '@/lib/copilot/request/types'
import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url'
import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context'
import { env } from '@/lib/core/config/env'
import { isHosted } from '@/lib/core/config/feature-flags'
@@ -101,6 +102,7 @@ async function orchestrateSubagentStreamInner(
const chatId =
(typeof requestPayload.chatId === 'string' && requestPayload.chatId) || generateId()
const execContext = await buildExecutionContext(userId, workflowId, workspaceId, chatId)
const mothershipBaseURL = await getMothershipBaseURL({ userId })
let resolvedWorkflowName =
typeof requestPayload.workflowName === 'string' ? requestPayload.workflowName : undefined
let resolvedWorkspaceId =
@@ -140,12 +142,13 @@ async function orchestrateSubagentStreamInner(
try {
await runStreamLoop(
`${SIM_AGENT_API_URL}/api/subagent/${agentId}`,
`${mothershipBaseURL}/api/subagent/${agentId}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
...getMothershipSourceEnvHeaders(),
'X-Client-Version': SIM_AGENT_VERSION,
},
body: JSON.stringify({
@@ -0,0 +1,145 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
getMothershipBaseURL,
getMothershipSourceEnvHeaders,
MOTHERSHIP_SOURCE_ENV_HEADER,
} from './agent-url'
const { dbMock, envMock, mockRows } = vi.hoisted(() => {
const mockRows: any[] = []
const dbMock = {
select: vi.fn(() => ({
from: vi.fn(() => ({
leftJoin: vi.fn(() => ({
where: vi.fn(() => ({
limit: vi.fn(async () => mockRows),
})),
})),
})),
})),
}
const envMock = {
COPILOT_DEV_URL: 'https://dev.mothership.test',
COPILOT_STAGING_URL: 'https://staging.mothership.test',
COPILOT_PROD_URL: 'https://prod.mothership.test',
COPILOT_SOURCE_ENV: undefined as string | undefined,
}
return { dbMock, envMock, mockRows }
})
vi.mock('@sim/db', () => ({ db: dbMock }))
vi.mock('@sim/db/schema', () => ({
settings: {
userId: 'settings.userId',
superUserModeEnabled: 'settings.superUserModeEnabled',
mothershipEnvironment: 'settings.mothershipEnvironment',
},
user: {
id: 'user.id',
role: 'user.role',
},
}))
vi.mock('drizzle-orm', () => ({
eq: vi.fn(() => ({})),
}))
vi.mock('@/lib/api/contracts', () => ({
mothershipEnvironmentSchema: {
safeParse: (value: unknown) =>
['default', 'dev', 'staging', 'prod'].includes(String(value))
? { success: true, data: value }
: { success: false },
},
}))
vi.mock('@/lib/copilot/constants', () => ({
SIM_AGENT_API_URL: 'https://default.mothership.test',
SIM_AGENT_API_URL_DEFAULT: 'https://fallback.mothership.test',
}))
vi.mock('@/lib/core/config/env', () => ({
env: envMock,
}))
describe('getMothershipBaseURL', () => {
beforeEach(() => {
mockRows.length = 0
dbMock.select.mockClear()
envMock.COPILOT_SOURCE_ENV = undefined
})
it('uses the default URL when there is no user context', async () => {
await expect(getMothershipBaseURL()).resolves.toBe('https://default.mothership.test')
await expect(getMothershipBaseURL({ environment: 'dev' })).resolves.toBe(
'https://default.mothership.test'
)
})
it('ignores stored and explicit environments for non-admin users', async () => {
mockRows.push({
role: 'user',
superUserModeEnabled: true,
mothershipEnvironment: 'dev',
})
await expect(getMothershipBaseURL({ userId: 'user-1', environment: 'staging' })).resolves.toBe(
'https://default.mothership.test'
)
})
it('ignores stored and explicit environments when super user mode is off', async () => {
mockRows.push({
role: 'admin',
superUserModeEnabled: false,
mothershipEnvironment: 'dev',
})
await expect(getMothershipBaseURL({ userId: 'admin-1', environment: 'prod' })).resolves.toBe(
'https://default.mothership.test'
)
})
it('uses default for super admins until they select a concrete environment', async () => {
mockRows.push({
role: 'admin',
superUserModeEnabled: true,
mothershipEnvironment: 'default',
})
await expect(getMothershipBaseURL({ userId: 'admin-1' })).resolves.toBe(
'https://default.mothership.test'
)
})
it('allows effective super admins to use a selected environment', async () => {
mockRows.push({
role: 'admin',
superUserModeEnabled: true,
mothershipEnvironment: 'dev',
})
await expect(getMothershipBaseURL({ userId: 'admin-1' })).resolves.toBe(
'https://dev.mothership.test'
)
await expect(getMothershipBaseURL({ userId: 'admin-1', environment: 'staging' })).resolves.toBe(
'https://staging.mothership.test'
)
})
})
describe('getMothershipSourceEnvHeaders', () => {
beforeEach(() => {
envMock.COPILOT_SOURCE_ENV = undefined
})
it('emits the source environment header for known hosted environments', () => {
envMock.COPILOT_SOURCE_ENV = 'dev'
expect(getMothershipSourceEnvHeaders()).toEqual({
[MOTHERSHIP_SOURCE_ENV_HEADER]: 'dev',
})
})
it('omits the source environment header for unknown values', () => {
envMock.COPILOT_SOURCE_ENV = 'local'
expect(getMothershipSourceEnvHeaders()).toEqual({})
})
})
+79
View File
@@ -0,0 +1,79 @@
import { db } from '@sim/db'
import { settings, user } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type MothershipEnvironment, mothershipEnvironmentSchema } from '@/lib/api/contracts'
import { SIM_AGENT_API_URL, SIM_AGENT_API_URL_DEFAULT } from '@/lib/copilot/constants'
import { env } from '@/lib/core/config/env'
export interface GetMothershipBaseURLOptions {
userId?: string | null
environment?: MothershipEnvironment
fallbackUrl?: string | null
}
type ConcreteMothershipEnvironment = Exclude<MothershipEnvironment, 'default'>
type MothershipSourceEnvironment = 'dev' | 'staging' | 'prod'
export const MOTHERSHIP_SOURCE_ENV_HEADER = 'X-Sim-Source-Env'
const ENVIRONMENT_URLS: Record<ConcreteMothershipEnvironment, string | undefined> = {
// env vars
dev: env.COPILOT_DEV_URL,
staging: env.COPILOT_STAGING_URL,
prod: env.COPILOT_PROD_URL,
}
const SOURCE_ENVIRONMENTS = new Set<MothershipSourceEnvironment>(['dev', 'staging', 'prod'])
function normalizeUrl(url: string | undefined): string | null {
if (!url) return null
return url.startsWith('http://') || url.startsWith('https://') ? url : null
}
function getConfiguredEnvironmentUrl(environment: MothershipEnvironment): string | null {
if (environment === 'default') return null
return normalizeUrl(ENVIRONMENT_URLS[environment])
}
function getDefaultMothershipBaseURL(fallbackUrl?: string | null): string {
const fallback = typeof fallbackUrl === 'string' ? fallbackUrl : undefined
return normalizeUrl(fallback) ?? normalizeUrl(SIM_AGENT_API_URL) ?? SIM_AGENT_API_URL_DEFAULT
}
export async function getMothershipBaseURL(
options: GetMothershipBaseURLOptions = {}
): Promise<string> {
const defaultUrl = getDefaultMothershipBaseURL(options.fallbackUrl)
const { userId } = options
if (!userId) return defaultUrl
const [row] = await db
.select({
role: user.role,
superUserModeEnabled: settings.superUserModeEnabled,
mothershipEnvironment: settings.mothershipEnvironment,
})
.from(user)
.leftJoin(settings, eq(settings.userId, user.id))
.where(eq(user.id, userId))
.limit(1)
const effectiveSuperUser = row?.role === 'admin' && (row.superUserModeEnabled ?? false)
if (!effectiveSuperUser) return defaultUrl
const selectedEnvironment = options.environment ?? row.mothershipEnvironment
const parsedEnvironment = mothershipEnvironmentSchema.safeParse(selectedEnvironment)
const environment = parsedEnvironment.success ? parsedEnvironment.data : 'default'
return getConfiguredEnvironmentUrl(environment) ?? defaultUrl
}
export function getMothershipSourceEnvHeaders(): Record<string, string> {
const sourceEnv = env.COPILOT_SOURCE_ENV?.trim().toLowerCase()
if (!sourceEnv || !SOURCE_ENVIRONMENTS.has(sourceEnv as MothershipSourceEnvironment)) {
return {}
}
return { [MOTHERSHIP_SOURCE_ENV_HEADER]: sourceEnv }
}
@@ -1,4 +1,8 @@
const HIDDEN_TOOL_NAMES = new Set(['tool_search_tool_regex', 'load_agent_skill'])
const HIDDEN_TOOL_NAMES = new Set([
'tool_search_tool_regex',
'load_agent_skill',
'load_custom_tool',
])
export function isToolHiddenInUi(toolName: string | undefined): boolean {
return !!toolName && HIDDEN_TOOL_NAMES.has(toolName)
@@ -74,4 +74,8 @@ describe('resolveToolDisplay', () => {
'Executed Deploy Api'
)
})
it('hides internal deferred tool loaders', () => {
expect(resolveToolDisplay('load_custom_tool', ClientToolCallState.executing)).toBeUndefined()
})
})
@@ -31,6 +31,7 @@ import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-ch
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import { normalizeWorkflowState } from '@/stores/workflows/workflow/validation'
import { applyOperationsToWorkflowState } from './engine'
import { formatWorkflowLintMessage, hasWorkflowLintIssues, lintEditedWorkflowState } from './lint'
import type { EditWorkflowParams, ValidationError } from './types'
import { preValidateCredentialInputs, validateWorkflowSelectorIds } from './validation'
@@ -266,6 +267,11 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, unknown>
isDeployed: false,
}
const workflowLint = lintEditedWorkflowState(workflowStateForDb as any)
const workflowLintMessage = hasWorkflowLintIssues(workflowLint)
? formatWorkflowLintMessage(workflowLint)
: undefined
assertServerToolNotAborted(context)
const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowStateForDb as any)
if (!saveResult.success) {
@@ -306,6 +312,10 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, unknown>
workflowId,
workflowName: workflowName ?? 'Workflow',
workflowState: { ...finalWorkflowState, blocks: layoutedBlocks },
...(workflowLintMessage && {
workflowLint,
workflowLintMessage,
}),
...(inputErrors && {
inputValidationErrors: inputErrors,
inputValidationMessage: `${inputErrors.length} input(s) were rejected due to validation errors. The workflow was still updated with valid inputs only. Errors: ${inputErrors.join('; ')}`,
@@ -0,0 +1,173 @@
import { describe, expect, it } from 'vitest'
import { hasWorkflowLintIssues, lintEditedWorkflowState } from './lint'
function baseBlock(id: string, type: string, name: string, subBlocks: Record<string, any> = {}) {
return {
id,
type,
name,
enabled: true,
position: { x: 0, y: 0 },
subBlocks,
outputs: {},
}
}
describe('lintEditedWorkflowState', () => {
it('reports orphan blocks and empty condition/router ports', () => {
const workflowState = {
blocks: {
start: baseBlock('start', 'starter', 'Start'),
condition: baseBlock('condition', 'condition', 'Condition', {
conditions: {
value: JSON.stringify([
{ id: 'condition-if', title: 'if', value: 'true' },
{ id: 'condition-else', title: 'else', value: '' },
]),
},
}),
router: baseBlock('router', 'router_v2', 'Router', {
routes: {
value: [
{ id: 'route-1', title: 'Route 1', value: 'support' },
{ id: 'route-2', title: 'Route 2', value: 'sales' },
],
},
}),
agent: baseBlock('agent', 'agent', 'Agent'),
function: baseBlock('function', 'function', 'Orphan Function'),
note: baseBlock('note', 'note', 'Note'),
},
edges: [
{
id: 'edge-start-condition',
source: 'start',
sourceHandle: 'source',
target: 'condition',
targetHandle: 'target',
},
{
id: 'edge-start-router',
source: 'start',
sourceHandle: 'source',
target: 'router',
targetHandle: 'target',
},
{
id: 'edge-condition-agent',
source: 'condition',
sourceHandle: 'if',
target: 'agent',
targetHandle: 'target',
},
],
}
const lint = lintEditedWorkflowState(workflowState as any)
expect(lint.orphanBlocks).toEqual([
{ blockId: 'function', blockName: 'Orphan Function', blockType: 'function' },
])
expect(lint.emptyOutgoingPorts.map((port) => `${port.blockName}.${port.label}`)).toEqual([
'Condition.else',
'Router.route-0',
'Router.route-1',
])
expect(lint.invalidBranchPorts).toEqual([])
expect(hasWorkflowLintIssues(lint)).toBe(true)
})
it('reports invalid branch handles and missing connection targets', () => {
const workflowState = {
blocks: {
start: baseBlock('start', 'starter', 'Start'),
condition: baseBlock('condition', 'condition', 'Condition', {
conditions: {
value: [{ id: 'condition-if', title: 'if', value: 'true' }],
},
}),
agent: baseBlock('agent', 'agent', 'Agent'),
},
edges: [
{
id: 'edge-start-condition',
source: 'start',
sourceHandle: 'source',
target: 'condition',
targetHandle: 'target',
},
{
id: 'edge-condition-agent',
source: 'condition',
sourceHandle: 'else',
target: 'agent',
targetHandle: 'target',
},
{
id: 'edge-agent-missing',
source: 'agent',
sourceHandle: 'source',
target: 'missing',
targetHandle: 'target',
},
],
}
const lint = lintEditedWorkflowState(workflowState as any)
expect(lint.invalidBranchPorts).toEqual([
expect.objectContaining({
blockId: 'condition',
sourceHandle: 'else',
}),
])
expect(lint.invalidConnectionTargets).toEqual([
expect.objectContaining({
sourceBlockId: 'agent',
targetBlockId: 'missing',
reason: 'Connection target block does not exist',
}),
])
expect(hasWorkflowLintIssues(lint)).toBe(true)
})
it('returns clean result when every active block and dynamic port is connected', () => {
const workflowState = {
blocks: {
start: baseBlock('start', 'starter', 'Start'),
router: baseBlock('router', 'router_v2', 'Router', {
routes: {
value: [{ id: 'route-1', title: 'Route 1', value: 'support' }],
},
}),
agent: baseBlock('agent', 'agent', 'Agent'),
},
edges: [
{
id: 'edge-start-router',
source: 'start',
sourceHandle: 'source',
target: 'router',
targetHandle: 'target',
},
{
id: 'edge-router-agent',
source: 'router',
sourceHandle: 'route-0',
target: 'agent',
targetHandle: 'target',
},
],
}
const lint = lintEditedWorkflowState(workflowState as any)
expect(lint).toEqual({
orphanBlocks: [],
emptyOutgoingPorts: [],
invalidBranchPorts: [],
invalidConnectionTargets: [],
})
expect(hasWorkflowLintIssues(lint)).toBe(false)
})
})
@@ -0,0 +1,255 @@
import { isTriggerBlockType } from '@/executor/constants'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import { validateConditionHandle, validateRouterHandle } from './validation'
type BlockState = {
id?: string
type?: string
name?: string
subBlocks?: Record<string, { value?: unknown } | undefined>
}
type EdgeState = {
source?: string | null
sourceHandle?: string | null
target?: string | null
}
export interface WorkflowLintBlockRef {
blockId: string
blockName?: string
blockType?: string
}
export interface WorkflowLintEmptyOutgoingPort extends WorkflowLintBlockRef {
handle: string
label: string
}
export interface WorkflowLintInvalidBranchPort extends WorkflowLintBlockRef {
sourceHandle: string
reason: string
}
export interface WorkflowLintInvalidConnectionTarget {
sourceBlockId: string
sourceBlockName?: string
sourceHandle?: string
targetBlockId: string
reason: string
}
export interface WorkflowLintResult {
orphanBlocks: WorkflowLintBlockRef[]
emptyOutgoingPorts: WorkflowLintEmptyOutgoingPort[]
invalidBranchPorts: WorkflowLintInvalidBranchPort[]
invalidConnectionTargets: WorkflowLintInvalidConnectionTarget[]
}
function blockRef(blockId: string, block: BlockState): WorkflowLintBlockRef {
return {
blockId,
blockName: block.name,
blockType: block.type,
}
}
function parseArrayValue(value: unknown): any[] {
if (Array.isArray(value)) return value
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
return []
}
function conditionPortLabel(title: string, elseIfIndex: number): string {
if (title === 'if') return 'if'
if (title === 'else') return 'else'
if (title === 'else if') return `else-if-${elseIfIndex}`
return title || `branch-${elseIfIndex}`
}
function conditionPorts(block: BlockState) {
const conditions = parseArrayValue(block.subBlocks?.conditions?.value)
let elseIfIndex = 0
return conditions
.map((condition, index) => {
const title = String(condition?.title ?? '').toLowerCase()
const label = conditionPortLabel(title, elseIfIndex)
if (title === 'else if') elseIfIndex++
if (!condition?.id) return null
return {
handle: `condition-${condition.id}`,
label: label || `branch-${index}`,
value: block.subBlocks?.conditions?.value,
}
})
.filter((port): port is { handle: string; label: string; value: unknown } => Boolean(port))
}
function routerPorts(block: BlockState) {
return parseArrayValue(block.subBlocks?.routes?.value)
.map((route, index) => {
if (!route?.id) return null
return {
handle: `router-${route.id}`,
label: `route-${index}`,
value: block.subBlocks?.routes?.value,
}
})
.filter((port): port is { handle: string; label: string; value: unknown } => Boolean(port))
}
function shouldLintDynamicPorts(block: BlockState) {
return block.type === 'condition' || block.type === 'router_v2'
}
export function lintEditedWorkflowState(workflowState: Pick<WorkflowState, 'blocks' | 'edges'>) {
const blocks = (workflowState.blocks || {}) as Record<string, BlockState>
const edges = Array.isArray(workflowState.edges)
? (workflowState.edges as EdgeState[])
: ([] as EdgeState[])
const incomingEdgesByTarget = new Map<string, number>()
const connectedDynamicHandles = new Map<string, Set<string>>()
const invalidBranchPorts: WorkflowLintInvalidBranchPort[] = []
const invalidConnectionTargets: WorkflowLintInvalidConnectionTarget[] = []
for (const edge of edges) {
const sourceBlockId = edge?.source || ''
const targetBlockId = edge?.target || ''
const sourceBlock = blocks[sourceBlockId]
const targetBlock = blocks[targetBlockId]
if (!sourceBlock || !targetBlock) {
invalidConnectionTargets.push({
sourceBlockId: sourceBlockId || 'unknown',
sourceBlockName: sourceBlock?.name,
sourceHandle: edge?.sourceHandle ?? undefined,
targetBlockId: targetBlockId || 'unknown',
reason: !sourceBlock
? 'Connection source block does not exist'
: 'Connection target block does not exist',
})
continue
}
incomingEdgesByTarget.set(targetBlockId, (incomingEdgesByTarget.get(targetBlockId) || 0) + 1)
if (!shouldLintDynamicPorts(sourceBlock)) continue
const sourceHandle = edge?.sourceHandle
if (!sourceHandle || sourceHandle === 'error') continue
const validation =
sourceBlock.type === 'condition'
? validateConditionHandle(
sourceHandle,
sourceBlockId,
sourceBlock.subBlocks?.conditions?.value as string | any[]
)
: validateRouterHandle(
sourceHandle,
sourceBlockId,
sourceBlock.subBlocks?.routes?.value as string | any[]
)
if (!validation.valid) {
invalidBranchPorts.push({
...blockRef(sourceBlockId, sourceBlock),
sourceHandle,
reason: validation.error || `Invalid branch handle "${sourceHandle}"`,
})
continue
}
const normalizedHandle = validation.normalizedHandle || sourceHandle
const handles = connectedDynamicHandles.get(sourceBlockId) || new Set<string>()
handles.add(normalizedHandle)
connectedDynamicHandles.set(sourceBlockId, handles)
}
const orphanBlocks = Object.entries(blocks)
.filter(([, block]) => block.type !== 'note' && !isTriggerBlockType(block.type))
.filter(([blockId]) => !incomingEdgesByTarget.has(blockId))
.map(([blockId, block]) => blockRef(blockId, block))
const emptyOutgoingPorts = Object.entries(blocks).flatMap(([blockId, block]) => {
const handles = connectedDynamicHandles.get(blockId) || new Set<string>()
const ports =
block.type === 'condition'
? conditionPorts(block)
: block.type === 'router_v2'
? routerPorts(block)
: []
return ports
.filter((port) => !handles.has(port.handle))
.map((port) => ({
...blockRef(blockId, block),
handle: port.handle,
label: port.label,
}))
})
return {
orphanBlocks,
emptyOutgoingPorts,
invalidBranchPorts,
invalidConnectionTargets,
} satisfies WorkflowLintResult
}
export function hasWorkflowLintIssues(lint: WorkflowLintResult) {
return (
lint.orphanBlocks.length > 0 ||
lint.emptyOutgoingPorts.length > 0 ||
lint.invalidBranchPorts.length > 0 ||
lint.invalidConnectionTargets.length > 0
)
}
export function formatWorkflowLintMessage(lint: WorkflowLintResult) {
const parts: string[] = []
if (lint.orphanBlocks.length > 0) {
parts.push(
`Blocks with no incoming edge: ${lint.orphanBlocks
.map((block) => `"${block.blockName || block.blockId}" (${block.blockType || 'unknown'})`)
.join(', ')}`
)
}
if (lint.emptyOutgoingPorts.length > 0) {
parts.push(
`Unconnected condition/router ports: ${lint.emptyOutgoingPorts
.map((port) => `"${port.blockName || port.blockId}".${port.label}`)
.join(', ')}`
)
}
if (lint.invalidBranchPorts.length > 0) {
parts.push(
`Invalid condition/router branch handles: ${lint.invalidBranchPorts
.map((port) => `"${port.blockName || port.blockId}" uses "${port.sourceHandle}"`)
.join(', ')}`
)
}
if (lint.invalidConnectionTargets.length > 0) {
parts.push(
`Connections pointing at missing blocks: ${lint.invalidConnectionTargets
.map((edge) => `${edge.sourceBlockId} -> ${edge.targetBlockId}`)
.join(', ')}`
)
}
return `Workflow graph lint found issues. Fix these before continuing: ${parts.join('; ')}`
}
+4
View File
@@ -37,6 +37,10 @@ export const env = createEnv({
// Copilot
COPILOT_API_KEY: z.string().min(1).optional(), // Secret for internal sim agent API authentication
SIM_AGENT_API_URL: z.string().url().optional(), // URL for internal sim agent API
COPILOT_SOURCE_ENV: z.enum(['dev', 'staging', 'prod']).optional(), // Source Sim environment sent to mothership for callbacks
COPILOT_DEV_URL: z.string().url().optional(), // Sim agent API URL for the dev mothership environment
COPILOT_STAGING_URL: z.string().url().optional(), // Sim agent API URL for the staging mothership environment
COPILOT_PROD_URL: z.string().url().optional(), // Sim agent API URL for the production mothership environment
AGENT_INDEXER_URL: z.string().url().optional(), // URL for agent training data indexer
AGENT_INDEXER_API_KEY: z.string().min(1).optional(), // API key for agent indexer authentication
COPILOT_STREAM_TTL_SECONDS: z.number().optional(), // Redis TTL for copilot SSE buffer
+25 -8
View File
@@ -18,6 +18,7 @@ import * as agentmail from '@/lib/mothership/inbox/agentmail-client'
import { formatEmailAsMessage } from '@/lib/mothership/inbox/format'
import { sendInboxResponse } from '@/lib/mothership/inbox/response'
import type { AgentMailAttachment } from '@/lib/mothership/inbox/types'
import { buildMothershipToolsForRequest } from '@/lib/mothership/settings/runtime'
import { uploadFile } from '@/lib/uploads/core/storage-service'
import { createFileContent, type MessageContent } from '@/lib/uploads/utils/file-utils'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -109,6 +110,7 @@ export async function executeInboxTask(taskId: string): Promise<void> {
requestChatTitle({
message: titleInput,
model: 'claude-opus-4-6',
userId,
})
.then(async (title) => {
if (title && chatId) {
@@ -165,14 +167,26 @@ export async function executeInboxTask(taskId: string): Promise<void> {
return { attachments, ...downloaded }
}
const [attachmentResult, workspaceContext, integrationTools, userPermission] =
await Promise.all([
fetchAttachments(),
generateWorkspaceContext(ws.id, userId),
buildIntegrationToolSchemas(userId, undefined, undefined, ws.id),
getUserEntityPermissions(userId, 'workspace', ws.id).catch(() => null),
])
const [
attachmentResult,
workspaceContext,
integrationTools,
mothershipToolRuntime,
userPermission,
] = await Promise.all([
fetchAttachments(),
generateWorkspaceContext(ws.id, userId),
buildIntegrationToolSchemas(userId, undefined, undefined, ws.id),
buildMothershipToolsForRequest({ workspaceId: ws.id, userId }),
getUserEntityPermissions(userId, 'workspace', ws.id).catch(() => null),
])
const { attachments, fileAttachments, storedAttachments } = attachmentResult
const workspaceContextWithMothershipTools = [
workspaceContext,
mothershipToolRuntime.catalogContext,
]
.filter(Boolean)
.join('\n\n')
const truncatedTask = {
...inboxTask,
@@ -188,8 +202,11 @@ export async function executeInboxTask(taskId: string): Promise<void> {
mode: 'agent',
messageId: userMessageId,
isHosted,
workspaceContext,
workspaceContext: workspaceContextWithMothershipTools,
...(integrationTools.length > 0 ? { integrationTools } : {}),
...(mothershipToolRuntime.tools.length > 0
? { mothershipTools: mothershipToolRuntime.tools }
: {}),
...(userPermission ? { userPermission } : {}),
...(fileAttachments.length > 0 ? { fileAttachments } : {}),
}
@@ -0,0 +1,165 @@
import { customTools, db, mcpServers, mothershipSettings, skill } from '@sim/db'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import type {
MothershipCustomToolRef,
MothershipMcpToolRef,
MothershipSettings,
MothershipSkillRef,
} from '@/lib/api/contracts/mothership-settings'
type MothershipSettingsInput = {
workspaceId: string
mcpTools: MothershipMcpToolRef[]
customTools: MothershipCustomToolRef[]
skills: MothershipSkillRef[]
}
function dedupeBy<T>(items: T[], getKey: (item: T) => string): T[] {
const seen = new Set<string>()
const result: T[] = []
for (const item of items) {
const key = getKey(item)
if (seen.has(key)) continue
seen.add(key)
result.push(item)
}
return result
}
function defaultSettings(workspaceId: string): MothershipSettings {
return {
workspaceId,
mcpTools: [],
customTools: [],
skills: [],
}
}
function mapRowToSettings(row: typeof mothershipSettings.$inferSelect): MothershipSettings {
return {
workspaceId: row.workspaceId,
mcpTools: Array.isArray(row.mcpToolRefs) ? (row.mcpToolRefs as MothershipMcpToolRef[]) : [],
customTools: Array.isArray(row.customToolRefs)
? (row.customToolRefs as MothershipCustomToolRef[])
: [],
skills: Array.isArray(row.skillRefs) ? (row.skillRefs as MothershipSkillRef[]) : [],
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
}
}
export async function getMothershipSettings(workspaceId: string): Promise<MothershipSettings> {
const [row] = await db
.select()
.from(mothershipSettings)
.where(eq(mothershipSettings.workspaceId, workspaceId))
.limit(1)
return row ? mapRowToSettings(row) : defaultSettings(workspaceId)
}
export async function updateMothershipSettings(
input: MothershipSettingsInput
): Promise<MothershipSettings> {
const mcpTools = await filterMcpToolRefs(input.workspaceId, input.mcpTools)
const customToolRefs = await filterCustomToolRefs(input.workspaceId, input.customTools)
const skillRefs = await filterSkillRefs(input.workspaceId, input.skills)
const now = new Date()
const [row] = await db
.insert(mothershipSettings)
.values({
workspaceId: input.workspaceId,
mcpToolRefs: mcpTools,
customToolRefs,
skillRefs,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: mothershipSettings.workspaceId,
set: {
mcpToolRefs: mcpTools,
customToolRefs,
skillRefs,
updatedAt: now,
},
})
.returning()
return mapRowToSettings(row)
}
async function filterMcpToolRefs(
workspaceId: string,
refs: MothershipMcpToolRef[]
): Promise<MothershipMcpToolRef[]> {
const deduped = dedupeBy(refs, (ref) => `${ref.serverId}:${ref.toolName}`)
const serverIds = [...new Set(deduped.map((ref) => ref.serverId))]
if (serverIds.length === 0) return []
const serverRows = await db
.select({ id: mcpServers.id, name: mcpServers.name })
.from(mcpServers)
.where(
and(
eq(mcpServers.workspaceId, workspaceId),
inArray(mcpServers.id, serverIds),
isNull(mcpServers.deletedAt)
)
)
const serversById = new Map(serverRows.map((server) => [server.id, server.name]))
return deduped
.filter((ref) => serversById.has(ref.serverId))
.map((ref) => ({
serverId: ref.serverId,
serverName: serversById.get(ref.serverId) ?? ref.serverName,
toolName: ref.toolName,
title: ref.title ?? ref.toolName,
}))
}
async function filterCustomToolRefs(
workspaceId: string,
refs: MothershipCustomToolRef[]
): Promise<MothershipCustomToolRef[]> {
const deduped = dedupeBy(refs, (ref) => ref.customToolId)
const toolIds = deduped.map((ref) => ref.customToolId)
if (toolIds.length === 0) return []
const toolRows = await db
.select({ id: customTools.id, title: customTools.title })
.from(customTools)
.where(and(eq(customTools.workspaceId, workspaceId), inArray(customTools.id, toolIds)))
const titlesById = new Map(toolRows.map((tool) => [tool.id, tool.title]))
return deduped
.filter((ref) => titlesById.has(ref.customToolId))
.map((ref) => ({
customToolId: ref.customToolId,
title: titlesById.get(ref.customToolId) ?? ref.title,
}))
}
async function filterSkillRefs(
workspaceId: string,
refs: MothershipSkillRef[]
): Promise<MothershipSkillRef[]> {
const deduped = dedupeBy(refs, (ref) => ref.skillId)
const skillIds = deduped.map((ref) => ref.skillId)
if (skillIds.length === 0) return []
const skillRows = await db
.select({ id: skill.id, name: skill.name })
.from(skill)
.where(and(eq(skill.workspaceId, workspaceId), inArray(skill.id, skillIds)))
const namesById = new Map(skillRows.map((row) => [row.id, row.name]))
return deduped
.filter((ref) => namesById.has(ref.skillId))
.map((ref) => ({
skillId: ref.skillId,
name: namesById.get(ref.skillId) ?? ref.name,
}))
}
@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { buildMothershipToolsForRequest } from './runtime'
const { dbMock, getMothershipSettingsMock, mockRows } = vi.hoisted(() => {
const mockRows: any[] = []
const dbMock = {
select: vi.fn(() => ({
from: vi.fn(() => ({
leftJoin: vi.fn(() => ({
where: vi.fn(() => ({
limit: vi.fn(async () => mockRows),
})),
})),
})),
})),
}
const getMothershipSettingsMock = vi.fn(async (workspaceId: string) => ({
workspaceId,
mcpTools: [],
customTools: [],
skills: [],
}))
return { dbMock, getMothershipSettingsMock, mockRows }
})
vi.mock('@sim/db', () => ({
db: dbMock,
customTools: {
id: 'customTools.id',
workspaceId: 'customTools.workspaceId',
title: 'customTools.title',
},
settings: {
userId: 'settings.userId',
superUserModeEnabled: 'settings.superUserModeEnabled',
},
skill: {
id: 'skill.id',
workspaceId: 'skill.workspaceId',
name: 'skill.name',
description: 'skill.description',
},
user: {
id: 'user.id',
role: 'user.role',
},
}))
vi.mock('drizzle-orm', () => ({
and: vi.fn(() => ({})),
eq: vi.fn(() => ({})),
inArray: vi.fn(() => ({})),
}))
vi.mock('@/lib/mcp/utils', () => ({
createMcpToolId: (serverId: string, toolName: string) => `${serverId}_${toolName}`,
}))
vi.mock('@/executor/constants', () => ({
AGENT: { CUSTOM_TOOL_PREFIX: 'custom_' },
}))
vi.mock('./operations', () => ({
getMothershipSettings: getMothershipSettingsMock,
}))
describe('buildMothershipToolsForRequest', () => {
beforeEach(() => {
mockRows.length = 0
dbMock.select.mockClear()
getMothershipSettingsMock.mockClear()
})
it('does not expose configured tools to non-superusers', async () => {
mockRows.push({ role: 'user', superUserModeEnabled: true })
await expect(
buildMothershipToolsForRequest({ workspaceId: 'workspace-1', userId: 'user-1' })
).resolves.toEqual({ tools: [] })
expect(getMothershipSettingsMock).not.toHaveBeenCalled()
})
it('loads workspace settings for effective superusers', async () => {
mockRows.push({ role: 'admin', superUserModeEnabled: true })
await buildMothershipToolsForRequest({ workspaceId: 'workspace-1', userId: 'admin-1' })
expect(getMothershipSettingsMock).toHaveBeenCalledWith('workspace-1')
})
})
+150
View File
@@ -0,0 +1,150 @@
import { customTools, db, skill, user, settings as userSettings } from '@sim/db'
import { and, eq, inArray } from 'drizzle-orm'
import type { ToolSchema } from '@/lib/copilot/chat/payload'
import { createMcpToolId } from '@/lib/mcp/utils'
import { AGENT } from '@/executor/constants'
import { getMothershipSettings } from './operations'
interface BuildMothershipToolsParams {
workspaceId: string
userId: string
}
interface MothershipToolRuntimePayload {
tools: ToolSchema[]
catalogContext?: string
}
function isObjectSchema(value: unknown): Record<string, unknown> {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>
}
return { type: 'object', properties: {} }
}
function customToolParameters(schema: unknown): Record<string, unknown> {
if (!schema || typeof schema !== 'object') return { type: 'object', properties: {} }
const fn = (schema as { function?: { parameters?: unknown } }).function
return isObjectSchema(fn?.parameters)
}
function customToolDescription(schema: unknown, fallback: string): string {
if (!schema || typeof schema !== 'object') return fallback
const description = (schema as { function?: { description?: unknown } }).function?.description
return typeof description === 'string' && description.trim() ? description : fallback
}
async function isEffectiveSuperUser(userId: string): Promise<boolean> {
if (!userId) return false
const [row] = await db
.select({
role: user.role,
superUserModeEnabled: userSettings.superUserModeEnabled,
})
.from(user)
.leftJoin(userSettings, eq(userSettings.userId, user.id))
.where(eq(user.id, userId))
.limit(1)
return row?.role === 'admin' && (row.superUserModeEnabled ?? false)
}
export async function buildMothershipToolsForRequest({
workspaceId,
userId,
}: BuildMothershipToolsParams): Promise<MothershipToolRuntimePayload> {
if (!(await isEffectiveSuperUser(userId))) {
return { tools: [] }
}
const settings = await getMothershipSettings(workspaceId)
const tools: ToolSchema[] = []
const catalogLines: string[] = []
if (settings.mcpTools.length > 0) {
const selectedKeys = new Set(
settings.mcpTools.map((tool) => `${tool.serverId}:${tool.toolName}`)
)
const { mcpService } = await import('@/lib/mcp/service')
const discoveredTools = await mcpService.discoverTools(userId, workspaceId)
for (const tool of discoveredTools) {
if (!selectedKeys.has(`${tool.serverId}:${tool.name}`)) continue
const catalogName = `${tool.serverName} / ${tool.name}`
tools.push({
name: createMcpToolId(tool.serverId, tool.name),
description: tool.description || `MCP tool: ${tool.name} (${tool.serverName})`,
input_schema: { ...tool.inputSchema },
defer_loading: true,
params: {
mothershipToolKind: 'mcp',
mothershipToolName: catalogName,
mothershipToolTitle: tool.name,
},
})
catalogLines.push(`- MCP: ${catalogName} (load with type "mcp" and name "${catalogName}")`)
}
}
if (settings.customTools.length > 0) {
const customToolIds = settings.customTools.map((tool) => tool.customToolId)
const rows = await db
.select()
.from(customTools)
.where(and(eq(customTools.workspaceId, workspaceId), inArray(customTools.id, customToolIds)))
for (const tool of rows) {
tools.push({
name: `${AGENT.CUSTOM_TOOL_PREFIX}${tool.id}`,
description: customToolDescription(tool.schema, tool.title),
input_schema: customToolParameters(tool.schema),
defer_loading: true,
params: {
mothershipToolKind: 'custom_tool',
mothershipToolName: tool.title,
mothershipToolTitle: tool.title,
},
})
catalogLines.push(
`- Custom tool: ${tool.title} (load with type "custom_tool" and name "${tool.title}")`
)
}
}
if (settings.skills.length > 0) {
const skillIds = settings.skills.map((s) => s.skillId)
const rows = await db
.select({ id: skill.id, name: skill.name, description: skill.description })
.from(skill)
.where(and(eq(skill.workspaceId, workspaceId), inArray(skill.id, skillIds)))
for (const s of rows) {
tools.push({
name: `load_skill_${s.id}`,
description: `Load the "${s.name}" skill to get specialized instructions. ${s.description}`,
input_schema: { type: 'object', properties: {} },
defer_loading: true,
params: {
mothershipToolKind: 'skill',
mothershipToolName: s.name,
mothershipToolTitle: s.name,
},
})
catalogLines.push(
`- Skill: ${s.name} - ${s.description} (load with type "skill" and name "${s.name}")`
)
}
}
return {
tools,
catalogContext:
catalogLines.length > 0
? [
'## Mothership Tool Catalog',
'The following workspace tools are available on request. Use `load_custom_tool` to load one before calling it.',
...catalogLines,
].join('\n')
: undefined,
}
}
+28 -2
View File
@@ -19,7 +19,10 @@ import { parseMcpToolId } from '@/lib/mcp/utils'
import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { assertPermissionsAllowed } from '@/ee/access-control/utils/permission-check'
import { isCustomTool, isMcpTool } from '@/executor/constants'
import { resolveSkillContent } from '@/executor/handlers/agent/skills-resolver'
import {
resolveSkillContent,
resolveSkillContentById,
} from '@/executor/handlers/agent/skills-resolver'
import type { ExecutionContext, UserFile } from '@/executor/types'
import type { ErrorInfo } from '@/tools/error-extractors'
import { extractErrorMessage } from '@/tools/error-extractors'
@@ -731,7 +734,7 @@ export async function executeTool(
const scope = resolveToolScope(params, executionContext)
const toolKind: 'skill' | 'custom' | 'mcp' | undefined =
normalizedToolId === 'load_skill'
normalizedToolId === 'load_skill' || toolId.startsWith('load_skill_')
? 'skill'
: isCustomTool(normalizedToolId)
? 'custom'
@@ -748,6 +751,29 @@ export async function executeTool(
})
}
if (toolId.startsWith('load_skill_')) {
const skillId = toolId.slice('load_skill_'.length)
if (!skillId || !scope.workspaceId) {
return {
success: false,
output: { error: 'Missing skill id or workspace context' },
error: 'Missing skill id or workspace context',
}
}
const loadedSkill = await resolveSkillContentById(skillId, scope.workspaceId)
if (!loadedSkill) {
return {
success: false,
output: { error: `Skill "${skillId}" not found` },
error: `Skill "${skillId}" not found`,
}
}
return {
success: true,
output: { name: loadedSkill.name, content: loadedSkill.content },
}
}
if (normalizedToolId === 'load_skill') {
const skillName = params.skill_name
if (!skillName || !scope.workspaceId) {
-1
View File
@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "simstudio",
@@ -0,0 +1,12 @@
CREATE TABLE "mothership_settings" (
"workspace_id" text PRIMARY KEY NOT NULL,
"mcp_tool_refs" jsonb DEFAULT '[]'::jsonb NOT NULL,
"custom_tool_refs" jsonb DEFAULT '[]'::jsonb NOT NULL,
"skill_refs" jsonb DEFAULT '[]'::jsonb NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "settings" ADD COLUMN "mothership_environment" text DEFAULT 'default' NOT NULL;--> statement-breakpoint
ALTER TABLE "mothership_settings" ADD CONSTRAINT "mothership_settings_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "mothership_settings_workspace_id_idx" ON "mothership_settings" USING btree ("workspace_id");
File diff suppressed because it is too large Load Diff
@@ -1429,6 +1429,13 @@
"when": 1778024401275,
"tag": "0204_powerful_medusa",
"breakpoints": true
},
{
"idx": 205,
"version": "7",
"when": 1778540313360,
"tag": "0205_smooth_sentinel",
"breakpoints": true
}
]
}
+18
View File
@@ -495,6 +495,7 @@ export const settings = pgTable('settings', {
// UI preferences
showTrainingControls: boolean('show_training_controls').notNull().default(false),
superUserModeEnabled: boolean('super_user_mode_enabled').notNull().default(true),
mothershipEnvironment: text('mothership_environment').notNull().default('default'),
// Notification preferences
errorNotificationsEnabled: boolean('error_notifications_enabled').notNull().default(true),
@@ -858,6 +859,23 @@ export const skill = pgTable(
})
)
export const mothershipSettings = pgTable(
'mothership_settings',
{
workspaceId: text('workspace_id')
.primaryKey()
.references(() => workspace.id, { onDelete: 'cascade' }),
mcpToolRefs: jsonb('mcp_tool_refs').notNull().default(sql`'[]'::jsonb`),
customToolRefs: jsonb('custom_tool_refs').notNull().default(sql`'[]'::jsonb`),
skillRefs: jsonb('skill_refs').notNull().default(sql`'[]'::jsonb`),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
workspaceIdIdx: index('mothership_settings_workspace_id_idx').on(table.workspaceId),
})
)
export const subscription = pgTable(
'subscription',
{
+2 -2
View File
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
const BASELINE = {
totalRoutes: 735,
zodRoutes: 735,
totalRoutes: 736,
zodRoutes: 736,
nonZodRoutes: 0,
} as const