mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-01 14:59:19 +08:00
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:
committed by
GitHub
parent
8774f5c341
commit
0b2cfaf7f7
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,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,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
|
||||
})
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
+3
-6
@@ -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,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),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
})
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({})
|
||||
})
|
||||
})
|
||||
@@ -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('; ')}`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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,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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user