Compare commits

..

11 Commits

Author SHA1 Message Date
Theodore Li
498504c35b Fix import ordering 2026-04-04 16:49:09 -07:00
Theodore Li
c367da81fb feat(block): Add cloudwatch block (#3911)
* feat(block): add cloudwatch integration

* Fix bun lock

* Add logger, use execution timeout

* Switch metric dimensions to map style input

* Fix attribute names for dimension map

* Fix import styling

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-04-04 16:35:36 -07:00
abhinavDhulipala
7971a64e63 fix(setup): db migrate hard fail and correct ini env (#3946) 2026-04-04 16:22:19 -07:00
abhinavDhulipala
f39b4c74dc fix(setup): bun run prepare explicitly (#3947) 2026-04-04 16:13:53 -07:00
Waleed
0ba8ab1ec7 fix(posthog): upgrade SDKs and fix serverless event flushing (#3951)
* fix(posthog): upgrade SDKs and fix serverless event flushing

* fix(posthog): revert flushAt to 20 for long-running ECS container
2026-04-04 16:11:35 -07:00
Waleed
039e57541e fix(csp): allow Cloudflare Turnstile domains for script, frame, and connect (#3948) 2026-04-04 15:54:14 -07:00
Theodore Li
75f8c6ad7e fix(ui): persist active resource tab in url, fix internal markdown links (#3925)
* fix(ui): handle markdown internal links

* Fix lint

* Reference correct scroll container

* Add resource tab to url state, scroll correctly on new tab

* Handle delete all resource by clearing url

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-04-04 18:25:35 -04:00
Waleed
c2b12cf21f fix(captcha): use getResponsePromise for Turnstile execute-on-submit flow (#3943) 2026-04-04 12:34:53 -07:00
Waleed
4a9439e952 improvement(models): tighten model metadata and crawl discovery (#3942)
* improvement(models): tighten model metadata and crawl discovery

Made-with: Cursor

* revert hardcoded FF

* fix(models): narrow structured output ranking signal

Made-with: Cursor

* fix(models): remove generic best-for copy

Made-with: Cursor

* fix(models): restore best-for with stricter criteria

Made-with: Cursor

* fix

* models
2026-04-04 11:53:54 -07:00
Waleed
893e322a49 fix(envvars): restore workflowUserId fallback for scheduled execution env var resolution (#3941)
* fix(envvars): restore workflowUserId fallback for scheduled execution env var resolution

* test(envvars): add coverage for env var user resolution branches
2026-04-04 11:22:52 -07:00
Emir Karabeg
b0cb95be2f feat: mothership/copilot feedback (#3940)
* feat: mothership/copilot feedback

* fix(feedback): remove mutation object from useCallback deps
2026-04-04 10:46:49 -07:00
57 changed files with 3344 additions and 496 deletions

View File

@@ -90,6 +90,7 @@ Sim also supports local models via [Ollama](https://ollama.ai) and [vLLM](https:
git clone https://github.com/simstudioai/sim.git
cd sim
bun install
bun run prepare # Set up pre-commit hooks
```
2. Set up PostgreSQL with pgvector:
@@ -104,6 +105,11 @@ Or install manually via the [pgvector guide](https://github.com/pgvector/pgvecto
```bash
cp apps/sim/.env.example apps/sim/.env
# Create your secrets
perl -i -pe "s/your_encryption_key/$(openssl rand -hex 32)/" apps/sim/.env
perl -i -pe "s/your_internal_api_secret/$(openssl rand -hex 32)/" apps/sim/.env
perl -i -pe "s/your_api_encryption_key/$(openssl rand -hex 32)/" apps/sim/.env
# DB configs for migration
cp packages/db/.env.example packages/db/.env
# Edit both .env files to set DATABASE_URL="postgresql://postgres:your_password@localhost:5432/simstudio"
```
@@ -111,7 +117,7 @@ cp packages/db/.env.example packages/db/.env
4. Run migrations:
```bash
cd packages/db && bunx drizzle-kit migrate --config=./drizzle.config.ts
cd packages/db && bun run db:migrate
```
5. Start development servers:

View File

@@ -1,8 +1,5 @@
# Database (Required)
DATABASE_URL="postgresql://postgres:password@localhost:5432/postgres"
# PostgreSQL Port (Optional) - defaults to 5432 if not specified
# POSTGRES_PORT=5432
DATABASE_URL="postgresql://postgres:your_password@localhost:5432/simstudio"
# Authentication (Required unless DISABLE_AUTH=true)
BETTER_AUTH_SECRET=your_secret_key # Use `openssl rand -hex 32` to generate, or visit https://www.better-auth.com/docs/installation

View File

@@ -99,8 +99,6 @@ function SignupFormContent({
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
const [formError, setFormError] = useState<string | null>(null)
const turnstileRef = useRef<TurnstileInstance>(null)
const captchaResolveRef = useRef<((token: string) => void) | null>(null)
const captchaRejectRef = useRef<((reason: Error) => void) | null>(null)
const turnstileSiteKey = useMemo(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'), [])
const redirectUrl = useMemo(
() => searchParams.get('redirect') || searchParams.get('callbackUrl') || '',
@@ -258,27 +256,14 @@ function SignupFormContent({
let token: string | undefined
const widget = turnstileRef.current
if (turnstileSiteKey && widget) {
let timeoutId: ReturnType<typeof setTimeout> | undefined
try {
widget.reset()
token = await Promise.race([
new Promise<string>((resolve, reject) => {
captchaResolveRef.current = resolve
captchaRejectRef.current = reject
widget.execute()
}),
new Promise<string>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error('Captcha timed out')), 15_000)
}),
])
widget.execute()
token = await widget.getResponsePromise()
} catch {
setFormError('Captcha verification failed. Please try again.')
setIsLoading(false)
return
} finally {
clearTimeout(timeoutId)
captchaResolveRef.current = null
captchaRejectRef.current = null
}
}
@@ -535,10 +520,7 @@ function SignupFormContent({
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
onSuccess={(token) => captchaResolveRef.current?.(token)}
onError={() => captchaRejectRef.current?.(new Error('Captcha verification failed'))}
onExpire={() => captchaRejectRef.current?.(new Error('Captcha token expired'))}
options={{ execution: 'execute' }}
options={{ execution: 'execute', appearance: 'execute' }}
/>
)}

View File

@@ -18,6 +18,7 @@ import {
formatPrice,
formatTokenCount,
formatUpdatedAt,
getEffectiveMaxOutputTokens,
getModelBySlug,
getPricingBounds,
getProviderBySlug,
@@ -198,7 +199,8 @@ export default async function ModelPage({
</div>
<p className='max-w-[820px] text-[17px] text-[var(--landing-text-muted)] leading-relaxed'>
{model.summary} {model.bestFor}
{model.summary}
{model.bestFor ? ` ${model.bestFor}` : ''}
</p>
<div className='mt-8 flex flex-wrap gap-3'>
@@ -229,13 +231,11 @@ export default async function ModelPage({
? `${formatPrice(model.pricing.cachedInput)}/1M`
: 'N/A'
}
compact
/>
<StatCard label='Output price' value={`${formatPrice(model.pricing.output)}/1M`} />
<StatCard
label='Context window'
value={model.contextWindow ? formatTokenCount(model.contextWindow) : 'Unknown'}
compact
/>
</section>
@@ -280,12 +280,12 @@ export default async function ModelPage({
label='Max output'
value={
model.capabilities.maxOutputTokens
? `${formatTokenCount(model.capabilities.maxOutputTokens)} tokens`
: 'Standard defaults'
? `${formatTokenCount(getEffectiveMaxOutputTokens(model.capabilities))} tokens`
: 'Not published'
}
/>
<DetailItem label='Provider' value={provider.name} />
<DetailItem label='Best for' value={model.bestFor} />
{model.bestFor ? <DetailItem label='Best for' value={model.bestFor} /> : null}
</div>
</section>

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { buildModelCapabilityFacts, getEffectiveMaxOutputTokens, getModelBySlug } from './utils'
describe('model catalog capability facts', () => {
it.concurrent(
'shows structured outputs support and published max output tokens for gpt-4o',
() => {
const model = getModelBySlug('openai', 'gpt-4o')
expect(model).not.toBeNull()
expect(model).toBeDefined()
const capabilityFacts = buildModelCapabilityFacts(model!)
const structuredOutputs = capabilityFacts.find((fact) => fact.label === 'Structured outputs')
const maxOutputTokens = capabilityFacts.find((fact) => fact.label === 'Max output tokens')
expect(getEffectiveMaxOutputTokens(model!.capabilities)).toBe(16384)
expect(structuredOutputs?.value).toBe('Supported')
expect(maxOutputTokens?.value).toBe('16k')
}
)
it.concurrent('preserves native structured outputs labeling for claude models', () => {
const model = getModelBySlug('anthropic', 'claude-sonnet-4-6')
expect(model).not.toBeNull()
expect(model).toBeDefined()
const capabilityFacts = buildModelCapabilityFacts(model!)
const structuredOutputs = capabilityFacts.find((fact) => fact.label === 'Structured outputs')
expect(structuredOutputs?.value).toBe('Supported (native)')
})
it.concurrent('does not invent a max output token limit when one is not published', () => {
expect(getEffectiveMaxOutputTokens({})).toBeNull()
})
it.concurrent('keeps best-for copy for clearly differentiated models only', () => {
const researchModel = getModelBySlug('google', 'deep-research-pro-preview-12-2025')
const generalModel = getModelBySlug('xai', 'grok-4-latest')
expect(researchModel).not.toBeNull()
expect(generalModel).not.toBeNull()
expect(researchModel?.bestFor).toContain('research workflows')
expect(generalModel?.bestFor).toBeUndefined()
})
})

View File

@@ -112,7 +112,7 @@ export interface CatalogModel {
capabilities: ModelCapabilities
capabilityTags: string[]
summary: string
bestFor: string
bestFor?: string
searchText: string
}
@@ -190,6 +190,14 @@ export function formatCapabilityBoolean(
return value ? positive : negative
}
function supportsCatalogStructuredOutputs(capabilities: ModelCapabilities): boolean {
return !capabilities.deepResearch
}
export function getEffectiveMaxOutputTokens(capabilities: ModelCapabilities): number | null {
return capabilities.maxOutputTokens ?? null
}
function trimTrailingZeros(value: string): string {
return value.replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1')
}
@@ -326,7 +334,7 @@ function buildCapabilityTags(capabilities: ModelCapabilities): string[] {
tags.push('Tool choice')
}
if (capabilities.nativeStructuredOutputs) {
if (supportsCatalogStructuredOutputs(capabilities)) {
tags.push('Structured outputs')
}
@@ -365,7 +373,7 @@ function buildBestForLine(model: {
pricing: PricingInfo
capabilities: ModelCapabilities
contextWindow: number | null
}): string {
}): string | null {
const { pricing, capabilities, contextWindow } = model
if (capabilities.deepResearch) {
@@ -376,10 +384,6 @@ function buildBestForLine(model: {
return 'Best for reasoning-heavy tasks that need more deliberate model control.'
}
if (pricing.input <= 0.2 && pricing.output <= 1.25) {
return 'Best for cost-sensitive automations, background tasks, and high-volume workloads.'
}
if (contextWindow && contextWindow >= 1000000) {
return 'Best for long-context retrieval, large documents, and high-memory workflows.'
}
@@ -388,7 +392,11 @@ function buildBestForLine(model: {
return 'Best for production workflows that need reliable typed outputs.'
}
return 'Best for general-purpose AI workflows inside Sim.'
if (pricing.input <= 0.2 && pricing.output <= 1.25) {
return 'Best for cost-sensitive automations, background tasks, and high-volume workloads.'
}
return null
}
function buildModelSummary(
@@ -437,6 +445,11 @@ const rawProviders = Object.values(PROVIDER_DEFINITIONS).map((provider) => {
const shortId = stripProviderPrefix(provider.id, model.id)
const mergedCapabilities = { ...provider.capabilities, ...model.capabilities }
const capabilityTags = buildCapabilityTags(mergedCapabilities)
const bestFor = buildBestForLine({
pricing: model.pricing,
capabilities: mergedCapabilities,
contextWindow: model.contextWindow ?? null,
})
const displayName = formatModelDisplayName(provider.id, model.id)
const modelSlug = slugify(shortId)
const href = `/models/${providerSlug}/${modelSlug}`
@@ -461,11 +474,7 @@ const rawProviders = Object.values(PROVIDER_DEFINITIONS).map((provider) => {
model.contextWindow ?? null,
capabilityTags
),
bestFor: buildBestForLine({
pricing: model.pricing,
capabilities: mergedCapabilities,
contextWindow: model.contextWindow ?? null,
}),
...(bestFor ? { bestFor } : {}),
searchText: [
provider.name,
providerDisplayName,
@@ -683,6 +692,7 @@ export function buildModelFaqs(provider: CatalogProvider, model: CatalogModel):
export function buildModelCapabilityFacts(model: CatalogModel): CapabilityFact[] {
const { capabilities } = model
const supportsStructuredOutputs = supportsCatalogStructuredOutputs(capabilities)
return [
{
@@ -711,7 +721,11 @@ export function buildModelCapabilityFacts(model: CatalogModel): CapabilityFact[]
},
{
label: 'Structured outputs',
value: formatCapabilityBoolean(capabilities.nativeStructuredOutputs),
value: supportsStructuredOutputs
? capabilities.nativeStructuredOutputs
? 'Supported (native)'
: 'Supported'
: 'Not supported',
},
{
label: 'Tool choice',
@@ -732,8 +746,8 @@ export function buildModelCapabilityFacts(model: CatalogModel): CapabilityFact[]
{
label: 'Max output tokens',
value: capabilities.maxOutputTokens
? formatTokenCount(capabilities.maxOutputTokens)
: 'Standard defaults',
? formatTokenCount(getEffectiveMaxOutputTokens(capabilities))
: 'Not published',
},
]
}
@@ -752,8 +766,8 @@ export function getProviderCapabilitySummary(provider: CatalogProvider): Capabil
const reasoningCount = provider.models.filter(
(model) => model.capabilities.reasoningEffort || model.capabilities.thinking
).length
const structuredCount = provider.models.filter(
(model) => model.capabilities.nativeStructuredOutputs
const structuredCount = provider.models.filter((model) =>
supportsCatalogStructuredOutputs(model.capabilities)
).length
const deepResearchCount = provider.models.filter(
(model) => model.capabilities.deepResearch

View File

@@ -0,0 +1,96 @@
import {
type AlarmType,
CloudWatchClient,
DescribeAlarmsCommand,
type StateValue,
} from '@aws-sdk/client-cloudwatch'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
const logger = createLogger('CloudWatchDescribeAlarms')
const DescribeAlarmsSchema = z.object({
region: z.string().min(1, 'AWS region is required'),
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
alarmNamePrefix: z.string().optional(),
stateValue: z.preprocess(
(v) => (v === '' ? undefined : v),
z.enum(['OK', 'ALARM', 'INSUFFICIENT_DATA']).optional()
),
alarmType: z.preprocess(
(v) => (v === '' ? undefined : v),
z.enum(['MetricAlarm', 'CompositeAlarm']).optional()
),
limit: z.preprocess(
(v) => (v === '' || v === undefined || v === null ? undefined : v),
z.number({ coerce: true }).int().positive().optional()
),
})
export async function POST(request: NextRequest) {
try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const validatedData = DescribeAlarmsSchema.parse(body)
const client = new CloudWatchClient({
region: validatedData.region,
credentials: {
accessKeyId: validatedData.accessKeyId,
secretAccessKey: validatedData.secretAccessKey,
},
})
const command = new DescribeAlarmsCommand({
...(validatedData.alarmNamePrefix && { AlarmNamePrefix: validatedData.alarmNamePrefix }),
...(validatedData.stateValue && { StateValue: validatedData.stateValue as StateValue }),
...(validatedData.alarmType && { AlarmTypes: [validatedData.alarmType as AlarmType] }),
...(validatedData.limit !== undefined && { MaxRecords: validatedData.limit }),
})
const response = await client.send(command)
const metricAlarms = (response.MetricAlarms ?? []).map((a) => ({
alarmName: a.AlarmName ?? '',
alarmArn: a.AlarmArn ?? '',
stateValue: a.StateValue ?? 'UNKNOWN',
stateReason: a.StateReason ?? '',
metricName: a.MetricName,
namespace: a.Namespace,
comparisonOperator: a.ComparisonOperator,
threshold: a.Threshold,
evaluationPeriods: a.EvaluationPeriods,
stateUpdatedTimestamp: a.StateUpdatedTimestamp?.getTime(),
}))
const compositeAlarms = (response.CompositeAlarms ?? []).map((a) => ({
alarmName: a.AlarmName ?? '',
alarmArn: a.AlarmArn ?? '',
stateValue: a.StateValue ?? 'UNKNOWN',
stateReason: a.StateReason ?? '',
metricName: undefined,
namespace: undefined,
comparisonOperator: undefined,
threshold: undefined,
evaluationPeriods: undefined,
stateUpdatedTimestamp: a.StateUpdatedTimestamp?.getTime(),
}))
return NextResponse.json({
success: true,
output: { alarms: [...metricAlarms, ...compositeAlarms] },
})
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to describe CloudWatch alarms'
logger.error('DescribeAlarms failed', { error: errorMessage })
return NextResponse.json({ error: errorMessage }, { status: 500 })
}
}

View File

@@ -0,0 +1,62 @@
import { DescribeLogGroupsCommand } from '@aws-sdk/client-cloudwatch-logs'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { createCloudWatchLogsClient } from '@/app/api/tools/cloudwatch/utils'
const logger = createLogger('CloudWatchDescribeLogGroups')
const DescribeLogGroupsSchema = z.object({
region: z.string().min(1, 'AWS region is required'),
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
prefix: z.string().optional(),
limit: z.preprocess(
(v) => (v === '' || v === undefined || v === null ? undefined : v),
z.number({ coerce: true }).int().positive().optional()
),
})
export async function POST(request: NextRequest) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const validatedData = DescribeLogGroupsSchema.parse(body)
const client = createCloudWatchLogsClient({
region: validatedData.region,
accessKeyId: validatedData.accessKeyId,
secretAccessKey: validatedData.secretAccessKey,
})
const command = new DescribeLogGroupsCommand({
...(validatedData.prefix && { logGroupNamePrefix: validatedData.prefix }),
...(validatedData.limit !== undefined && { limit: validatedData.limit }),
})
const response = await client.send(command)
const logGroups = (response.logGroups ?? []).map((lg) => ({
logGroupName: lg.logGroupName ?? '',
arn: lg.arn ?? '',
storedBytes: lg.storedBytes ?? 0,
retentionInDays: lg.retentionInDays,
creationTime: lg.creationTime,
}))
return NextResponse.json({
success: true,
output: { logGroups },
})
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to describe CloudWatch log groups'
logger.error('DescribeLogGroups failed', { error: errorMessage })
return NextResponse.json({ error: errorMessage }, { status: 500 })
}
}

View File

@@ -0,0 +1,52 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { createCloudWatchLogsClient, describeLogStreams } from '@/app/api/tools/cloudwatch/utils'
const logger = createLogger('CloudWatchDescribeLogStreams')
const DescribeLogStreamsSchema = z.object({
region: z.string().min(1, 'AWS region is required'),
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
logGroupName: z.string().min(1, 'Log group name is required'),
prefix: z.string().optional(),
limit: z.preprocess(
(v) => (v === '' || v === undefined || v === null ? undefined : v),
z.number({ coerce: true }).int().positive().optional()
),
})
export async function POST(request: NextRequest) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const validatedData = DescribeLogStreamsSchema.parse(body)
const client = createCloudWatchLogsClient({
region: validatedData.region,
accessKeyId: validatedData.accessKeyId,
secretAccessKey: validatedData.secretAccessKey,
})
const result = await describeLogStreams(client, validatedData.logGroupName, {
prefix: validatedData.prefix,
limit: validatedData.limit,
})
return NextResponse.json({
success: true,
output: { logStreams: result.logStreams },
})
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to describe CloudWatch log streams'
logger.error('DescribeLogStreams failed', { error: errorMessage })
return NextResponse.json({ error: errorMessage }, { status: 500 })
}
}

View File

@@ -0,0 +1,60 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createCloudWatchLogsClient, getLogEvents } from '@/app/api/tools/cloudwatch/utils'
const logger = createLogger('CloudWatchGetLogEvents')
const GetLogEventsSchema = z.object({
region: z.string().min(1, 'AWS region is required'),
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
logGroupName: z.string().min(1, 'Log group name is required'),
logStreamName: z.string().min(1, 'Log stream name is required'),
startTime: z.number({ coerce: true }).int().optional(),
endTime: z.number({ coerce: true }).int().optional(),
limit: z.preprocess(
(v) => (v === '' || v === undefined || v === null ? undefined : v),
z.number({ coerce: true }).int().positive().optional()
),
})
export async function POST(request: NextRequest) {
try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const validatedData = GetLogEventsSchema.parse(body)
const client = createCloudWatchLogsClient({
region: validatedData.region,
accessKeyId: validatedData.accessKeyId,
secretAccessKey: validatedData.secretAccessKey,
})
const result = await getLogEvents(
client,
validatedData.logGroupName,
validatedData.logStreamName,
{
startTime: validatedData.startTime,
endTime: validatedData.endTime,
limit: validatedData.limit,
}
)
return NextResponse.json({
success: true,
output: { events: result.events },
})
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to get CloudWatch log events'
logger.error('GetLogEvents failed', { error: errorMessage })
return NextResponse.json({ error: errorMessage }, { status: 500 })
}
}

View File

@@ -0,0 +1,97 @@
import { CloudWatchClient, GetMetricStatisticsCommand } from '@aws-sdk/client-cloudwatch'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
const logger = createLogger('CloudWatchGetMetricStatistics')
const GetMetricStatisticsSchema = z.object({
region: z.string().min(1, 'AWS region is required'),
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
namespace: z.string().min(1, 'Namespace is required'),
metricName: z.string().min(1, 'Metric name is required'),
startTime: z.number({ coerce: true }).int(),
endTime: z.number({ coerce: true }).int(),
period: z.number({ coerce: true }).int().min(1),
statistics: z.array(z.enum(['Average', 'Sum', 'Minimum', 'Maximum', 'SampleCount'])).min(1),
dimensions: z.string().optional(),
})
export async function POST(request: NextRequest) {
try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const validatedData = GetMetricStatisticsSchema.parse(body)
const client = new CloudWatchClient({
region: validatedData.region,
credentials: {
accessKeyId: validatedData.accessKeyId,
secretAccessKey: validatedData.secretAccessKey,
},
})
let parsedDimensions: { Name: string; Value: string }[] | undefined
if (validatedData.dimensions) {
try {
const dims = JSON.parse(validatedData.dimensions)
if (Array.isArray(dims)) {
parsedDimensions = dims.map((d: Record<string, string>) => ({
Name: d.name,
Value: d.value,
}))
} else if (typeof dims === 'object') {
parsedDimensions = Object.entries(dims).map(([name, value]) => ({
Name: name,
Value: String(value),
}))
}
} catch {
throw new Error('Invalid dimensions JSON')
}
}
const command = new GetMetricStatisticsCommand({
Namespace: validatedData.namespace,
MetricName: validatedData.metricName,
StartTime: new Date(validatedData.startTime * 1000),
EndTime: new Date(validatedData.endTime * 1000),
Period: validatedData.period,
Statistics: validatedData.statistics,
...(parsedDimensions && { Dimensions: parsedDimensions }),
})
const response = await client.send(command)
const datapoints = (response.Datapoints ?? [])
.sort((a, b) => (a.Timestamp?.getTime() ?? 0) - (b.Timestamp?.getTime() ?? 0))
.map((dp) => ({
timestamp: dp.Timestamp ? Math.floor(dp.Timestamp.getTime() / 1000) : 0,
average: dp.Average,
sum: dp.Sum,
minimum: dp.Minimum,
maximum: dp.Maximum,
sampleCount: dp.SampleCount,
unit: dp.Unit,
}))
return NextResponse.json({
success: true,
output: {
label: response.Label ?? validatedData.metricName,
datapoints,
},
})
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to get CloudWatch metric statistics'
logger.error('GetMetricStatistics failed', { error: errorMessage })
return NextResponse.json({ error: errorMessage }, { status: 500 })
}
}

View File

@@ -0,0 +1,67 @@
import { CloudWatchClient, ListMetricsCommand } from '@aws-sdk/client-cloudwatch'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
const logger = createLogger('CloudWatchListMetrics')
const ListMetricsSchema = z.object({
region: z.string().min(1, 'AWS region is required'),
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
namespace: z.string().optional(),
metricName: z.string().optional(),
recentlyActive: z.boolean().optional(),
limit: z.preprocess(
(v) => (v === '' || v === undefined || v === null ? undefined : v),
z.number({ coerce: true }).int().positive().optional()
),
})
export async function POST(request: NextRequest) {
try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const validatedData = ListMetricsSchema.parse(body)
const client = new CloudWatchClient({
region: validatedData.region,
credentials: {
accessKeyId: validatedData.accessKeyId,
secretAccessKey: validatedData.secretAccessKey,
},
})
const command = new ListMetricsCommand({
...(validatedData.namespace && { Namespace: validatedData.namespace }),
...(validatedData.metricName && { MetricName: validatedData.metricName }),
...(validatedData.recentlyActive && { RecentlyActive: 'PT3H' }),
})
const response = await client.send(command)
const metrics = (response.Metrics ?? []).slice(0, validatedData.limit ?? 500).map((m) => ({
namespace: m.Namespace ?? '',
metricName: m.MetricName ?? '',
dimensions: (m.Dimensions ?? []).map((d) => ({
name: d.Name ?? '',
value: d.Value ?? '',
})),
}))
return NextResponse.json({
success: true,
output: { metrics },
})
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to list CloudWatch metrics'
logger.error('ListMetrics failed', { error: errorMessage })
return NextResponse.json({ error: errorMessage }, { status: 500 })
}
}

View File

@@ -0,0 +1,71 @@
import { StartQueryCommand } from '@aws-sdk/client-cloudwatch-logs'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createCloudWatchLogsClient, pollQueryResults } from '@/app/api/tools/cloudwatch/utils'
const logger = createLogger('CloudWatchQueryLogs')
const QueryLogsSchema = z.object({
region: z.string().min(1, 'AWS region is required'),
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
logGroupNames: z.array(z.string().min(1)).min(1, 'At least one log group name is required'),
queryString: z.string().min(1, 'Query string is required'),
startTime: z.number({ coerce: true }).int(),
endTime: z.number({ coerce: true }).int(),
limit: z.preprocess(
(v) => (v === '' || v === undefined || v === null ? undefined : v),
z.number({ coerce: true }).int().positive().optional()
),
})
export async function POST(request: NextRequest) {
try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const validatedData = QueryLogsSchema.parse(body)
const client = createCloudWatchLogsClient({
region: validatedData.region,
accessKeyId: validatedData.accessKeyId,
secretAccessKey: validatedData.secretAccessKey,
})
const startQueryCommand = new StartQueryCommand({
logGroupNames: validatedData.logGroupNames,
queryString: validatedData.queryString,
startTime: validatedData.startTime,
endTime: validatedData.endTime,
...(validatedData.limit !== undefined && { limit: validatedData.limit }),
})
const startQueryResponse = await client.send(startQueryCommand)
const queryId = startQueryResponse.queryId
if (!queryId) {
throw new Error('Failed to start CloudWatch Log Insights query: no queryId returned')
}
const result = await pollQueryResults(client, queryId)
return NextResponse.json({
success: true,
output: {
results: result.results,
statistics: result.statistics,
status: result.status,
},
})
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'CloudWatch Log Insights query failed'
logger.error('QueryLogs failed', { error: errorMessage })
return NextResponse.json({ error: errorMessage }, { status: 500 })
}
}

View File

@@ -0,0 +1,161 @@
import {
CloudWatchLogsClient,
DescribeLogStreamsCommand,
GetLogEventsCommand,
GetQueryResultsCommand,
type ResultField,
} from '@aws-sdk/client-cloudwatch-logs'
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
interface AwsCredentials {
region: string
accessKeyId: string
secretAccessKey: string
}
export function createCloudWatchLogsClient(config: AwsCredentials): CloudWatchLogsClient {
return new CloudWatchLogsClient({
region: config.region,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
})
}
interface PollOptions {
maxWaitMs?: number
pollIntervalMs?: number
}
interface PollResult {
results: Record<string, string>[]
statistics: {
bytesScanned: number
recordsMatched: number
recordsScanned: number
}
status: string
}
function parseResultFields(fields: ResultField[] | undefined): Record<string, string> {
const record: Record<string, string> = {}
if (!fields) return record
for (const field of fields) {
if (field.field && field.value !== undefined) {
record[field.field] = field.value ?? ''
}
}
return record
}
export async function pollQueryResults(
client: CloudWatchLogsClient,
queryId: string,
options: PollOptions = {}
): Promise<PollResult> {
const { maxWaitMs = DEFAULT_EXECUTION_TIMEOUT_MS, pollIntervalMs = 1_000 } = options
const startTime = Date.now()
while (Date.now() - startTime < maxWaitMs) {
const command = new GetQueryResultsCommand({ queryId })
const response = await client.send(command)
const status = response.status ?? 'Unknown'
if (status === 'Complete') {
return {
results: (response.results ?? []).map(parseResultFields),
statistics: {
bytesScanned: response.statistics?.bytesScanned ?? 0,
recordsMatched: response.statistics?.recordsMatched ?? 0,
recordsScanned: response.statistics?.recordsScanned ?? 0,
},
status,
}
}
if (status === 'Failed' || status === 'Cancelled') {
throw new Error(`CloudWatch Log Insights query ${status.toLowerCase()}`)
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
}
// Timeout -- fetch one last time for partial results
const finalResponse = await client.send(new GetQueryResultsCommand({ queryId }))
return {
results: (finalResponse.results ?? []).map(parseResultFields),
statistics: {
bytesScanned: finalResponse.statistics?.bytesScanned ?? 0,
recordsMatched: finalResponse.statistics?.recordsMatched ?? 0,
recordsScanned: finalResponse.statistics?.recordsScanned ?? 0,
},
status: `Timeout (last status: ${finalResponse.status ?? 'Unknown'})`,
}
}
export async function describeLogStreams(
client: CloudWatchLogsClient,
logGroupName: string,
options?: { prefix?: string; limit?: number }
): Promise<{
logStreams: {
logStreamName: string
lastEventTimestamp: number | undefined
firstEventTimestamp: number | undefined
creationTime: number | undefined
storedBytes: number
}[]
}> {
const hasPrefix = Boolean(options?.prefix)
const command = new DescribeLogStreamsCommand({
logGroupName,
...(hasPrefix
? { orderBy: 'LogStreamName', logStreamNamePrefix: options!.prefix }
: { orderBy: 'LastEventTime', descending: true }),
...(options?.limit !== undefined && { limit: options.limit }),
})
const response = await client.send(command)
return {
logStreams: (response.logStreams ?? []).map((ls) => ({
logStreamName: ls.logStreamName ?? '',
lastEventTimestamp: ls.lastEventTimestamp,
firstEventTimestamp: ls.firstEventTimestamp,
creationTime: ls.creationTime,
storedBytes: ls.storedBytes ?? 0,
})),
}
}
export async function getLogEvents(
client: CloudWatchLogsClient,
logGroupName: string,
logStreamName: string,
options?: { startTime?: number; endTime?: number; limit?: number }
): Promise<{
events: {
timestamp: number | undefined
message: string | undefined
ingestionTime: number | undefined
}[]
}> {
const command = new GetLogEventsCommand({
logGroupIdentifier: logGroupName,
logStreamName,
...(options?.startTime !== undefined && { startTime: options.startTime * 1000 }),
...(options?.endTime !== undefined && { endTime: options.endTime * 1000 }),
...(options?.limit !== undefined && { limit: options.limit }),
startFromHead: true,
})
const response = await client.send(command)
return {
events: (response.events ?? []).map((e) => ({
timestamp: e.timestamp,
message: e.message,
ingestionTime: e.ingestionTime,
})),
}
}

View File

@@ -1,42 +1,44 @@
import { getBaseUrl } from '@/lib/core/utils/urls'
export async function GET() {
export function GET() {
const baseUrl = getBaseUrl()
const llmsContent = `# Sim
const content = `# Sim
> Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.
> Sim is the open-source platform to build AI agents and run your agentic workforce. Connect integrations and LLMs to deploy and orchestrate agentic workflows.
Sim lets teams create agents, workflows, knowledge bases, tables, and docs. Over 100,000 builders use Sim — from startups to Fortune 500 companies. SOC2 compliant.
Sim lets teams create agents, workflows, knowledge bases, tables, and docs. It supports both product discovery pages and deeper technical documentation.
## Core Pages
## Preferred URLs
- [Homepage](${baseUrl}): Product overview, features, and pricing
- [Homepage](${baseUrl}): Product overview and primary entry point
- [Integrations directory](${baseUrl}/integrations): Public catalog of integrations and automation capabilities
- [Models directory](${baseUrl}/models): Public catalog of AI models, pricing, context windows, and capabilities
- [Blog](${baseUrl}/blog): Announcements, guides, and product context
- [Changelog](${baseUrl}/changelog): Product updates and release notes
- [Sim Blog](${baseUrl}/blog): Announcements, insights, and guides
## Documentation
- [Documentation](https://docs.sim.ai): Complete guides and API reference
- [Quickstart](https://docs.sim.ai/quickstart): Get started in 5 minutes
- [API Reference](https://docs.sim.ai/api): REST API documentation
- [Documentation](https://docs.sim.ai): Product guides and technical reference
- [Quickstart](https://docs.sim.ai/quickstart): Fastest path to getting started
- [API Reference](https://docs.sim.ai/api): API documentation
## Key Concepts
- **Workspace**: Container for workflows, data sources, and executions
- **Workflow**: Directed graph of blocks defining an agentic process
- **Block**: Individual step (LLM call, tool call, HTTP request, code execution)
- **Block**: Individual step such as an LLM call, tool call, HTTP request, or code execution
- **Trigger**: Event or schedule that initiates workflow execution
- **Execution**: A single run of a workflow with logs and outputs
- **Knowledge Base**: Vector-indexed document store for retrieval-augmented generation
- **Knowledge Base**: Document store used for retrieval-augmented generation
## Capabilities
- AI agent creation and deployment
- Agentic workflow orchestration
- 1,000+ integrations (Slack, Gmail, Notion, Airtable, databases, and more)
- Multi-model LLM orchestration (OpenAI, Anthropic, Google, Mistral, xAI, Perplexity)
- Knowledge base creation with retrieval-augmented generation (RAG)
- Integrations across business tools, databases, and communication platforms
- Multi-model LLM orchestration
- Knowledge bases and retrieval-augmented generation
- Table creation and management
- Document creation and processing
- Scheduled and webhook-triggered executions
@@ -45,24 +47,19 @@ Sim lets teams create agents, workflows, knowledge bases, tables, and docs. Over
- AI agent deployment and orchestration
- Knowledge bases and RAG pipelines
- Document creation and processing
- Customer support automation
- Internal operations (sales, marketing, legal, finance)
- Internal operations workflows across sales, marketing, legal, and finance
## Links
## Additional Links
- [GitHub Repository](https://github.com/simstudioai/sim): Open-source codebase
- [Discord Community](https://discord.gg/Hr4UWYEcTT): Get help and connect with 100,000+ builders
- [X/Twitter](https://x.com/simdotai): Product updates and announcements
## Optional
- [Careers](https://jobs.ashbyhq.com/sim): Join the Sim team
- [Docs](https://docs.sim.ai): Canonical documentation source
- [Terms of Service](${baseUrl}/terms): Legal terms
- [Privacy Policy](${baseUrl}/privacy): Data handling practices
- [Sitemap](${baseUrl}/sitemap.xml): Public URL inventory
`
return new Response(llmsContent, {
return new Response(content, {
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'Cache-Control': 'public, max-age=86400, s-maxage=86400',

View File

@@ -8,6 +8,34 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = getBaseUrl()
const now = new Date()
const integrationPages: MetadataRoute.Sitemap = integrations.map((integration) => ({
url: `${baseUrl}/integrations/${integration.slug}`,
lastModified: now,
}))
const modelHubPages: MetadataRoute.Sitemap = [
{
url: `${baseUrl}/integrations`,
lastModified: now,
},
{
url: `${baseUrl}/models`,
lastModified: now,
},
{
url: `${baseUrl}/partners`,
lastModified: now,
},
]
const providerPages: MetadataRoute.Sitemap = MODEL_PROVIDERS_WITH_CATALOGS.map((provider) => ({
url: `${baseUrl}${provider.href}`,
lastModified: new Date(
Math.max(...provider.models.map((model) => new Date(model.pricing.updatedAt).getTime()))
),
}))
const modelPages: MetadataRoute.Sitemap = ALL_CATALOG_MODELS.map((model) => ({
url: `${baseUrl}${model.href}`,
lastModified: new Date(model.pricing.updatedAt),
}))
const staticPages: MetadataRoute.Sitemap = [
{
@@ -26,14 +54,6 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// url: `${baseUrl}/templates`,
// lastModified: now,
// },
{
url: `${baseUrl}/integrations`,
lastModified: now,
},
{
url: `${baseUrl}/models`,
lastModified: now,
},
{
url: `${baseUrl}/changelog`,
lastModified: now,
@@ -54,20 +74,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
lastModified: new Date(p.updated ?? p.date),
}))
const integrationPages: MetadataRoute.Sitemap = integrations.map((i) => ({
url: `${baseUrl}/integrations/${i.slug}`,
lastModified: now,
}))
const providerPages: MetadataRoute.Sitemap = MODEL_PROVIDERS_WITH_CATALOGS.map((provider) => ({
url: `${baseUrl}${provider.href}`,
lastModified: now,
}))
const modelPages: MetadataRoute.Sitemap = ALL_CATALOG_MODELS.map((model) => ({
url: `${baseUrl}${model.href}`,
lastModified: new Date(model.pricing.updatedAt),
}))
return [...staticPages, ...blogPages, ...integrationPages, ...providerPages, ...modelPages]
return [
...staticPages,
...modelHubPages,
...integrationPages,
...providerPages,
...modelPages,
...blogPages,
]
}

View File

@@ -1,22 +1,59 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Check, Copy, Ellipsis, Hash } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Button,
Check,
Copy,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Textarea,
ThumbsDown,
ThumbsUp,
} from '@/components/emcn'
import { useSubmitCopilotFeedback } from '@/hooks/queries/copilot-feedback'
const SPECIAL_TAGS = 'thinking|options|usage_upgrade|credential|mothership-error|file'
function toPlainText(raw: string): string {
return (
raw
// Strip special tags and their contents
.replace(new RegExp(`<\\/?(${SPECIAL_TAGS})(?:>[\\s\\S]*?<\\/(${SPECIAL_TAGS})>|>)`, 'g'), '')
// Strip markdown
.replace(/^#{1,6}\s+/gm, '')
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/\*(.+?)\*/g, '$1')
.replace(/`{3}[\s\S]*?`{3}/g, '')
.replace(/`(.+?)`/g, '$1')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/^[>\-*]\s+/gm, '')
.replace(/!\[[^\]]*\]\([^)]+\)/g, '')
// Normalize whitespace
.replace(/\n{3,}/g, '\n\n')
.trim()
)
}
const ICON_CLASS = 'h-[14px] w-[14px]'
const BUTTON_CLASS =
'flex h-[26px] w-[26px] items-center justify-center rounded-[6px] text-[var(--text-icon)] transition-colors hover-hover:bg-[var(--surface-hover)] focus-visible:outline-none'
interface MessageActionsProps {
content: string
requestId?: string
chatId?: string
userQuery?: string
}
export function MessageActions({ content, requestId }: MessageActionsProps) {
const [copied, setCopied] = useState<'message' | 'request' | null>(null)
export function MessageActions({ content, chatId, userQuery }: MessageActionsProps) {
const [copied, setCopied] = useState(false)
const [pendingFeedback, setPendingFeedback] = useState<'up' | 'down' | null>(null)
const [feedbackText, setFeedbackText] = useState('')
const resetTimeoutRef = useRef<number | null>(null)
const submitFeedback = useSubmitCopilotFeedback()
useEffect(() => {
return () => {
@@ -26,59 +63,119 @@ export function MessageActions({ content, requestId }: MessageActionsProps) {
}
}, [])
const copyToClipboard = useCallback(async (text: string, type: 'message' | 'request') => {
const copyToClipboard = useCallback(async () => {
if (!content) return
const text = toPlainText(content)
if (!text) return
try {
await navigator.clipboard.writeText(text)
setCopied(type)
setCopied(true)
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current)
}
resetTimeoutRef.current = window.setTimeout(() => setCopied(null), 1500)
resetTimeoutRef.current = window.setTimeout(() => setCopied(false), 1500)
} catch {
/* clipboard unavailable */
}
}, [content])
const handleFeedbackClick = useCallback(
(type: 'up' | 'down') => {
if (chatId && userQuery) {
setPendingFeedback(type)
setFeedbackText('')
}
},
[chatId, userQuery]
)
const handleSubmitFeedback = useCallback(() => {
if (!pendingFeedback || !chatId || !userQuery) return
const text = feedbackText.trim()
if (!text) {
setPendingFeedback(null)
setFeedbackText('')
return
}
submitFeedback.mutate({
chatId,
userQuery,
agentResponse: content,
isPositiveFeedback: pendingFeedback === 'up',
feedback: text,
})
setPendingFeedback(null)
setFeedbackText('')
}, [pendingFeedback, chatId, userQuery, content, feedbackText])
const handleModalClose = useCallback((open: boolean) => {
if (!open) {
setPendingFeedback(null)
setFeedbackText('')
}
}, [])
if (!content && !requestId) {
return null
}
if (!content) return null
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<>
<div className='flex items-center gap-0.5'>
<button
type='button'
aria-label='More options'
className='flex h-5 w-5 items-center justify-center rounded-sm text-[var(--text-icon)] opacity-0 transition-colors transition-opacity hover-hover:bg-[var(--surface-3)] hover-hover:text-[var(--text-primary)] focus-visible:opacity-100 focus-visible:outline-none group-hover/msg:opacity-100 data-[state=open]:opacity-100'
onClick={(event) => event.stopPropagation()}
aria-label='Copy message'
onClick={copyToClipboard}
className={BUTTON_CLASS}
>
<Ellipsis className='h-3 w-3' strokeWidth={2} />
{copied ? <Check className={ICON_CLASS} /> : <Copy className={ICON_CLASS} />}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' side='top' sideOffset={4}>
<DropdownMenuItem
disabled={!content}
onSelect={(event) => {
event.stopPropagation()
void copyToClipboard(content, 'message')
}}
<button
type='button'
aria-label='Like'
onClick={() => handleFeedbackClick('up')}
className={BUTTON_CLASS}
>
{copied === 'message' ? <Check /> : <Copy />}
<span>Copy Message</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled={!requestId}
onSelect={(event) => {
event.stopPropagation()
if (requestId) {
void copyToClipboard(requestId, 'request')
}
}}
<ThumbsUp className={ICON_CLASS} />
</button>
<button
type='button'
aria-label='Dislike'
onClick={() => handleFeedbackClick('down')}
className={BUTTON_CLASS}
>
{copied === 'request' ? <Check /> : <Hash />}
<span>Copy Request ID</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ThumbsDown className={ICON_CLASS} />
</button>
</div>
<Modal open={pendingFeedback !== null} onOpenChange={handleModalClose}>
<ModalContent size='sm'>
<ModalHeader>Give feedback</ModalHeader>
<ModalBody>
<div className='flex flex-col gap-2'>
<p className='font-medium text-[var(--text-secondary)] text-sm'>
{pendingFeedback === 'up' ? 'What did you like?' : 'What could be improved?'}
</p>
<Textarea
placeholder={
pendingFeedback === 'up'
? 'Tell us what was helpful...'
: 'Tell us what went wrong...'
}
value={feedbackText}
onChange={(e) => setFeedbackText(e.target.value)}
rows={3}
/>
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => handleModalClose(false)}>
Cancel
</Button>
<Button variant='primary' onClick={handleSubmitFeedback}>
Submit
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}

View File

@@ -1,8 +1,10 @@
'use client'
import { createContext, memo, useContext, useMemo, useRef } from 'react'
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef } from 'react'
import { useRouter } from 'next/navigation'
import type { Components, ExtraProps } from 'react-markdown'
import ReactMarkdown from 'react-markdown'
import rehypeSlug from 'rehype-slug'
import remarkBreaks from 'remark-breaks'
import remarkGfm from 'remark-gfm'
import { Checkbox } from '@/components/emcn'
@@ -70,6 +72,7 @@ export const PreviewPanel = memo(function PreviewPanel({
})
const REMARK_PLUGINS = [remarkGfm, remarkBreaks]
const REHYPE_PLUGINS = [rehypeSlug]
/**
* Carries the contentRef and toggle handler from MarkdownPreview down to the
@@ -83,29 +86,43 @@ const MarkdownCheckboxCtx = createContext<{
/** Carries the resolved checkbox index from LiRenderer to InputRenderer. */
const CheckboxIndexCtx = createContext(-1)
const NavigateCtx = createContext<((path: string) => void) | null>(null)
const STATIC_MARKDOWN_COMPONENTS = {
p: ({ children }: { children?: React.ReactNode }) => (
<p className='mb-3 break-words text-[14px] text-[var(--text-primary)] leading-[1.6] last:mb-0'>
{children}
</p>
),
h1: ({ children }: { children?: React.ReactNode }) => (
<h1 className='mt-6 mb-4 break-words font-semibold text-[24px] text-[var(--text-primary)] first:mt-0'>
h1: ({ id, children }: { id?: string; children?: React.ReactNode }) => (
<h1
id={id}
className='mt-6 mb-4 break-words font-semibold text-[24px] text-[var(--text-primary)] first:mt-0'
>
{children}
</h1>
),
h2: ({ children }: { children?: React.ReactNode }) => (
<h2 className='mt-5 mb-3 break-words font-semibold text-[20px] text-[var(--text-primary)] first:mt-0'>
h2: ({ id, children }: { id?: string; children?: React.ReactNode }) => (
<h2
id={id}
className='mt-5 mb-3 break-words font-semibold text-[20px] text-[var(--text-primary)] first:mt-0'
>
{children}
</h2>
),
h3: ({ children }: { children?: React.ReactNode }) => (
<h3 className='mt-4 mb-2 break-words font-semibold text-[16px] text-[var(--text-primary)] first:mt-0'>
h3: ({ id, children }: { id?: string; children?: React.ReactNode }) => (
<h3
id={id}
className='mt-4 mb-2 break-words font-semibold text-[16px] text-[var(--text-primary)] first:mt-0'
>
{children}
</h3>
),
h4: ({ children }: { children?: React.ReactNode }) => (
<h4 className='mt-3 mb-2 break-words font-semibold text-[14px] text-[var(--text-primary)] first:mt-0'>
h4: ({ id, children }: { id?: string; children?: React.ReactNode }) => (
<h4
id={id}
className='mt-3 mb-2 break-words font-semibold text-[14px] text-[var(--text-primary)] first:mt-0'
>
{children}
</h4>
),
@@ -138,16 +155,6 @@ const STATIC_MARKDOWN_COMPONENTS = {
)
},
pre: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
<a
href={href}
target='_blank'
rel='noopener noreferrer'
className='break-all text-[var(--brand-secondary)] underline-offset-2 hover:underline'
>
{children}
</a>
),
strong: ({ children }: { children?: React.ReactNode }) => (
<strong className='break-words font-semibold text-[var(--text-primary)]'>{children}</strong>
),
@@ -267,8 +274,75 @@ function InputRenderer({
)
}
function isInternalHref(
href: string,
origin = window.location.origin
): { pathname: string; hash: string } | null {
if (href.startsWith('#')) return { pathname: '', hash: href }
try {
const url = new URL(href, origin)
if (url.origin === origin && url.pathname.startsWith('/workspace/')) {
return { pathname: url.pathname, hash: url.hash }
}
} catch {
if (href.startsWith('/workspace/')) {
const hashIdx = href.indexOf('#')
if (hashIdx === -1) return { pathname: href, hash: '' }
return { pathname: href.slice(0, hashIdx), hash: href.slice(hashIdx) }
}
}
return null
}
function AnchorRenderer({ href, children }: { href?: string; children?: React.ReactNode }) {
const navigate = useContext(NavigateCtx)
const parsed = useMemo(() => (href ? isInternalHref(href) : null), [href])
const handleClick = useCallback(
(e: React.MouseEvent<HTMLAnchorElement>) => {
if (!parsed || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
e.preventDefault()
if (parsed.pathname === '' && parsed.hash) {
const el = document.getElementById(parsed.hash.slice(1))
if (el) {
const container = el.closest('.overflow-auto') as HTMLElement | null
if (container) {
container.scrollTo({ top: el.offsetTop - container.offsetTop, behavior: 'smooth' })
} else {
el.scrollIntoView({ behavior: 'smooth' })
}
}
return
}
const destination = parsed.pathname + parsed.hash
if (navigate) {
navigate(destination)
} else {
window.location.assign(destination)
}
},
[parsed, navigate]
)
return (
<a
href={href}
target={parsed ? undefined : '_blank'}
rel={parsed ? undefined : 'noopener noreferrer'}
onClick={handleClick}
className='break-all text-[var(--brand-secondary)] underline-offset-2 hover:underline'
>
{children}
</a>
)
}
const MARKDOWN_COMPONENTS = {
...STATIC_MARKDOWN_COMPONENTS,
a: AnchorRenderer,
ul: UlRenderer,
ol: OlRenderer,
li: LiRenderer,
@@ -284,6 +358,7 @@ const MarkdownPreview = memo(function MarkdownPreview({
isStreaming?: boolean
onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void
}) {
const { push: navigate } = useRouter()
const { ref: scrollRef } = useAutoScroll(isStreaming)
const { committed, incoming, generation } = useStreamingReveal(content, isStreaming)
@@ -295,10 +370,30 @@ const MarkdownPreview = memo(function MarkdownPreview({
[onCheckboxToggle]
)
const hasScrolledToHash = useRef(false)
useEffect(() => {
const hash = window.location.hash
if (!hash || hasScrolledToHash.current) return
const id = hash.slice(1)
const el = document.getElementById(id)
if (!el) return
hasScrolledToHash.current = true
const container = el.closest('.overflow-auto') as HTMLElement | null
if (container) {
container.scrollTo({ top: el.offsetTop - container.offsetTop, behavior: 'smooth' })
} else {
el.scrollIntoView({ behavior: 'smooth' })
}
}, [content])
const committedMarkdown = useMemo(
() =>
committed ? (
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
<ReactMarkdown
remarkPlugins={REMARK_PLUGINS}
rehypePlugins={REHYPE_PLUGINS}
components={MARKDOWN_COMPONENTS}
>
{committed}
</ReactMarkdown>
) : null,
@@ -307,30 +402,42 @@ const MarkdownPreview = memo(function MarkdownPreview({
if (onCheckboxToggle) {
return (
<MarkdownCheckboxCtx.Provider value={ctxValue}>
<div ref={scrollRef} className='h-full overflow-auto p-6'>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
{content}
</ReactMarkdown>
</div>
</MarkdownCheckboxCtx.Provider>
<NavigateCtx.Provider value={navigate}>
<MarkdownCheckboxCtx.Provider value={ctxValue}>
<div ref={scrollRef} className='h-full overflow-auto p-6'>
<ReactMarkdown
remarkPlugins={REMARK_PLUGINS}
rehypePlugins={REHYPE_PLUGINS}
components={MARKDOWN_COMPONENTS}
>
{content}
</ReactMarkdown>
</div>
</MarkdownCheckboxCtx.Provider>
</NavigateCtx.Provider>
)
}
return (
<div ref={scrollRef} className='h-full overflow-auto p-6'>
{committedMarkdown}
{incoming && (
<div
key={generation}
className={cn(isStreaming && 'animate-stream-fade-in', '[&>:first-child]:mt-0')}
>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
{incoming}
</ReactMarkdown>
</div>
)}
</div>
<NavigateCtx.Provider value={navigate}>
<div ref={scrollRef} className='h-full overflow-auto p-6'>
{committedMarkdown}
{incoming && (
<div
key={generation}
className={cn(isStreaming && 'animate-stream-fade-in', '[&>:first-child]:mt-0')}
>
<ReactMarkdown
remarkPlugins={REMARK_PLUGINS}
rehypePlugins={REHYPE_PLUGINS}
components={MARKDOWN_COMPONENTS}
>
{incoming}
</ReactMarkdown>
</div>
)}
</div>
</NavigateCtx.Provider>
)
})

View File

@@ -473,9 +473,9 @@ function MothershipErrorDisplay({ data }: { data: MothershipErrorTagData }) {
const detail = data.code ? `${data.message} (${data.code})` : data.message
return (
<span className='animate-stream-fade-in font-base text-[13px] text-[var(--text-secondary)] italic leading-[20px]'>
<p className='animate-stream-fade-in font-base text-[13px] text-[var(--text-secondary)] italic leading-[20px]'>
{detail}
</span>
</p>
)
}

View File

@@ -35,6 +35,7 @@ interface MothershipChatProps {
onSendQueuedMessage: (id: string) => Promise<void>
onEditQueuedMessage: (id: string) => void
userId?: string
chatId?: string
onContextAdd?: (context: ChatContext) => void
editValue?: string
onEditValueConsumed?: () => void
@@ -53,7 +54,7 @@ const LAYOUT_STYLES = {
userRow: 'flex flex-col items-end gap-[6px] pt-3',
attachmentWidth: 'max-w-[70%]',
userBubble: 'max-w-[70%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2',
assistantRow: 'group/msg relative pb-5',
assistantRow: 'group/msg',
footer: 'flex-shrink-0 px-[24px] pb-[16px]',
footerInner: 'mx-auto max-w-[42rem]',
},
@@ -63,7 +64,7 @@ const LAYOUT_STYLES = {
userRow: 'flex flex-col items-end gap-[6px] pt-2',
attachmentWidth: 'max-w-[85%]',
userBubble: 'max-w-[85%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3 py-2',
assistantRow: 'group/msg relative pb-3',
assistantRow: 'group/msg',
footer: 'flex-shrink-0 px-3 pb-3',
footerInner: '',
},
@@ -80,6 +81,7 @@ export function MothershipChat({
onSendQueuedMessage,
onEditQueuedMessage,
userId,
chatId,
onContextAdd,
editValue,
onEditValueConsumed,
@@ -147,20 +149,28 @@ export function MothershipChat({
}
const isLastMessage = index === messages.length - 1
const precedingUserMsg = [...messages]
.slice(0, index)
.reverse()
.find((m) => m.role === 'user')
return (
<div key={msg.id} className={styles.assistantRow}>
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
<div className='absolute right-0 bottom-0 z-10'>
<MessageActions content={msg.content} requestId={msg.requestId} />
</div>
)}
<MessageContent
blocks={msg.contentBlocks || []}
fallbackContent={msg.content}
isStreaming={isThisStreaming}
onOptionSelect={isLastMessage ? onSubmit : undefined}
/>
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
<div className='mt-2.5'>
<MessageActions
content={msg.content}
chatId={chatId}
userQuery={precedingUserMsg?.content}
/>
</div>
)}
</div>
)
})}

View File

@@ -115,7 +115,7 @@ export const MothershipView = memo(
<div
ref={ref}
className={cn(
'relative z-10 flex h-full flex-col overflow-hidden border-[var(--border)] bg-[var(--bg)] transition-[width,min-width,border-width] duration-500 ease-[cubic-bezier(0.16,1,0.3,1)]',
'relative z-10 flex h-full flex-col overflow-hidden border-[var(--border)] bg-[var(--bg)] transition-[width,min-width,border-width] duration-200 ease-[cubic-bezier(0.25,0.1,0.25,1)]',
isCollapsed ? 'w-0 min-w-0 border-l-0' : 'w-1/2 border-l',
className
)}

View File

@@ -2,7 +2,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import { PanelLeft } from '@/components/emcn/icons'
import { useSession } from '@/lib/auth/auth-client'
@@ -28,6 +28,8 @@ interface HomeProps {
export function Home({ chatId }: HomeProps = {}) {
const { workspaceId } = useParams<{ workspaceId: string }>()
const router = useRouter()
const searchParams = useSearchParams()
const initialResourceId = searchParams.get('resource')
const { data: session } = useSession()
const posthog = usePostHog()
const posthogRef = useRef(posthog)
@@ -160,7 +162,10 @@ export function Home({ chatId }: HomeProps = {}) {
} = useChat(
workspaceId,
chatId,
getMothershipUseChatOptions({ onResourceEvent: handleResourceEvent })
getMothershipUseChatOptions({
onResourceEvent: handleResourceEvent,
initialActiveResourceId: initialResourceId,
})
)
const [editingInputValue, setEditingInputValue] = useState('')
@@ -183,6 +188,16 @@ export function Home({ chatId }: HomeProps = {}) {
[editQueuedMessage]
)
useEffect(() => {
const url = new URL(window.location.href)
if (activeResourceId) {
url.searchParams.set('resource', activeResourceId)
} else {
url.searchParams.delete('resource')
}
window.history.replaceState(null, '', url.toString())
}, [activeResourceId])
useEffect(() => {
wasSendingRef.current = false
if (resolvedChatId) markRead(resolvedChatId)
@@ -348,6 +363,7 @@ export function Home({ chatId }: HomeProps = {}) {
onSendQueuedMessage={sendNow}
onEditQueuedMessage={handleEditQueuedMessage}
userId={session?.user?.id}
chatId={resolvedChatId}
onContextAdd={handleContextAdd}
editValue={editingInputValue}
onEditValueConsumed={clearEditingValue}

View File

@@ -377,10 +377,11 @@ export interface UseChatOptions {
onToolResult?: (toolName: string, success: boolean, result: unknown) => void
onTitleUpdate?: () => void
onStreamEnd?: (chatId: string, messages: ChatMessage[]) => void
initialActiveResourceId?: string | null
}
export function getMothershipUseChatOptions(
options: Pick<UseChatOptions, 'onResourceEvent' | 'onStreamEnd'> = {}
options: Pick<UseChatOptions, 'onResourceEvent' | 'onStreamEnd' | 'initialActiveResourceId'> = {}
): UseChatOptions {
return {
apiPath: MOTHERSHIP_CHAT_API_PATH,
@@ -416,6 +417,7 @@ export function useChat(
const [resolvedChatId, setResolvedChatId] = useState<string | undefined>(initialChatId)
const [resources, setResources] = useState<MothershipResource[]>([])
const [activeResourceId, setActiveResourceId] = useState<string | null>(null)
const initialActiveResourceIdRef = useRef(options?.initialActiveResourceId)
const onResourceEventRef = useRef(options?.onResourceEvent)
onResourceEventRef.current = options?.onResourceEvent
const apiPathRef = useRef(options?.apiPath ?? MOTHERSHIP_CHAT_API_PATH)
@@ -845,7 +847,12 @@ export function useChat(
const persistedResources = history.resources.filter((r) => r.id !== 'streaming-file')
if (persistedResources.length > 0) {
setResources(persistedResources)
setActiveResourceId(persistedResources[persistedResources.length - 1].id)
const initialId = initialActiveResourceIdRef.current
const restoredId =
initialId && persistedResources.some((r) => r.id === initialId)
? initialId
: persistedResources[persistedResources.length - 1].id
setActiveResourceId(restoredId)
for (const resource of persistedResources) {
if (resource.type !== 'workflow') continue

View File

@@ -839,6 +839,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
onSendQueuedMessage={copilotSendNow}
onEditQueuedMessage={handleCopilotEditQueuedMessage}
userId={session?.user?.id}
chatId={copilotResolvedChatId}
editValue={copilotEditingInputValue}
onEditValueConsumed={clearCopilotEditingValue}
layout='copilot-view'

View File

@@ -12,7 +12,6 @@ import {
Button,
Combobox,
Input,
Label,
Modal,
ModalBody,
ModalContent,
@@ -432,7 +431,7 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto'>
<div className='space-y-3'>
<div className='flex flex-col gap-2'>
<Label htmlFor='type'>Request</Label>
<p className='font-medium text-[var(--text-secondary)] text-sm'>Request</p>
<Combobox
id='type'
options={REQUEST_TYPE_OPTIONS}
@@ -447,7 +446,7 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
</div>
<div className='flex flex-col gap-2'>
<Label htmlFor='subject'>Subject</Label>
<p className='font-medium text-[var(--text-secondary)] text-sm'>Subject</p>
<Input
id='subject'
placeholder='Brief description of your request'
@@ -457,7 +456,7 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
</div>
<div className='flex flex-col gap-2'>
<Label htmlFor='message'>Message</Label>
<p className='font-medium text-[var(--text-secondary)] text-sm'>Message</p>
<Textarea
id='message'
placeholder='Please provide details about your request...'
@@ -468,7 +467,9 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
</div>
<div className='flex flex-col gap-2'>
<Label>Attach Images (Optional)</Label>
<p className='font-medium text-[var(--text-secondary)] text-sm'>
Attach Images (Optional)
</p>
<Button
type='button'
variant='default'
@@ -505,7 +506,9 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
{images.length > 0 && (
<div className='space-y-2'>
<Label>Uploaded Images</Label>
<p className='font-medium text-[var(--text-secondary)] text-sm'>
Uploaded Images
</p>
<div className='grid grid-cols-2 gap-3'>
{images.map((image, index) => (
<div

View File

@@ -316,6 +316,7 @@ export const Sidebar = memo(function Sidebar() {
const sidebarRef = useRef<HTMLElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const scrollContentRef = useRef<HTMLDivElement>(null)
const posthog = usePostHog()
const { data: sessionData, isPending: sessionLoading } = useSession()
@@ -894,6 +895,9 @@ export const Sidebar = memo(function Sidebar() {
container.addEventListener('scroll', updateScrollState, { passive: true })
const observer = new ResizeObserver(updateScrollState)
observer.observe(container)
if (scrollContentRef.current) {
observer.observe(scrollContentRef.current)
}
return () => {
container.removeEventListener('scroll', updateScrollState)
@@ -1336,275 +1340,286 @@ export const Sidebar = memo(function Sidebar() {
!hasOverflowTop && 'border-transparent'
)}
>
<div className='tasks-section flex flex-shrink-0 flex-col' data-tour='nav-tasks'>
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-4'>
<div className='font-base text-[var(--text-icon)] text-small'>All tasks</div>
{!isCollapsed && (
<div className='flex items-center justify-center gap-2'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
onClick={handleNewTask}
>
<Plus className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<Tooltip.Shortcut keys={isMac ? '⌘⇧K' : 'Ctrl+Shift+K'}>
New task
</Tooltip.Shortcut>
</Tooltip.Content>
</Tooltip.Root>
<div ref={scrollContentRef} className='flex flex-col'>
<div
className='tasks-section flex flex-shrink-0 flex-col'
data-tour='nav-tasks'
>
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-4'>
<div className='font-base text-[var(--text-icon)] text-small'>
All tasks
</div>
)}
</div>
{isCollapsed ? (
<CollapsedSidebarMenu
icon={tasksCollapsedIcon}
hover={tasksHover}
ariaLabel='Tasks'
className='mt-1.5'
primaryAction={tasksPrimaryAction}
>
{tasksLoading ? (
<DropdownMenuItem disabled>
<Loader className='h-[14px] w-[14px]' animate />
Loading...
</DropdownMenuItem>
) : (
tasks.map((task) => (
<CollapsedTaskFlyoutItem
key={task.id}
task={task}
isCurrentRoute={task.id !== 'new' && pathname === task.href}
isMenuOpen={menuOpenTaskId === task.id}
isEditing={task.id === taskFlyoutRename.editingId}
editValue={taskFlyoutRename.value}
inputRef={taskFlyoutRename.inputRef}
isRenaming={taskFlyoutRename.isSaving}
onEditValueChange={taskFlyoutRename.setValue}
onEditKeyDown={taskFlyoutRename.handleKeyDown}
onEditBlur={handleTaskRenameBlur}
onContextMenu={handleTaskContextMenu}
onMorePointerDown={handleTaskMorePointerDown}
onMoreClick={handleTaskMoreClick}
/>
))
)}
</CollapsedSidebarMenu>
) : (
<div className='mt-1.5 flex flex-col gap-0.5 px-2'>
{tasksLoading ? (
<SidebarItemSkeleton />
) : (
<>
{tasks.slice(0, visibleTaskCount).map((task) => {
const isCurrentRoute = task.id !== 'new' && pathname === task.href
const isRenaming = taskFlyoutRename.editingId === task.id
const isSelected = task.id !== 'new' && selectedTasks.has(task.id)
if (isRenaming) {
return (
<div
key={task.id}
className='mx-0.5 flex h-[30px] items-center gap-2 rounded-lg bg-[var(--surface-active)] px-2 text-sm'
>
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<input
ref={taskFlyoutRename.inputRef}
value={taskFlyoutRename.value}
onChange={(e) => taskFlyoutRename.setValue(e.target.value)}
onKeyDown={taskFlyoutRename.handleKeyDown}
onBlur={handleTaskRenameBlur}
className='min-w-0 flex-1 border-none bg-transparent font-base text-[14px] text-[var(--text-body)] outline-none'
/>
</div>
)
}
return (
<SidebarTaskItem
key={task.id}
task={task}
isCurrentRoute={isCurrentRoute}
isSelected={isSelected}
isActive={!!task.isActive}
isUnread={!!task.isUnread}
isMenuOpen={menuOpenTaskId === task.id}
showCollapsedTooltips={showCollapsedTooltips}
onMultiSelectClick={handleTaskClick}
onContextMenu={handleTaskContextMenu}
onMorePointerDown={handleTaskMorePointerDown}
onMoreClick={handleTaskMoreClick}
/>
)
})}
{tasks.length > visibleTaskCount && (
<button
type='button'
onClick={handleSeeMoreTasks}
className='mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-[var(--text-icon)] text-sm hover-hover:bg-[var(--surface-hover)]'
>
<MoreHorizontal className='h-[16px] w-[16px] flex-shrink-0' />
<span className='font-base'>See more</span>
</button>
)}
</>
)}
</div>
)}
</div>
<div
className='workflows-section relative mt-3.5 flex flex-col'
data-tour='nav-workflows'
>
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-4'>
<div className='font-base text-[var(--text-icon)] text-small'>Workflows</div>
{!isCollapsed && (
<div className='flex items-center justify-center gap-2'>
<DropdownMenu>
{!isCollapsed && (
<div className='flex items-center justify-center gap-2'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
disabled={!canEdit}
>
{isImporting || isCreatingFolder ? (
<Loader className='h-[16px] w-[16px]' animate />
) : (
<MoreHorizontal className='h-[16px] w-[16px]' />
)}
</Button>
</DropdownMenuTrigger>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
onClick={handleNewTask}
>
<Plus className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>More actions</p>
<Tooltip.Shortcut keys={isMac ? '⌘⇧K' : 'Ctrl+Shift+K'}>
New task
</Tooltip.Shortcut>
</Tooltip.Content>
</Tooltip.Root>
<DropdownMenuContent
align='start'
sideOffset={8}
className='min-w-[160px]'
>
<DropdownMenuItem
onSelect={handleImportWorkflow}
disabled={!canEdit || isImporting}
>
<Download />
{isImporting ? 'Importing...' : 'Import workflow'}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={handleCreateFolder}
disabled={!canEdit || isCreatingFolder}
>
<FolderPlus />
{isCreatingFolder ? 'Creating folder...' : 'Create folder'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
onClick={handleCreateWorkflow}
disabled={isCreatingWorkflow || !canEdit}
>
<Plus className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
{isCreatingWorkflow ? (
<p>Creating workflow...</p>
) : (
<Tooltip.Shortcut keys={isMac ? '⌘⇧P' : 'Ctrl+Shift+P'}>
New workflow
</Tooltip.Shortcut>
</div>
)}
</div>
{isCollapsed ? (
<CollapsedSidebarMenu
icon={tasksCollapsedIcon}
hover={tasksHover}
ariaLabel='Tasks'
className='mt-1.5'
primaryAction={tasksPrimaryAction}
>
{tasksLoading ? (
<DropdownMenuItem disabled>
<Loader className='h-[14px] w-[14px]' animate />
Loading...
</DropdownMenuItem>
) : (
tasks.map((task) => (
<CollapsedTaskFlyoutItem
key={task.id}
task={task}
isCurrentRoute={task.id !== 'new' && pathname === task.href}
isMenuOpen={menuOpenTaskId === task.id}
isEditing={task.id === taskFlyoutRename.editingId}
editValue={taskFlyoutRename.value}
inputRef={taskFlyoutRename.inputRef}
isRenaming={taskFlyoutRename.isSaving}
onEditValueChange={taskFlyoutRename.setValue}
onEditKeyDown={taskFlyoutRename.handleKeyDown}
onEditBlur={handleTaskRenameBlur}
onContextMenu={handleTaskContextMenu}
onMorePointerDown={handleTaskMorePointerDown}
onMoreClick={handleTaskMoreClick}
/>
))
)}
</CollapsedSidebarMenu>
) : (
<div className='mt-1.5 flex flex-col gap-0.5 px-2'>
{tasksLoading ? (
<SidebarItemSkeleton />
) : (
<>
{tasks.slice(0, visibleTaskCount).map((task) => {
const isCurrentRoute = task.id !== 'new' && pathname === task.href
const isRenaming = taskFlyoutRename.editingId === task.id
const isSelected = task.id !== 'new' && selectedTasks.has(task.id)
if (isRenaming) {
return (
<div
key={task.id}
className='mx-0.5 flex h-[30px] items-center gap-2 rounded-lg bg-[var(--surface-active)] px-2 text-sm'
>
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<input
ref={taskFlyoutRename.inputRef}
value={taskFlyoutRename.value}
onChange={(e) => taskFlyoutRename.setValue(e.target.value)}
onKeyDown={taskFlyoutRename.handleKeyDown}
onBlur={handleTaskRenameBlur}
className='min-w-0 flex-1 border-none bg-transparent font-base text-[14px] text-[var(--text-body)] outline-none'
/>
</div>
)
}
return (
<SidebarTaskItem
key={task.id}
task={task}
isCurrentRoute={isCurrentRoute}
isSelected={isSelected}
isActive={!!task.isActive}
isUnread={!!task.isUnread}
isMenuOpen={menuOpenTaskId === task.id}
showCollapsedTooltips={showCollapsedTooltips}
onMultiSelectClick={handleTaskClick}
onContextMenu={handleTaskContextMenu}
onMorePointerDown={handleTaskMorePointerDown}
onMoreClick={handleTaskMoreClick}
/>
)
})}
{tasks.length > visibleTaskCount && (
<button
type='button'
onClick={handleSeeMoreTasks}
className='mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-[var(--text-icon)] text-sm hover-hover:bg-[var(--surface-hover)]'
>
<MoreHorizontal className='h-[16px] w-[16px] flex-shrink-0' />
<span className='font-base'>See more</span>
</button>
)}
</Tooltip.Content>
</Tooltip.Root>
</>
)}
</div>
)}
</div>
{isCollapsed ? (
<CollapsedSidebarMenu
icon={workflowsCollapsedIcon}
hover={workflowsHover}
ariaLabel='Workflows'
className='mt-1.5'
primaryAction={workflowsPrimaryAction}
>
{workflowsLoading && regularWorkflows.length === 0 ? (
<DropdownMenuItem disabled>
<Loader className='h-[14px] w-[14px]' animate />
Loading...
</DropdownMenuItem>
) : regularWorkflows.length === 0 ? (
<DropdownMenuItem disabled>No workflows yet</DropdownMenuItem>
) : (
<>
<CollapsedFolderItems
nodes={folderTree}
workflowsByFolder={workflowsByFolder}
workspaceId={workspaceId}
currentWorkflowId={workflowId}
editingWorkflowId={workflowFlyoutRename.editingId}
editingValue={workflowFlyoutRename.value}
editInputRef={workflowFlyoutRename.inputRef}
isRenamingWorkflow={workflowFlyoutRename.isSaving}
onEditValueChange={workflowFlyoutRename.setValue}
onEditKeyDown={workflowFlyoutRename.handleKeyDown}
onEditBlur={handleWorkflowRenameBlur}
onWorkflowOpenInNewTab={handleCollapsedWorkflowOpenInNewTab}
onWorkflowRename={handleCollapsedWorkflowRename}
canRenameWorkflow={canEdit}
/>
{(workflowsByFolder.root || []).map((workflow) => (
<CollapsedWorkflowFlyoutItem
key={workflow.id}
workflow={workflow}
href={`/workspace/${workspaceId}/w/${workflow.id}`}
isCurrentRoute={workflow.id === workflowId}
isEditing={workflow.id === workflowFlyoutRename.editingId}
editValue={workflowFlyoutRename.value}
inputRef={workflowFlyoutRename.inputRef}
isRenaming={workflowFlyoutRename.isSaving}
<div
className='workflows-section relative mt-3.5 flex flex-col'
data-tour='nav-workflows'
>
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-4'>
<div className='font-base text-[var(--text-icon)] text-small'>
Workflows
</div>
{!isCollapsed && (
<div className='flex items-center justify-center gap-2'>
<DropdownMenu>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
disabled={!canEdit}
>
{isImporting || isCreatingFolder ? (
<Loader className='h-[16px] w-[16px]' animate />
) : (
<MoreHorizontal className='h-[16px] w-[16px]' />
)}
</Button>
</DropdownMenuTrigger>
</Tooltip.Trigger>
<Tooltip.Content>
<p>More actions</p>
</Tooltip.Content>
</Tooltip.Root>
<DropdownMenuContent
align='start'
sideOffset={8}
className='min-w-[160px]'
>
<DropdownMenuItem
onSelect={handleImportWorkflow}
disabled={!canEdit || isImporting}
>
<Download />
{isImporting ? 'Importing...' : 'Import workflow'}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={handleCreateFolder}
disabled={!canEdit || isCreatingFolder}
>
<FolderPlus />
{isCreatingFolder ? 'Creating folder...' : 'Create folder'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
onClick={handleCreateWorkflow}
disabled={isCreatingWorkflow || !canEdit}
>
<Plus className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
{isCreatingWorkflow ? (
<p>Creating workflow...</p>
) : (
<Tooltip.Shortcut keys={isMac ? '⌘⇧P' : 'Ctrl+Shift+P'}>
New workflow
</Tooltip.Shortcut>
)}
</Tooltip.Content>
</Tooltip.Root>
</div>
)}
</div>
{isCollapsed ? (
<CollapsedSidebarMenu
icon={workflowsCollapsedIcon}
hover={workflowsHover}
ariaLabel='Workflows'
className='mt-1.5'
primaryAction={workflowsPrimaryAction}
>
{workflowsLoading && regularWorkflows.length === 0 ? (
<DropdownMenuItem disabled>
<Loader className='h-[14px] w-[14px]' animate />
Loading...
</DropdownMenuItem>
) : regularWorkflows.length === 0 ? (
<DropdownMenuItem disabled>No workflows yet</DropdownMenuItem>
) : (
<>
<CollapsedFolderItems
nodes={folderTree}
workflowsByFolder={workflowsByFolder}
workspaceId={workspaceId}
currentWorkflowId={workflowId}
editingWorkflowId={workflowFlyoutRename.editingId}
editingValue={workflowFlyoutRename.value}
editInputRef={workflowFlyoutRename.inputRef}
isRenamingWorkflow={workflowFlyoutRename.isSaving}
onEditValueChange={workflowFlyoutRename.setValue}
onEditKeyDown={workflowFlyoutRename.handleKeyDown}
onEditBlur={handleWorkflowRenameBlur}
onOpenInNewTab={() => handleCollapsedWorkflowOpenInNewTab(workflow)}
onRename={() => handleCollapsedWorkflowRename(workflow)}
canRename={canEdit}
onWorkflowOpenInNewTab={handleCollapsedWorkflowOpenInNewTab}
onWorkflowRename={handleCollapsedWorkflowRename}
canRenameWorkflow={canEdit}
/>
))}
</>
)}
</CollapsedSidebarMenu>
) : (
<div className='mt-1.5 px-2'>
{workflowsLoading && regularWorkflows.length === 0 && (
<SidebarItemSkeleton />
)}
<WorkflowList
workspaceId={workspaceId}
workflowId={workflowId}
regularWorkflows={regularWorkflows}
isLoading={isLoading}
canReorder={canEdit}
handleFileChange={handleImportFileChange}
fileInputRef={fileInputRef}
scrollContainerRef={scrollContainerRef}
onCreateWorkflow={handleCreateWorkflow}
onCreateFolder={handleCreateFolder}
disableCreate={!canEdit || isCreatingWorkflow || isCreatingFolder}
/>
</div>
)}
{(workflowsByFolder.root || []).map((workflow) => (
<CollapsedWorkflowFlyoutItem
key={workflow.id}
workflow={workflow}
href={`/workspace/${workspaceId}/w/${workflow.id}`}
isCurrentRoute={workflow.id === workflowId}
isEditing={workflow.id === workflowFlyoutRename.editingId}
editValue={workflowFlyoutRename.value}
inputRef={workflowFlyoutRename.inputRef}
isRenaming={workflowFlyoutRename.isSaving}
onEditValueChange={workflowFlyoutRename.setValue}
onEditKeyDown={workflowFlyoutRename.handleKeyDown}
onEditBlur={handleWorkflowRenameBlur}
onOpenInNewTab={() =>
handleCollapsedWorkflowOpenInNewTab(workflow)
}
onRename={() => handleCollapsedWorkflowRename(workflow)}
canRename={canEdit}
/>
))}
</>
)}
</CollapsedSidebarMenu>
) : (
<div className='mt-1.5 px-2'>
{workflowsLoading && regularWorkflows.length === 0 && (
<SidebarItemSkeleton />
)}
<WorkflowList
workspaceId={workspaceId}
workflowId={workflowId}
regularWorkflows={regularWorkflows}
isLoading={isLoading}
canReorder={canEdit}
handleFileChange={handleImportFileChange}
fileInputRef={fileInputRef}
scrollContainerRef={scrollContainerRef}
onCreateWorkflow={handleCreateWorkflow}
onCreateFolder={handleCreateFolder}
disableCreate={!canEdit || isCreatingWorkflow || isCreatingFolder}
/>
</div>
)}
</div>
</div>
</div>

View File

@@ -0,0 +1,571 @@
import { CloudWatchIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { IntegrationType } from '@/blocks/types'
import type {
CloudWatchDescribeAlarmsResponse,
CloudWatchDescribeLogGroupsResponse,
CloudWatchDescribeLogStreamsResponse,
CloudWatchGetLogEventsResponse,
CloudWatchGetMetricStatisticsResponse,
CloudWatchListMetricsResponse,
CloudWatchQueryLogsResponse,
} from '@/tools/cloudwatch/types'
export const CloudWatchBlock: BlockConfig<
| CloudWatchQueryLogsResponse
| CloudWatchDescribeLogGroupsResponse
| CloudWatchDescribeLogStreamsResponse
| CloudWatchGetLogEventsResponse
| CloudWatchDescribeAlarmsResponse
| CloudWatchListMetricsResponse
| CloudWatchGetMetricStatisticsResponse
> = {
type: 'cloudwatch',
name: 'CloudWatch',
description: 'Query and monitor AWS CloudWatch logs, metrics, and alarms',
longDescription:
'Integrate AWS CloudWatch into workflows. Run Log Insights queries, list log groups, retrieve log events, list and get metrics, and monitor alarms. Requires AWS access key and secret access key.',
category: 'tools',
integrationType: IntegrationType.Analytics,
tags: ['cloud', 'monitoring'],
bgColor: 'linear-gradient(45deg, #B0084D 0%, #FF4F8B 100%)',
icon: CloudWatchIcon,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Query Logs (Insights)', id: 'query_logs' },
{ label: 'Describe Log Groups', id: 'describe_log_groups' },
{ label: 'Get Log Events', id: 'get_log_events' },
{ label: 'Describe Log Streams', id: 'describe_log_streams' },
{ label: 'List Metrics', id: 'list_metrics' },
{ label: 'Get Metric Statistics', id: 'get_metric_statistics' },
{ label: 'Describe Alarms', id: 'describe_alarms' },
],
value: () => 'query_logs',
},
{
id: 'awsRegion',
title: 'AWS Region',
type: 'short-input',
placeholder: 'us-east-1',
required: true,
},
{
id: 'awsAccessKeyId',
title: 'AWS Access Key ID',
type: 'short-input',
placeholder: 'AKIA...',
password: true,
required: true,
},
{
id: 'awsSecretAccessKey',
title: 'AWS Secret Access Key',
type: 'short-input',
placeholder: 'Your secret access key',
password: true,
required: true,
},
// Query Logs fields
{
id: 'logGroupSelector',
title: 'Log Group',
type: 'file-selector',
canonicalParamId: 'logGroupNames',
selectorKey: 'cloudwatch.logGroups',
dependsOn: ['awsAccessKeyId', 'awsSecretAccessKey', 'awsRegion'],
placeholder: 'Select a log group',
condition: { field: 'operation', value: 'query_logs' },
required: { field: 'operation', value: 'query_logs' },
mode: 'basic',
},
{
id: 'logGroupNamesInput',
title: 'Log Group Names',
type: 'short-input',
canonicalParamId: 'logGroupNames',
placeholder: '/aws/lambda/my-func, /aws/ecs/my-service',
condition: { field: 'operation', value: 'query_logs' },
required: { field: 'operation', value: 'query_logs' },
mode: 'advanced',
},
{
id: 'queryString',
title: 'Query',
type: 'code',
placeholder: 'fields @timestamp, @message\n| sort @timestamp desc\n| limit 20',
condition: { field: 'operation', value: 'query_logs' },
required: { field: 'operation', value: 'query_logs' },
wandConfig: {
enabled: true,
prompt: `Generate a CloudWatch Log Insights query based on the user's description.
The query language supports: fields, filter, stats, sort, limit, parse, display.
Common patterns:
- fields @timestamp, @message | sort @timestamp desc | limit 20
- filter @message like /ERROR/ | stats count(*) by bin(1h)
- stats avg(duration) as avgDuration by functionName | sort avgDuration desc
- filter @message like /Exception/ | parse @message "* Exception: *" as prefix, errorMsg
- stats count(*) as requestCount by status | sort requestCount desc
Return ONLY the query — no explanations, no markdown code blocks.`,
placeholder: 'Describe what you want to find in the logs...',
},
},
{
id: 'startTime',
title: 'Start Time (Unix epoch seconds)',
type: 'short-input',
placeholder: 'e.g., 1711900800',
condition: {
field: 'operation',
value: ['query_logs', 'get_log_events', 'get_metric_statistics'],
},
required: { field: 'operation', value: ['query_logs', 'get_metric_statistics'] },
},
{
id: 'endTime',
title: 'End Time (Unix epoch seconds)',
type: 'short-input',
placeholder: 'e.g., 1711987200',
condition: {
field: 'operation',
value: ['query_logs', 'get_log_events', 'get_metric_statistics'],
},
required: { field: 'operation', value: ['query_logs', 'get_metric_statistics'] },
},
// Describe Log Groups fields
{
id: 'prefix',
title: 'Log Group Name Prefix',
type: 'short-input',
placeholder: '/aws/lambda/',
condition: { field: 'operation', value: 'describe_log_groups' },
},
// Get Log Events / Describe Log Streams — shared log group selector
{
id: 'logGroupNameSelector',
title: 'Log Group',
type: 'file-selector',
canonicalParamId: 'logGroupName',
selectorKey: 'cloudwatch.logGroups',
dependsOn: ['awsAccessKeyId', 'awsSecretAccessKey', 'awsRegion'],
placeholder: 'Select a log group',
condition: { field: 'operation', value: ['get_log_events', 'describe_log_streams'] },
required: { field: 'operation', value: ['get_log_events', 'describe_log_streams'] },
mode: 'basic',
},
{
id: 'logGroupNameInput',
title: 'Log Group Name',
type: 'short-input',
canonicalParamId: 'logGroupName',
placeholder: '/aws/lambda/my-func',
condition: { field: 'operation', value: ['get_log_events', 'describe_log_streams'] },
required: { field: 'operation', value: ['get_log_events', 'describe_log_streams'] },
mode: 'advanced',
},
// Describe Log Streams — stream prefix filter
{
id: 'streamPrefix',
title: 'Stream Name Prefix',
type: 'short-input',
placeholder: '2024/03/31/',
condition: { field: 'operation', value: 'describe_log_streams' },
},
// Get Log Events — log stream selector (cascading: depends on log group)
{
id: 'logStreamNameSelector',
title: 'Log Stream',
type: 'file-selector',
canonicalParamId: 'logStreamName',
selectorKey: 'cloudwatch.logStreams',
dependsOn: ['awsAccessKeyId', 'awsSecretAccessKey', 'awsRegion', 'logGroupNameSelector'],
placeholder: 'Select a log stream',
condition: { field: 'operation', value: 'get_log_events' },
required: { field: 'operation', value: 'get_log_events' },
mode: 'basic',
},
{
id: 'logStreamNameInput',
title: 'Log Stream Name',
type: 'short-input',
canonicalParamId: 'logStreamName',
placeholder: '2024/03/31/[$LATEST]abc123',
condition: { field: 'operation', value: 'get_log_events' },
required: { field: 'operation', value: 'get_log_events' },
mode: 'advanced',
},
// List Metrics fields
{
id: 'metricNamespace',
title: 'Namespace',
type: 'short-input',
placeholder: 'e.g., AWS/EC2, AWS/Lambda, AWS/RDS',
condition: { field: 'operation', value: ['list_metrics', 'get_metric_statistics'] },
required: { field: 'operation', value: 'get_metric_statistics' },
},
{
id: 'metricName',
title: 'Metric Name',
type: 'short-input',
placeholder: 'e.g., CPUUtilization, Invocations',
condition: { field: 'operation', value: ['list_metrics', 'get_metric_statistics'] },
required: { field: 'operation', value: 'get_metric_statistics' },
},
{
id: 'recentlyActive',
title: 'Recently Active Only',
type: 'switch',
condition: { field: 'operation', value: 'list_metrics' },
},
// Get Metric Statistics fields
{
id: 'metricPeriod',
title: 'Period (seconds)',
type: 'short-input',
placeholder: 'e.g., 60, 300, 3600',
condition: { field: 'operation', value: 'get_metric_statistics' },
required: { field: 'operation', value: 'get_metric_statistics' },
},
{
id: 'metricStatistics',
title: 'Statistics',
type: 'dropdown',
options: [
{ label: 'Average', id: 'Average' },
{ label: 'Sum', id: 'Sum' },
{ label: 'Minimum', id: 'Minimum' },
{ label: 'Maximum', id: 'Maximum' },
{ label: 'Sample Count', id: 'SampleCount' },
],
condition: { field: 'operation', value: 'get_metric_statistics' },
required: { field: 'operation', value: 'get_metric_statistics' },
},
{
id: 'metricDimensions',
title: 'Dimensions',
type: 'table',
columns: ['name', 'value'],
condition: { field: 'operation', value: 'get_metric_statistics' },
},
// Describe Alarms fields
{
id: 'alarmNamePrefix',
title: 'Alarm Name Prefix',
type: 'short-input',
placeholder: 'my-service-',
condition: { field: 'operation', value: 'describe_alarms' },
},
{
id: 'stateValue',
title: 'State',
type: 'dropdown',
options: [
{ label: 'All States', id: '' },
{ label: 'OK', id: 'OK' },
{ label: 'ALARM', id: 'ALARM' },
{ label: 'INSUFFICIENT_DATA', id: 'INSUFFICIENT_DATA' },
],
condition: { field: 'operation', value: 'describe_alarms' },
},
{
id: 'alarmType',
title: 'Alarm Type',
type: 'dropdown',
options: [
{ label: 'All Types', id: '' },
{ label: 'Metric Alarm', id: 'MetricAlarm' },
{ label: 'Composite Alarm', id: 'CompositeAlarm' },
],
condition: { field: 'operation', value: 'describe_alarms' },
},
// Shared limit field
{
id: 'limit',
title: 'Limit',
type: 'short-input',
placeholder: '100',
condition: {
field: 'operation',
value: [
'query_logs',
'describe_log_groups',
'get_log_events',
'describe_log_streams',
'list_metrics',
'describe_alarms',
],
},
},
],
tools: {
access: [
'cloudwatch_query_logs',
'cloudwatch_describe_log_groups',
'cloudwatch_get_log_events',
'cloudwatch_describe_log_streams',
'cloudwatch_list_metrics',
'cloudwatch_get_metric_statistics',
'cloudwatch_describe_alarms',
],
config: {
tool: (params) => {
switch (params.operation) {
case 'query_logs':
return 'cloudwatch_query_logs'
case 'describe_log_groups':
return 'cloudwatch_describe_log_groups'
case 'get_log_events':
return 'cloudwatch_get_log_events'
case 'describe_log_streams':
return 'cloudwatch_describe_log_streams'
case 'list_metrics':
return 'cloudwatch_list_metrics'
case 'get_metric_statistics':
return 'cloudwatch_get_metric_statistics'
case 'describe_alarms':
return 'cloudwatch_describe_alarms'
default:
throw new Error(`Invalid CloudWatch operation: ${params.operation}`)
}
},
params: (params) => {
const { operation, startTime, endTime, limit, ...rest } = params
const awsRegion = rest.awsRegion
const awsAccessKeyId = rest.awsAccessKeyId
const awsSecretAccessKey = rest.awsSecretAccessKey
const parsedLimit = limit ? Number.parseInt(String(limit), 10) : undefined
switch (operation) {
case 'query_logs': {
const logGroupNames = rest.logGroupNames
if (!logGroupNames) {
throw new Error('Log group names are required')
}
if (!startTime) {
throw new Error('Start time is required')
}
if (!endTime) {
throw new Error('End time is required')
}
const groupNames =
typeof logGroupNames === 'string'
? logGroupNames
.split(',')
.map((n: string) => n.trim())
.filter(Boolean)
: logGroupNames
return {
awsRegion,
awsAccessKeyId,
awsSecretAccessKey,
logGroupNames: groupNames,
queryString: rest.queryString,
startTime: Number(startTime),
endTime: Number(endTime),
...(parsedLimit !== undefined && { limit: parsedLimit }),
}
}
case 'describe_log_groups':
return {
awsRegion,
awsAccessKeyId,
awsSecretAccessKey,
...(rest.prefix && { prefix: rest.prefix }),
...(parsedLimit !== undefined && { limit: parsedLimit }),
}
case 'get_log_events': {
if (!rest.logGroupName) {
throw new Error('Log group name is required')
}
if (!rest.logStreamName) {
throw new Error('Log stream name is required')
}
return {
awsRegion,
awsAccessKeyId,
awsSecretAccessKey,
logGroupName: rest.logGroupName,
logStreamName: rest.logStreamName,
...(startTime && { startTime: Number(startTime) }),
...(endTime && { endTime: Number(endTime) }),
...(parsedLimit !== undefined && { limit: parsedLimit }),
}
}
case 'describe_log_streams': {
if (!rest.logGroupName) {
throw new Error('Log group name is required')
}
return {
awsRegion,
awsAccessKeyId,
awsSecretAccessKey,
logGroupName: rest.logGroupName,
...(rest.streamPrefix && { prefix: rest.streamPrefix }),
...(parsedLimit !== undefined && { limit: parsedLimit }),
}
}
case 'list_metrics':
return {
awsRegion,
awsAccessKeyId,
awsSecretAccessKey,
...(rest.metricNamespace && { namespace: rest.metricNamespace }),
...(rest.metricName && { metricName: rest.metricName }),
...(rest.recentlyActive && { recentlyActive: true }),
...(parsedLimit !== undefined && { limit: parsedLimit }),
}
case 'get_metric_statistics': {
if (!rest.metricNamespace) {
throw new Error('Namespace is required')
}
if (!rest.metricName) {
throw new Error('Metric name is required')
}
if (!startTime) {
throw new Error('Start time is required')
}
if (!endTime) {
throw new Error('End time is required')
}
if (!rest.metricPeriod) {
throw new Error('Period is required')
}
const stat = rest.metricStatistics
if (!stat) {
throw new Error('Statistics is required')
}
return {
awsRegion,
awsAccessKeyId,
awsSecretAccessKey,
namespace: rest.metricNamespace,
metricName: rest.metricName,
startTime: Number(startTime),
endTime: Number(endTime),
period: Number(rest.metricPeriod),
statistics: Array.isArray(stat) ? stat : [stat],
...(rest.metricDimensions && {
dimensions: (() => {
const dims = rest.metricDimensions
if (typeof dims === 'string') return dims
if (Array.isArray(dims)) {
const obj: Record<string, string> = {}
for (const row of dims) {
const name = row.cells?.name
const value = row.cells?.value
if (name && value !== undefined) obj[name] = String(value)
}
return JSON.stringify(obj)
}
return JSON.stringify(dims)
})(),
}),
}
}
case 'describe_alarms':
return {
awsRegion,
awsAccessKeyId,
awsSecretAccessKey,
...(rest.alarmNamePrefix && { alarmNamePrefix: rest.alarmNamePrefix }),
...(rest.stateValue && { stateValue: rest.stateValue }),
...(rest.alarmType && { alarmType: rest.alarmType }),
...(parsedLimit !== undefined && { limit: parsedLimit }),
}
default:
throw new Error(`Invalid CloudWatch operation: ${operation}`)
}
},
},
},
inputs: {
operation: { type: 'string', description: 'CloudWatch operation to perform' },
awsRegion: { type: 'string', description: 'AWS region' },
awsAccessKeyId: { type: 'string', description: 'AWS access key ID' },
awsSecretAccessKey: { type: 'string', description: 'AWS secret access key' },
logGroupNames: { type: 'string', description: 'Log group name(s) for query' },
queryString: { type: 'string', description: 'CloudWatch Log Insights query string' },
startTime: { type: 'string', description: 'Start time as Unix epoch seconds' },
endTime: { type: 'string', description: 'End time as Unix epoch seconds' },
prefix: { type: 'string', description: 'Log group name prefix filter' },
logGroupName: {
type: 'string',
description: 'Log group name for get events / describe streams',
},
logStreamName: { type: 'string', description: 'Log stream name for get events' },
streamPrefix: { type: 'string', description: 'Log stream name prefix filter' },
metricNamespace: { type: 'string', description: 'Metric namespace (e.g., AWS/EC2)' },
metricName: { type: 'string', description: 'Metric name (e.g., CPUUtilization)' },
recentlyActive: { type: 'boolean', description: 'Only show recently active metrics' },
metricPeriod: { type: 'number', description: 'Granularity in seconds' },
metricStatistics: { type: 'string', description: 'Statistic type (Average, Sum, etc.)' },
metricDimensions: { type: 'json', description: 'Metric dimensions (Name/Value pairs)' },
alarmNamePrefix: { type: 'string', description: 'Alarm name prefix filter' },
stateValue: {
type: 'string',
description: 'Alarm state filter (OK, ALARM, INSUFFICIENT_DATA)',
},
alarmType: { type: 'string', description: 'Alarm type filter (MetricAlarm, CompositeAlarm)' },
limit: { type: 'number', description: 'Maximum number of results' },
},
outputs: {
results: {
type: 'array',
description: 'Log Insights query result rows',
},
statistics: {
type: 'json',
description: 'Query statistics (bytesScanned, recordsMatched, recordsScanned)',
},
status: {
type: 'string',
description: 'Query completion status',
},
logGroups: {
type: 'array',
description: 'List of CloudWatch log groups',
},
events: {
type: 'array',
description: 'Log events with timestamp and message',
},
logStreams: {
type: 'array',
description: 'Log streams with metadata',
},
metrics: {
type: 'array',
description: 'List of available metrics',
},
label: {
type: 'string',
description: 'Metric label',
},
datapoints: {
type: 'array',
description: 'Metric datapoints with timestamps and values',
},
alarms: {
type: 'array',
description: 'CloudWatch alarms with state and configuration',
},
},
}

View File

@@ -24,6 +24,7 @@ import { CirclebackBlock } from '@/blocks/blocks/circleback'
import { ClayBlock } from '@/blocks/blocks/clay'
import { ClerkBlock } from '@/blocks/blocks/clerk'
import { CloudflareBlock } from '@/blocks/blocks/cloudflare'
import { CloudWatchBlock } from '@/blocks/blocks/cloudwatch'
import { ConditionBlock } from '@/blocks/blocks/condition'
import { ConfluenceBlock, ConfluenceV2Block } from '@/blocks/blocks/confluence'
import { CredentialBlock } from '@/blocks/blocks/credential'
@@ -241,6 +242,7 @@ export const registry: Record<string, BlockConfig> = {
chat_trigger: ChatTriggerBlock,
circleback: CirclebackBlock,
cloudflare: CloudflareBlock,
cloudwatch: CloudWatchBlock,
clay: ClayBlock,
clerk: ClerkBlock,
condition: ConditionBlock,

View File

@@ -72,6 +72,8 @@ export { Table } from './table'
export { TableX } from './table-x'
export { TagIcon } from './tag'
export { TerminalWindow } from './terminal-window'
export { ThumbsDown } from './thumbs-down'
export { ThumbsUp } from './thumbs-up'
export { Trash } from './trash'
export { TrashOutline } from './trash-outline'
export { Trash2 } from './trash2'

View File

@@ -0,0 +1,28 @@
import type { SVGProps } from 'react'
/**
* ThumbsDown icon component
* @param props - SVG properties including className, fill, etc.
*/
export function ThumbsDown(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.75'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<g transform='scale(1,-1) translate(0,-20)'>
<path d='M6 8v12' />
<path d='M14 3.88L13 8h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 16.5 20H3a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L11 0a3.13 3.13 0 0 1 3 3.88Z' />
</g>
</svg>
)
}

View File

@@ -0,0 +1,26 @@
import type { SVGProps } from 'react'
/**
* ThumbsUp icon component
* @param props - SVG properties including className, fill, etc.
*/
export function ThumbsUp(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.75'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<path d='M6 8v12' />
<path d='M14 3.88L13 8h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 16.5 20H3a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L11 0a3.13 3.13 0 0 1 3 3.88Z' />
</svg>
)
}

View File

@@ -4653,6 +4653,33 @@ export function SQSIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function CloudWatchIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
viewBox='0 0 80 80'
version='1.1'
xmlns='http://www.w3.org/2000/svg'
xmlnsXlink='http://www.w3.org/1999/xlink'
>
<g
id='Icon-Architecture/64/Arch_Amazon-CloudWatch_64'
stroke='none'
strokeWidth='1'
fill='none'
fillRule='evenodd'
transform='translate(40, 40) scale(1.25) translate(-40, -40)'
>
<path
d='M53,42 L41,42 L41,24 L43,24 L43,40 L53,40 L53,42 Z M40,66 C24.561,66 12,53.439 12,38 C12,22.561 24.561,10 40,10 C55.439,10 68,22.561 68,38 C68,53.439 55.439,66 40,66 M40,8 C23.458,8 10,21.458 10,38 C10,54.542 23.458,68 40,68 C56.542,68 70,54.542 70,38 C70,21.458 56.542,8 40,8'
id='Amazon-CloudWatch_Icon_64_Squid'
fill='currentColor'
/>
</g>
</svg>
)
}
export function TextractIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg

View File

@@ -0,0 +1,39 @@
import { createLogger } from '@sim/logger'
import { useMutation } from '@tanstack/react-query'
const logger = createLogger('CopilotFeedbackMutation')
interface SubmitFeedbackVariables {
chatId: string
userQuery: string
agentResponse: string
isPositiveFeedback: boolean
feedback?: string
}
interface SubmitFeedbackResponse {
success: boolean
feedbackId: string
}
export function useSubmitCopilotFeedback() {
return useMutation({
mutationFn: async (variables: SubmitFeedbackVariables): Promise<SubmitFeedbackResponse> => {
const response = await fetch('/api/copilot/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(variables),
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error || 'Failed to submit feedback')
}
return response.json()
},
onError: (error) => {
logger.error('Failed to submit copilot feedback:', error)
},
})
}

View File

@@ -1716,6 +1716,81 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
}))
},
},
'cloudwatch.logGroups': {
key: 'cloudwatch.logGroups',
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'cloudwatch.logGroups',
context.awsAccessKeyId ?? 'none',
context.awsRegion ?? 'none',
],
enabled: ({ context }) =>
Boolean(context.awsAccessKeyId && context.awsSecretAccessKey && context.awsRegion),
fetchList: async ({ context, search }: SelectorQueryArgs) => {
const body = JSON.stringify({
accessKeyId: context.awsAccessKeyId,
secretAccessKey: context.awsSecretAccessKey,
region: context.awsRegion,
...(search && { prefix: search }),
})
const data = await fetchJson<{
output: { logGroups: { logGroupName: string }[] }
}>('/api/tools/cloudwatch/describe-log-groups', {
method: 'POST',
body,
})
return (data.output?.logGroups || []).map((lg) => ({
id: lg.logGroupName,
label: lg.logGroupName,
}))
},
fetchById: async ({ detailId }: SelectorQueryArgs) => {
if (!detailId) return null
return { id: detailId, label: detailId }
},
},
'cloudwatch.logStreams': {
key: 'cloudwatch.logStreams',
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'cloudwatch.logStreams',
context.awsAccessKeyId ?? 'none',
context.awsRegion ?? 'none',
context.logGroupName ?? 'none',
],
enabled: ({ context }) =>
Boolean(
context.awsAccessKeyId &&
context.awsSecretAccessKey &&
context.awsRegion &&
context.logGroupName
),
fetchList: async ({ context, search }: SelectorQueryArgs) => {
const body = JSON.stringify({
accessKeyId: context.awsAccessKeyId,
secretAccessKey: context.awsSecretAccessKey,
region: context.awsRegion,
logGroupName: context.logGroupName,
...(search && { prefix: search }),
})
const data = await fetchJson<{
output: { logStreams: { logStreamName: string; lastEventTimestamp?: number }[] }
}>('/api/tools/cloudwatch/describe-log-streams', {
method: 'POST',
body,
})
return (data.output?.logStreams || []).map((ls) => ({
id: ls.logStreamName,
label: ls.logStreamName,
}))
},
fetchById: async ({ detailId }: SelectorQueryArgs) => {
if (!detailId) return null
return { id: detailId, label: detailId }
},
},
'sim.workflows': {
key: 'sim.workflows',
staleTime: SELECTOR_STALE,

View File

@@ -49,6 +49,8 @@ export type SelectorKey =
| 'webflow.sites'
| 'webflow.collections'
| 'webflow.items'
| 'cloudwatch.logGroups'
| 'cloudwatch.logStreams'
| 'sim.workflows'
export interface SelectorOption {
@@ -78,6 +80,10 @@ export interface SelectorContext {
datasetId?: string
serviceDeskId?: string
impersonateUserEmail?: string
awsAccessKeyId?: string
awsSecretAccessKey?: string
awsRegion?: string
logGroupName?: string
}
export interface SelectorQueryArgs {

View File

@@ -140,25 +140,17 @@ async function initializeOpenTelemetry() {
sdk.start()
const shutdownHandler = async () => {
const shutdownOtel = async () => {
try {
await sdk.shutdown()
logger.info('OpenTelemetry SDK shut down successfully')
} catch (err) {
logger.error('Error shutting down OpenTelemetry SDK', err)
}
try {
const { getPostHogClient } = await import('@/lib/posthog/server')
await getPostHogClient()?.shutdown()
logger.info('PostHog client shut down successfully')
} catch (err) {
logger.error('Error shutting down PostHog client', err)
}
}
process.on('SIGTERM', shutdownHandler)
process.on('SIGINT', shutdownHandler)
process.on('SIGTERM', shutdownOtel)
process.on('SIGINT', shutdownOtel)
logger.info('OpenTelemetry instrumentation initialized with business span filtering')
} catch (error) {
@@ -169,6 +161,19 @@ async function initializeOpenTelemetry() {
export async function register() {
await initializeOpenTelemetry()
const shutdownPostHog = async () => {
try {
const { getPostHogClient } = await import('@/lib/posthog/server')
await getPostHogClient()?.shutdown()
logger.info('PostHog client shut down successfully')
} catch (err) {
logger.error('Error shutting down PostHog client', err)
}
}
process.on('SIGTERM', shutdownPostHog)
process.on('SIGINT', shutdownPostHog)
const { startMemoryTelemetry } = await import('./lib/monitoring/memory-telemetry')
startMemoryTelemetry()
}

View File

@@ -40,6 +40,7 @@ export const buildTimeCSPDirectives: CSPDirectives = {
'https://*.google.com',
'https://apis.google.com',
'https://assets.onedollarstats.com',
'https://challenges.cloudflare.com',
...(isReactGrabEnabled ? ['https://unpkg.com'] : []),
],
@@ -102,6 +103,7 @@ export const buildTimeCSPDirectives: CSPDirectives = {
'https://*.supabase.co',
'https://api.github.com',
'https://github.com/*',
'https://challenges.cloudflare.com',
'https://collector.onedollarstats.com',
...getHostnameFromUrl(env.NEXT_PUBLIC_BRAND_LOGO_URL),
...getHostnameFromUrl(env.NEXT_PUBLIC_PRIVACY_URL),
@@ -110,6 +112,7 @@ export const buildTimeCSPDirectives: CSPDirectives = {
'frame-src': [
"'self'",
'https://challenges.cloudflare.com',
'https://drive.google.com',
'https://docs.google.com',
'https://*.google.com',
@@ -171,13 +174,13 @@ export function generateRuntimeCSP(): string {
return `
default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.google.com https://apis.google.com https://assets.onedollarstats.com ${reactGrabScript};
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.google.com https://apis.google.com https://assets.onedollarstats.com https://challenges.cloudflare.com ${reactGrabScript};
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
img-src 'self' data: blob: https://*.googleusercontent.com https://*.google.com https://*.atlassian.com https://cdn.discordapp.com https://*.githubusercontent.com https://*.s3.amazonaws.com https://s3.amazonaws.com https://*.amazonaws.com https://*.blob.core.windows.net https://github.com/* https://collector.onedollarstats.com ${brandLogoDomain} ${brandFaviconDomain};
media-src 'self' blob:;
font-src 'self' https://fonts.gstatic.com;
connect-src 'self' ${appUrl} ${ollamaUrl} ${socketUrl} ${socketWsUrl} https://api.browser-use.com https://api.exa.ai https://api.firecrawl.dev https://*.googleapis.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.blob.core.windows.net https://api.github.com https://github.com/* https://*.atlassian.com https://*.supabase.co https://collector.onedollarstats.com ${dynamicDomainsStr};
frame-src 'self' https://drive.google.com https://docs.google.com https://*.google.com;
connect-src 'self' ${appUrl} ${ollamaUrl} ${socketUrl} ${socketWsUrl} https://api.browser-use.com https://api.exa.ai https://api.firecrawl.dev https://*.googleapis.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.blob.core.windows.net https://api.github.com https://github.com/* https://*.atlassian.com https://*.supabase.co https://challenges.cloudflare.com https://collector.onedollarstats.com ${dynamicDomainsStr};
frame-src 'self' https://challenges.cloudflare.com https://drive.google.com https://docs.google.com https://*.google.com;
frame-ancestors 'self';
form-action 'self';
base-uri 'self';

View File

@@ -123,6 +123,7 @@ describe('executeWorkflowCore terminal finalization sequencing', () => {
requestId: 'req-1',
workflowId: 'workflow-1',
userId: 'user-1',
workflowUserId: 'workflow-owner',
workspaceId: 'workspace-1',
triggerType: 'api',
executionId: 'execution-1',
@@ -755,4 +756,92 @@ describe('executeWorkflowCore terminal finalization sequencing', () => {
expect(safeCompleteWithErrorMock).not.toHaveBeenCalled()
expect(wasExecutionFinalizedByCore(envError, 'execution-no-log-start')).toBe(false)
})
it('uses sessionUserId for env resolution when isClientSession is true', async () => {
const snapshot = {
...createSnapshot(),
metadata: {
...createSnapshot().metadata,
isClientSession: true,
sessionUserId: 'session-user',
workflowUserId: 'workflow-owner',
},
}
getPersonalAndWorkspaceEnvMock.mockResolvedValue({
personalEncrypted: {},
workspaceEncrypted: {},
personalDecrypted: {},
workspaceDecrypted: {},
})
safeStartMock.mockResolvedValue(true)
executorExecuteMock.mockResolvedValue({
output: { done: true },
logs: [],
metadata: { duration: 123, startTime: 'start', endTime: 'end' },
})
await executeWorkflowCore({
snapshot: snapshot as any,
callbacks: {},
loggingSession: loggingSession as any,
})
expect(getPersonalAndWorkspaceEnvMock).toHaveBeenCalledWith('session-user', 'workspace-1')
})
it('uses workflowUserId for env resolution in server-side execution', async () => {
const snapshot = {
...createSnapshot(),
metadata: {
...createSnapshot().metadata,
isClientSession: false,
sessionUserId: undefined,
workflowUserId: 'workflow-owner',
userId: 'billing-actor',
},
}
getPersonalAndWorkspaceEnvMock.mockResolvedValue({
personalEncrypted: {},
workspaceEncrypted: {},
personalDecrypted: {},
workspaceDecrypted: {},
})
safeStartMock.mockResolvedValue(true)
executorExecuteMock.mockResolvedValue({
output: { done: true },
logs: [],
metadata: { duration: 123, startTime: 'start', endTime: 'end' },
})
await executeWorkflowCore({
snapshot: snapshot as any,
callbacks: {},
loggingSession: loggingSession as any,
})
expect(getPersonalAndWorkspaceEnvMock).toHaveBeenCalledWith('workflow-owner', 'workspace-1')
})
it('throws when workflowUserId is missing in server-side execution', async () => {
const snapshot = {
...createSnapshot(),
metadata: {
...createSnapshot().metadata,
isClientSession: false,
sessionUserId: undefined,
workflowUserId: undefined,
userId: 'billing-actor',
},
}
await expect(
executeWorkflowCore({
snapshot: snapshot as any,
callbacks: {},
loggingSession: loggingSession as any,
})
).rejects.toThrow('Missing workflowUserId in execution metadata')
})
})

View File

@@ -325,10 +325,13 @@ export async function executeWorkflowCore(
const mergedStates = mergeSubblockStateWithValues(blocks)
const personalEnvUserId = metadata.sessionUserId || metadata.userId
const personalEnvUserId =
metadata.isClientSession && metadata.sessionUserId
? metadata.sessionUserId
: metadata.workflowUserId
if (!personalEnvUserId) {
throw new Error('Missing execution actor for environment resolution')
throw new Error('Missing workflowUserId in execution metadata')
}
const { personalEncrypted, workspaceEncrypted, personalDecrypted, workspaceDecrypted } =

View File

@@ -22,6 +22,10 @@ export const SELECTOR_CONTEXT_FIELDS = new Set<keyof SelectorContext>([
'datasetId',
'serviceDeskId',
'impersonateUserEmail',
'awsAccessKeyId',
'awsSecretAccessKey',
'awsRegion',
'logGroupName',
])
/**

View File

@@ -37,6 +37,8 @@
"@a2a-js/sdk": "0.3.7",
"@anthropic-ai/sdk": "0.71.2",
"@aws-sdk/client-bedrock-runtime": "3.940.0",
"@aws-sdk/client-cloudwatch": "3.940.0",
"@aws-sdk/client-cloudwatch-logs": "3.940.0",
"@aws-sdk/client-dynamodb": "3.940.0",
"@aws-sdk/client-rds-data": "3.940.0",
"@aws-sdk/client-s3": "^3.779.0",
@@ -154,8 +156,8 @@
"papaparse": "5.5.3",
"pdf-lib": "1.17.1",
"postgres": "^3.4.5",
"posthog-js": "1.334.1",
"posthog-node": "5.9.2",
"posthog-js": "1.364.4",
"posthog-node": "5.28.9",
"pptxgenjs": "4.0.1",
"pptxviewjs": "1.1.8",
"prismjs": "^1.30.0",

View File

@@ -271,6 +271,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 400000,
},
@@ -290,6 +291,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 400000,
},
@@ -324,6 +326,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 400000,
},
@@ -342,6 +345,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 400000,
},
@@ -360,6 +364,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 400000,
},
@@ -373,6 +378,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 2 },
maxOutputTokens: 16384,
},
contextWindow: 128000,
},
@@ -449,6 +455,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
reasoningEffort: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 100000,
},
contextWindow: 200000,
},
@@ -463,6 +470,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 2 },
maxOutputTokens: 16384,
},
contextWindow: 128000,
},
@@ -509,7 +517,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true,
maxOutputTokens: 128000,
maxOutputTokens: 64000,
thinking: {
levels: ['low', 'medium', 'high', 'max'],
default: 'high',
@@ -741,6 +749,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 400000,
},
@@ -759,6 +768,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 400000,
},
@@ -777,6 +787,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 400000,
},
@@ -795,6 +806,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 400000,
},
@@ -813,6 +825,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 400000,
},
@@ -831,6 +844,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 400000,
},
@@ -1067,6 +1081,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
levels: ['minimal', 'low', 'medium', 'high'],
default: 'high',
},
maxOutputTokens: 65536,
},
contextWindow: 1048576,
},
@@ -1084,6 +1099,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
levels: ['minimal', 'low', 'medium', 'high'],
default: 'minimal',
},
maxOutputTokens: 65536,
},
contextWindow: 1048576,
},
@@ -1101,6 +1117,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
levels: ['minimal', 'low', 'medium', 'high'],
default: 'high',
},
maxOutputTokens: 65536,
},
contextWindow: 1000000,
},
@@ -1114,6 +1131,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 2 },
maxOutputTokens: 65536,
},
contextWindow: 1048576,
},
@@ -1127,6 +1145,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 2 },
maxOutputTokens: 65536,
},
contextWindow: 1048576,
},
@@ -1140,6 +1159,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 2 },
maxOutputTokens: 65536,
},
contextWindow: 1048576,
},
@@ -1153,6 +1173,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 2 },
maxOutputTokens: 8192,
},
contextWindow: 1048576,
},
@@ -1165,6 +1186,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 2 },
maxOutputTokens: 8192,
},
contextWindow: 1048576,
},
@@ -1178,6 +1200,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
deepResearch: true,
memory: false,
maxOutputTokens: 65536,
},
contextWindow: 1000000,
},
@@ -2094,6 +2117,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true,
maxOutputTokens: 64000,
},
contextWindow: 200000,
},
@@ -2107,6 +2131,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true,
maxOutputTokens: 64000,
},
contextWindow: 200000,
},
@@ -2120,6 +2145,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true,
maxOutputTokens: 64000,
},
contextWindow: 200000,
},
@@ -2133,6 +2159,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true,
maxOutputTokens: 64000,
},
contextWindow: 200000,
},
@@ -2337,6 +2364,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 1 },
maxOutputTokens: 32768,
},
contextWindow: 128000,
},
@@ -2373,6 +2401,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 1 },
maxOutputTokens: 16384,
},
contextWindow: 128000,
},
@@ -2385,6 +2414,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 1 },
maxOutputTokens: 40000,
},
contextWindow: 128000,
},
@@ -2397,6 +2427,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 1 },
maxOutputTokens: 8192,
},
contextWindow: 128000,
},
@@ -2409,6 +2440,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 1 },
maxOutputTokens: 8192,
},
contextWindow: 128000,
},
@@ -2421,6 +2453,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 1 },
maxOutputTokens: 8192,
},
contextWindow: 128000,
},
@@ -2863,13 +2896,17 @@ export function getModelsWithoutMemory(): string[] {
export function getMaxOutputTokensForModel(modelId: string): number {
const normalizedModelId = modelId.toLowerCase()
const STANDARD_MAX_OUTPUT_TOKENS = 4096
const allModels = Object.values(PROVIDER_DEFINITIONS).flatMap((provider) => provider.models)
for (const provider of Object.values(PROVIDER_DEFINITIONS)) {
for (const model of provider.models) {
const baseModelId = model.id.toLowerCase()
if (normalizedModelId === baseModelId || normalizedModelId.startsWith(`${baseModelId}-`)) {
return model.capabilities.maxOutputTokens || STANDARD_MAX_OUTPUT_TOKENS
}
const exactMatch = allModels.find((model) => model.id.toLowerCase() === normalizedModelId)
if (exactMatch) {
return exactMatch.capabilities.maxOutputTokens || STANDARD_MAX_OUTPUT_TOKENS
}
for (const model of allModels) {
const baseModelId = model.id.toLowerCase()
if (normalizedModelId.startsWith(`${baseModelId}-`)) {
return model.capabilities.maxOutputTokens || STANDARD_MAX_OUTPUT_TOKENS
}
}

View File

@@ -664,6 +664,45 @@ describe('Model Capabilities', () => {
describe('Max Output Tokens', () => {
describe('getMaxOutputTokensForModel', () => {
it.concurrent('should return published max for OpenAI GPT-4o', () => {
expect(getMaxOutputTokensForModel('gpt-4o')).toBe(16384)
})
it.concurrent('should return published max for OpenAI GPT-5.1', () => {
expect(getMaxOutputTokensForModel('gpt-5.1')).toBe(128000)
})
it.concurrent('should return published max for OpenAI GPT-5 Chat', () => {
expect(getMaxOutputTokensForModel('gpt-5-chat-latest')).toBe(16384)
})
it.concurrent('should return published max for OpenAI o1', () => {
expect(getMaxOutputTokensForModel('o1')).toBe(100000)
})
it.concurrent('should return updated max for Claude Sonnet 4.6', () => {
expect(getMaxOutputTokensForModel('claude-sonnet-4-6')).toBe(64000)
})
it.concurrent('should return published max for Gemini 2.5 Pro', () => {
expect(getMaxOutputTokensForModel('gemini-2.5-pro')).toBe(65536)
})
it.concurrent('should return published max for Azure GPT-5.2', () => {
expect(getMaxOutputTokensForModel('azure/gpt-5.2')).toBe(128000)
})
it.concurrent('should return standard default for models without maxOutputTokens', () => {
expect(getMaxOutputTokensForModel('deepseek-reasoner')).toBe(4096)
expect(getMaxOutputTokensForModel('grok-4-latest')).toBe(4096)
})
it.concurrent('should return published max for Bedrock Claude Opus 4.1', () => {
expect(getMaxOutputTokensForModel('bedrock/anthropic.claude-opus-4-1-20250805-v1:0')).toBe(
64000
)
})
it.concurrent('should return correct max for Claude Opus 4.6', () => {
expect(getMaxOutputTokensForModel('claude-opus-4-6')).toBe(128000)
})
@@ -676,10 +715,6 @@ describe('Max Output Tokens', () => {
expect(getMaxOutputTokensForModel('claude-opus-4-1')).toBe(32000)
})
it.concurrent('should return standard default for models without maxOutputTokens', () => {
expect(getMaxOutputTokensForModel('gpt-4o')).toBe(4096)
})
it.concurrent('should return standard default for unknown models', () => {
expect(getMaxOutputTokensForModel('unknown-model')).toBe(4096)
})

View File

@@ -0,0 +1,99 @@
import type {
CloudWatchDescribeAlarmsParams,
CloudWatchDescribeAlarmsResponse,
} from '@/tools/cloudwatch/types'
import type { ToolConfig } from '@/tools/types'
export const describeAlarmsTool: ToolConfig<
CloudWatchDescribeAlarmsParams,
CloudWatchDescribeAlarmsResponse
> = {
id: 'cloudwatch_describe_alarms',
name: 'CloudWatch Describe Alarms',
description: 'List and filter CloudWatch alarms',
version: '1.0',
params: {
awsRegion: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AWS region (e.g., us-east-1)',
},
awsAccessKeyId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AWS access key ID',
},
awsSecretAccessKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AWS secret access key',
},
alarmNamePrefix: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter alarms by name prefix',
},
stateValue: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter by alarm state (OK, ALARM, INSUFFICIENT_DATA)',
},
alarmType: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter by alarm type (MetricAlarm, CompositeAlarm)',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of alarms to return',
},
},
request: {
url: '/api/tools/cloudwatch/describe-alarms',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => ({
region: params.awsRegion,
accessKeyId: params.awsAccessKeyId,
secretAccessKey: params.awsSecretAccessKey,
...(params.alarmNamePrefix && { alarmNamePrefix: params.alarmNamePrefix }),
...(params.stateValue && { stateValue: params.stateValue }),
...(params.alarmType && { alarmType: params.alarmType }),
...(params.limit !== undefined && { limit: params.limit }),
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to describe CloudWatch alarms')
}
return {
success: true,
output: {
alarms: data.output.alarms,
},
}
},
outputs: {
alarms: {
type: 'array',
description: 'List of CloudWatch alarms with state and configuration',
},
},
}

View File

@@ -0,0 +1,82 @@
import type {
CloudWatchDescribeLogGroupsParams,
CloudWatchDescribeLogGroupsResponse,
} from '@/tools/cloudwatch/types'
import type { ToolConfig } from '@/tools/types'
export const describeLogGroupsTool: ToolConfig<
CloudWatchDescribeLogGroupsParams,
CloudWatchDescribeLogGroupsResponse
> = {
id: 'cloudwatch_describe_log_groups',
name: 'CloudWatch Describe Log Groups',
description: 'List available CloudWatch log groups',
version: '1.0',
params: {
awsRegion: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AWS region (e.g., us-east-1)',
},
awsAccessKeyId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AWS access key ID',
},
awsSecretAccessKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AWS secret access key',
},
prefix: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter log groups by name prefix',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of log groups to return',
},
},
request: {
url: '/api/tools/cloudwatch/describe-log-groups',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => ({
region: params.awsRegion,
accessKeyId: params.awsAccessKeyId,
secretAccessKey: params.awsSecretAccessKey,
...(params.prefix && { prefix: params.prefix }),
...(params.limit !== undefined && { limit: params.limit }),
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to describe CloudWatch log groups')
}
return {
success: true,
output: {
logGroups: data.output.logGroups,
},
}
},
outputs: {
logGroups: { type: 'array', description: 'List of CloudWatch log groups with metadata' },
},
}

View File

@@ -0,0 +1,92 @@
import type {
CloudWatchDescribeLogStreamsParams,
CloudWatchDescribeLogStreamsResponse,
} from '@/tools/cloudwatch/types'
import type { ToolConfig } from '@/tools/types'
export const describeLogStreamsTool: ToolConfig<
CloudWatchDescribeLogStreamsParams,
CloudWatchDescribeLogStreamsResponse
> = {
id: 'cloudwatch_describe_log_streams',
name: 'CloudWatch Describe Log Streams',
description: 'List log streams within a CloudWatch log group',
version: '1.0',
params: {
awsRegion: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AWS region (e.g., us-east-1)',
},
awsAccessKeyId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AWS access key ID',
},
awsSecretAccessKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AWS secret access key',
},
logGroupName: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'CloudWatch log group name',
},
prefix: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter log streams by name prefix',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of log streams to return',
},
},
request: {
url: '/api/tools/cloudwatch/describe-log-streams',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => ({
region: params.awsRegion,
accessKeyId: params.awsAccessKeyId,
secretAccessKey: params.awsSecretAccessKey,
logGroupName: params.logGroupName,
...(params.prefix && { prefix: params.prefix }),
...(params.limit !== undefined && { limit: params.limit }),
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to describe CloudWatch log streams')
}
return {
success: true,
output: {
logStreams: data.output.logStreams,
},
}
},
outputs: {
logStreams: {
type: 'array',
description: 'List of log streams with metadata',
},
},
}

View File

@@ -0,0 +1,106 @@
import type {
CloudWatchGetLogEventsParams,
CloudWatchGetLogEventsResponse,
} from '@/tools/cloudwatch/types'
import type { ToolConfig } from '@/tools/types'
export const getLogEventsTool: ToolConfig<
CloudWatchGetLogEventsParams,
CloudWatchGetLogEventsResponse
> = {
id: 'cloudwatch_get_log_events',
name: 'CloudWatch Get Log Events',
description: 'Retrieve log events from a specific CloudWatch log stream',
version: '1.0',
params: {
awsRegion: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AWS region (e.g., us-east-1)',
},
awsAccessKeyId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AWS access key ID',
},
awsSecretAccessKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AWS secret access key',
},
logGroupName: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'CloudWatch log group name',
},
logStreamName: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'CloudWatch log stream name',
},
startTime: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Start time as Unix epoch seconds',
},
endTime: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'End time as Unix epoch seconds',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of events to return',
},
},
request: {
url: '/api/tools/cloudwatch/get-log-events',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => ({
region: params.awsRegion,
accessKeyId: params.awsAccessKeyId,
secretAccessKey: params.awsSecretAccessKey,
logGroupName: params.logGroupName,
logStreamName: params.logStreamName,
...(params.startTime !== undefined && { startTime: params.startTime }),
...(params.endTime !== undefined && { endTime: params.endTime }),
...(params.limit !== undefined && { limit: params.limit }),
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to get CloudWatch log events')
}
return {
success: true,
output: {
events: data.output.events,
},
}
},
outputs: {
events: {
type: 'array',
description: 'Log events with timestamp, message, and ingestion time',
},
},
}

View File

@@ -0,0 +1,119 @@
import type {
CloudWatchGetMetricStatisticsParams,
CloudWatchGetMetricStatisticsResponse,
} from '@/tools/cloudwatch/types'
import type { ToolConfig } from '@/tools/types'
export const getMetricStatisticsTool: ToolConfig<
CloudWatchGetMetricStatisticsParams,
CloudWatchGetMetricStatisticsResponse
> = {
id: 'cloudwatch_get_metric_statistics',
name: 'CloudWatch Get Metric Statistics',
description: 'Get statistics for a CloudWatch metric over a time range',
version: '1.0',
params: {
awsRegion: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AWS region (e.g., us-east-1)',
},
awsAccessKeyId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AWS access key ID',
},
awsSecretAccessKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AWS secret access key',
},
namespace: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Metric namespace (e.g., AWS/EC2, AWS/Lambda)',
},
metricName: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Metric name (e.g., CPUUtilization, Invocations)',
},
startTime: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'Start time as Unix epoch seconds',
},
endTime: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'End time as Unix epoch seconds',
},
period: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'Granularity in seconds (e.g., 60, 300, 3600)',
},
statistics: {
type: 'array',
required: true,
visibility: 'user-or-llm',
description: 'Statistics to retrieve (Average, Sum, Minimum, Maximum, SampleCount)',
},
dimensions: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Dimensions as JSON (e.g., {"InstanceId": "i-1234"})',
},
},
request: {
url: '/api/tools/cloudwatch/get-metric-statistics',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => ({
region: params.awsRegion,
accessKeyId: params.awsAccessKeyId,
secretAccessKey: params.awsSecretAccessKey,
namespace: params.namespace,
metricName: params.metricName,
startTime: params.startTime,
endTime: params.endTime,
period: params.period,
statistics: params.statistics,
...(params.dimensions && { dimensions: params.dimensions }),
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to get CloudWatch metric statistics')
}
return {
success: true,
output: {
label: data.output.label,
datapoints: data.output.datapoints,
},
}
},
outputs: {
label: { type: 'string', description: 'Metric label' },
datapoints: { type: 'array', description: 'Datapoints with timestamp and statistics values' },
},
}

View File

@@ -0,0 +1,15 @@
import { describeAlarmsTool } from '@/tools/cloudwatch/describe_alarms'
import { describeLogGroupsTool } from '@/tools/cloudwatch/describe_log_groups'
import { describeLogStreamsTool } from '@/tools/cloudwatch/describe_log_streams'
import { getLogEventsTool } from '@/tools/cloudwatch/get_log_events'
import { getMetricStatisticsTool } from '@/tools/cloudwatch/get_metric_statistics'
import { listMetricsTool } from '@/tools/cloudwatch/list_metrics'
import { queryLogsTool } from '@/tools/cloudwatch/query_logs'
export const cloudwatchDescribeAlarmsTool = describeAlarmsTool
export const cloudwatchDescribeLogGroupsTool = describeLogGroupsTool
export const cloudwatchDescribeLogStreamsTool = describeLogStreamsTool
export const cloudwatchGetLogEventsTool = getLogEventsTool
export const cloudwatchGetMetricStatisticsTool = getMetricStatisticsTool
export const cloudwatchListMetricsTool = listMetricsTool
export const cloudwatchQueryLogsTool = queryLogsTool

View File

@@ -0,0 +1,96 @@
import type {
CloudWatchListMetricsParams,
CloudWatchListMetricsResponse,
} from '@/tools/cloudwatch/types'
import type { ToolConfig } from '@/tools/types'
export const listMetricsTool: ToolConfig<
CloudWatchListMetricsParams,
CloudWatchListMetricsResponse
> = {
id: 'cloudwatch_list_metrics',
name: 'CloudWatch List Metrics',
description: 'List available CloudWatch metrics',
version: '1.0',
params: {
awsRegion: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AWS region (e.g., us-east-1)',
},
awsAccessKeyId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AWS access key ID',
},
awsSecretAccessKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AWS secret access key',
},
namespace: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter by namespace (e.g., AWS/EC2, AWS/Lambda)',
},
metricName: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter by metric name',
},
recentlyActive: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Only show metrics active in the last 3 hours',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of metrics to return',
},
},
request: {
url: '/api/tools/cloudwatch/list-metrics',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => ({
region: params.awsRegion,
accessKeyId: params.awsAccessKeyId,
secretAccessKey: params.awsSecretAccessKey,
...(params.namespace && { namespace: params.namespace }),
...(params.metricName && { metricName: params.metricName }),
...(params.recentlyActive && { recentlyActive: params.recentlyActive }),
...(params.limit !== undefined && { limit: params.limit }),
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to list CloudWatch metrics')
}
return {
success: true,
output: {
metrics: data.output.metrics,
},
}
},
outputs: {
metrics: { type: 'array', description: 'List of metrics with namespace, name, and dimensions' },
},
}

View File

@@ -0,0 +1,107 @@
import type {
CloudWatchQueryLogsParams,
CloudWatchQueryLogsResponse,
} from '@/tools/cloudwatch/types'
import type { ToolConfig } from '@/tools/types'
export const queryLogsTool: ToolConfig<CloudWatchQueryLogsParams, CloudWatchQueryLogsResponse> = {
id: 'cloudwatch_query_logs',
name: 'CloudWatch Query Logs',
description: 'Run a CloudWatch Log Insights query against one or more log groups',
version: '1.0',
params: {
awsRegion: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AWS region (e.g., us-east-1)',
},
awsAccessKeyId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AWS access key ID',
},
awsSecretAccessKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AWS secret access key',
},
logGroupNames: {
type: 'array',
required: true,
visibility: 'user-or-llm',
description: 'Log group names to query',
},
queryString: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'CloudWatch Log Insights query string',
},
startTime: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'Start time as Unix epoch seconds',
},
endTime: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'End time as Unix epoch seconds',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of results to return',
},
},
request: {
url: '/api/tools/cloudwatch/query-logs',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => ({
region: params.awsRegion,
accessKeyId: params.awsAccessKeyId,
secretAccessKey: params.awsSecretAccessKey,
logGroupNames: params.logGroupNames,
queryString: params.queryString,
startTime: params.startTime,
endTime: params.endTime,
...(params.limit !== undefined && { limit: params.limit }),
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'CloudWatch Log Insights query failed')
}
return {
success: true,
output: {
results: data.output.results,
statistics: data.output.statistics,
status: data.output.status,
},
}
},
outputs: {
results: { type: 'array', description: 'Query result rows' },
statistics: {
type: 'object',
description: 'Query statistics (bytesScanned, recordsMatched, recordsScanned)',
},
status: { type: 'string', description: 'Query completion status' },
},
}

View File

@@ -0,0 +1,146 @@
import type { ToolResponse } from '@/tools/types'
export interface CloudWatchConnectionConfig {
awsRegion: string
awsAccessKeyId: string
awsSecretAccessKey: string
}
export interface CloudWatchQueryLogsParams extends CloudWatchConnectionConfig {
logGroupNames: string[]
queryString: string
startTime: number
endTime: number
limit?: number
}
export interface CloudWatchDescribeLogGroupsParams extends CloudWatchConnectionConfig {
prefix?: string
limit?: number
}
export interface CloudWatchGetLogEventsParams extends CloudWatchConnectionConfig {
logGroupName: string
logStreamName: string
startTime?: number
endTime?: number
limit?: number
}
export interface CloudWatchQueryLogsResponse extends ToolResponse {
output: {
results: Record<string, string>[]
statistics: {
bytesScanned: number
recordsMatched: number
recordsScanned: number
}
status: string
}
}
export interface CloudWatchDescribeLogGroupsResponse extends ToolResponse {
output: {
logGroups: {
logGroupName: string
arn: string
storedBytes: number
retentionInDays: number | undefined
creationTime: number | undefined
}[]
}
}
export interface CloudWatchGetLogEventsResponse extends ToolResponse {
output: {
events: {
timestamp: number | undefined
message: string | undefined
ingestionTime: number | undefined
}[]
}
}
export interface CloudWatchDescribeLogStreamsParams extends CloudWatchConnectionConfig {
logGroupName: string
prefix?: string
limit?: number
}
export interface CloudWatchDescribeLogStreamsResponse extends ToolResponse {
output: {
logStreams: {
logStreamName: string
lastEventTimestamp: number | undefined
firstEventTimestamp: number | undefined
creationTime: number | undefined
storedBytes: number
}[]
}
}
export interface CloudWatchListMetricsParams extends CloudWatchConnectionConfig {
namespace?: string
metricName?: string
recentlyActive?: boolean
limit?: number
}
export interface CloudWatchListMetricsResponse extends ToolResponse {
output: {
metrics: {
namespace: string
metricName: string
dimensions: { name: string; value: string }[]
}[]
}
}
export interface CloudWatchGetMetricStatisticsParams extends CloudWatchConnectionConfig {
namespace: string
metricName: string
startTime: number
endTime: number
period: number
statistics: string[]
dimensions?: string
}
export interface CloudWatchGetMetricStatisticsResponse extends ToolResponse {
output: {
label: string
datapoints: {
timestamp: number
average?: number
sum?: number
minimum?: number
maximum?: number
sampleCount?: number
unit?: string
}[]
}
}
export interface CloudWatchDescribeAlarmsParams extends CloudWatchConnectionConfig {
alarmNamePrefix?: string
stateValue?: string
alarmType?: string
limit?: number
}
export interface CloudWatchDescribeAlarmsResponse extends ToolResponse {
output: {
alarms: {
alarmName: string
alarmArn: string
stateValue: string
stateReason: string
metricName: string | undefined
namespace: string | undefined
comparisonOperator: string | undefined
threshold: number | undefined
evaluationPeriods: number | undefined
stateUpdatedTimestamp: number | undefined
}[]
}
}

View File

@@ -275,6 +275,15 @@ import {
cloudflareUpdateDnsRecordTool,
cloudflareUpdateZoneSettingTool,
} from '@/tools/cloudflare'
import {
cloudwatchDescribeAlarmsTool,
cloudwatchDescribeLogGroupsTool,
cloudwatchDescribeLogStreamsTool,
cloudwatchGetLogEventsTool,
cloudwatchGetMetricStatisticsTool,
cloudwatchListMetricsTool,
cloudwatchQueryLogsTool,
} from '@/tools/cloudwatch'
import {
confluenceAddLabelTool,
confluenceCreateBlogPostTool,
@@ -3376,6 +3385,13 @@ export const tools: Record<string, ToolConfig> = {
rds_delete: rdsDeleteTool,
rds_execute: rdsExecuteTool,
rds_introspect: rdsIntrospectTool,
cloudwatch_query_logs: cloudwatchQueryLogsTool,
cloudwatch_describe_log_groups: cloudwatchDescribeLogGroupsTool,
cloudwatch_describe_alarms: cloudwatchDescribeAlarmsTool,
cloudwatch_describe_log_streams: cloudwatchDescribeLogStreamsTool,
cloudwatch_get_log_events: cloudwatchGetLogEventsTool,
cloudwatch_list_metrics: cloudwatchListMetricsTool,
cloudwatch_get_metric_statistics: cloudwatchGetMetricStatisticsTool,
dynamodb_get: dynamodbGetTool,
dynamodb_put: dynamodbPutTool,
dynamodb_query: dynamodbQueryTool,

View File

@@ -57,6 +57,8 @@
"@a2a-js/sdk": "0.3.7",
"@anthropic-ai/sdk": "0.71.2",
"@aws-sdk/client-bedrock-runtime": "3.940.0",
"@aws-sdk/client-cloudwatch": "3.940.0",
"@aws-sdk/client-cloudwatch-logs": "3.940.0",
"@aws-sdk/client-dynamodb": "3.940.0",
"@aws-sdk/client-rds-data": "3.940.0",
"@aws-sdk/client-s3": "^3.779.0",
@@ -174,8 +176,8 @@
"papaparse": "5.5.3",
"pdf-lib": "1.17.1",
"postgres": "^3.4.5",
"posthog-js": "1.334.1",
"posthog-node": "5.9.2",
"posthog-js": "1.364.4",
"posthog-node": "5.28.9",
"pptxgenjs": "4.0.1",
"pptxviewjs": "1.1.8",
"prismjs": "^1.30.0",
@@ -414,6 +416,10 @@
"@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.940.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.940.0", "@aws-sdk/credential-provider-node": "3.940.0", "@aws-sdk/eventstream-handler-node": "3.936.0", "@aws-sdk/middleware-eventstream": "3.936.0", "@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-user-agent": "3.940.0", "@aws-sdk/middleware-websocket": "3.936.0", "@aws-sdk/region-config-resolver": "3.936.0", "@aws-sdk/token-providers": "3.940.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@aws-sdk/util-user-agent-browser": "3.936.0", "@aws-sdk/util-user-agent-node": "3.940.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.5", "@smithy/eventstream-serde-browser": "^4.2.5", "@smithy/eventstream-serde-config-resolver": "^4.3.5", "@smithy/eventstream-serde-node": "^4.2.5", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.12", "@smithy/middleware-retry": "^4.4.12", "@smithy/middleware-serde": "^4.2.6", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.11", "@smithy/util-defaults-mode-node": "^4.2.14", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-stream": "^4.5.6", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-Gs6UUQP1zt8vahOxJ3BADcb3B+2KldUNA3bKa+KdK58de7N7tLJFJfZuXhFGGtwyNPh1aw6phtdP6dauq3OLWA=="],
"@aws-sdk/client-cloudwatch": ["@aws-sdk/client-cloudwatch@3.940.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.940.0", "@aws-sdk/credential-provider-node": "3.940.0", "@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-user-agent": "3.940.0", "@aws-sdk/region-config-resolver": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@aws-sdk/util-user-agent-browser": "3.936.0", "@aws-sdk/util-user-agent-node": "3.940.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.5", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-compression": "^4.3.12", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.12", "@smithy/middleware-retry": "^4.4.12", "@smithy/middleware-serde": "^4.2.6", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.11", "@smithy/util-defaults-mode-node": "^4.2.14", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "@smithy/util-waiter": "^4.2.5", "tslib": "^2.6.2" } }, "sha512-C35xpPntRAGdEg3X5iKpSUCBaP3yxYNo1U95qipN/X1e0/TYIDWHwGt8Z1ntRafK19jp5oVzhRQ+PD1JAPSEzA=="],
"@aws-sdk/client-cloudwatch-logs": ["@aws-sdk/client-cloudwatch-logs@3.940.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.940.0", "@aws-sdk/credential-provider-node": "3.940.0", "@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-user-agent": "3.940.0", "@aws-sdk/region-config-resolver": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@aws-sdk/util-user-agent-browser": "3.936.0", "@aws-sdk/util-user-agent-node": "3.940.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.5", "@smithy/eventstream-serde-browser": "^4.2.5", "@smithy/eventstream-serde-config-resolver": "^4.3.5", "@smithy/eventstream-serde-node": "^4.2.5", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.12", "@smithy/middleware-retry": "^4.4.12", "@smithy/middleware-serde": "^4.2.6", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.11", "@smithy/util-defaults-mode-node": "^4.2.14", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-7dEIO3D98IxA9IhqixPJbzQsBkk4TchHHpFdd0JOhlSlihWhiwbf3ijUePJVXYJxcpRRtMmAMtDRLDzCSO+ZHg=="],
"@aws-sdk/client-dynamodb": ["@aws-sdk/client-dynamodb@3.940.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.940.0", "@aws-sdk/credential-provider-node": "3.940.0", "@aws-sdk/middleware-endpoint-discovery": "3.936.0", "@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-user-agent": "3.940.0", "@aws-sdk/region-config-resolver": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@aws-sdk/util-user-agent-browser": "3.936.0", "@aws-sdk/util-user-agent-node": "3.940.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.5", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.12", "@smithy/middleware-retry": "^4.4.12", "@smithy/middleware-serde": "^4.2.6", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.11", "@smithy/util-defaults-mode-node": "^4.2.14", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "@smithy/util-waiter": "^4.2.5", "tslib": "^2.6.2" } }, "sha512-u2sXsNJazJbuHeWICvsj6RvNyJh3isedEfPvB21jK/kxcriK+dE/izlKC2cyxUjERCmku0zTFNzY9FhrLbYHjQ=="],
"@aws-sdk/client-rds-data": ["@aws-sdk/client-rds-data@3.940.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.940.0", "@aws-sdk/credential-provider-node": "3.940.0", "@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-user-agent": "3.940.0", "@aws-sdk/region-config-resolver": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@aws-sdk/util-user-agent-browser": "3.936.0", "@aws-sdk/util-user-agent-node": "3.940.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.5", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.12", "@smithy/middleware-retry": "^4.4.12", "@smithy/middleware-serde": "^4.2.6", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.11", "@smithy/util-defaults-mode-node": "^4.2.14", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-68NH61MvS48CVPfzBNCPdCG4KnNjM+Uj/3DSw7rT9PJvdML9ARS4M2Uqco9POPw+Aj20KBumsEUd6FMVcYBXAA=="],
@@ -1018,9 +1024,9 @@
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@posthog/core": ["@posthog/core@1.13.0", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-knjncrk7qRmssFRbGzBl1Tunt21GRpe0Wv+uVelyL0Rh7PdQUsgguulzXFTps8hA6wPwTU4kq85qnbAJ3eH6Wg=="],
"@posthog/core": ["@posthog/core@1.24.4", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-S+TolwBHSSJz7WWtgaELQWQqXviSm3uf1e+qorWUts0bZcgPwWzhnmhCUZAhvn0NVpTQHDJ3epv+hHbPLl5dHg=="],
"@posthog/types": ["@posthog/types@1.334.1", "", {}, "sha512-ypFnwTO7qbV7icylLbujbamPdQXbJq0a61GUUBnJAeTbBw/qYPIss5IRYICcbCj0uunQrwD7/CGxVb5TOYKWgA=="],
"@posthog/types": ["@posthog/types@1.364.4", "", {}, "sha512-U7NpIy9XWrzz1q/66xyDu8Wm12a7avNRKRn5ISPT5kuCJQRaeAaHuf+dpgrFnuqjCCgxg+oIY/ReJdlZ+8/z4Q=="],
"@prisma/config": ["@prisma/config@6.19.2", "", { "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", "effect": "3.18.4", "empathic": "2.0.0" } }, "sha512-kadBGDl+aUswv/zZMk9Mx0C8UZs1kjao8H9/JpI4Wh4SHZaM7zkTwiKn/iFLfRg+XtOAo/Z/c6pAYhijKl0nzQ=="],
@@ -1348,6 +1354,8 @@
"@smithy/md5-js": ["@smithy/md5-js@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-W/oIpHCpWU2+iAkfZYyGWE+qkpuf3vEXHLxQQDx9FPNZTTdnul0dZ2d/gUFrtQ5je1G2kp4cjG0/24YueG2LbQ=="],
"@smithy/middleware-compression": ["@smithy/middleware-compression@4.3.42", "", { "dependencies": { "@smithy/core": "^3.23.13", "@smithy/is-array-buffer": "^4.2.2", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "fflate": "0.8.1", "tslib": "^2.6.2" } }, "sha512-Ys2R8N7oZ3b6p063lhk7paRbX1F9Ju8a8Bsrw2nFfsG8iHYpgfW6ijd7hJKqRe+Wq9ABfcmX3luBlEd+B5/jVA=="],
"@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.12", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA=="],
"@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.27", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/middleware-serde": "^4.2.15", "@smithy/node-config-provider": "^4.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-T3TFfUgXQlpcg+UdzcAISdZpj4Z+XECZ/cefgA6wLBd6V4lRi0svN2hBouN/be9dXQ31X4sLWz3fAQDf+nt6BA=="],
@@ -3146,9 +3154,9 @@
"postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
"posthog-js": ["posthog-js@1.334.1", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.208.0", "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", "@opentelemetry/resources": "^2.2.0", "@opentelemetry/sdk-logs": "^0.208.0", "@posthog/core": "1.13.0", "@posthog/types": "1.334.1", "core-js": "^3.38.1", "dompurify": "^3.3.1", "fflate": "^0.4.8", "preact": "^10.28.0", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^5.1.0" } }, "sha512-5cDzLICr2afnwX/cR9fwoLC0vN0Nb5gP5HiCigzHkgHdO+E3WsYefla3EFMQz7U4r01CBPZ+nZ9/srkzeACxtQ=="],
"posthog-js": ["posthog-js@1.364.4", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.208.0", "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", "@opentelemetry/resources": "^2.2.0", "@opentelemetry/sdk-logs": "^0.208.0", "@posthog/core": "1.24.4", "@posthog/types": "1.364.4", "core-js": "^3.38.1", "dompurify": "^3.3.2", "fflate": "^0.4.8", "preact": "^10.28.2", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^5.1.0" } }, "sha512-T71zr06gH5YcrjS7c+sdzqfZKMxqqXC/a0w++zMQIPbL1ejvF9PdfUi0Kyd6Sy78Ocbb2smobdzBh8vXLwC+lQ=="],
"posthog-node": ["posthog-node@5.9.2", "", { "dependencies": { "@posthog/core": "1.2.2" } }, "sha512-oU7FbFcH5cn40nhP04cBeT67zE76EiGWjKKzDvm6IOm5P83sqM0Ij0wMJQSHp+QI6ZN7MLzb+4xfMPUEZ4q6CA=="],
"posthog-node": ["posthog-node@5.28.9", "", { "dependencies": { "@posthog/core": "1.24.4" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-iZWyAYkIAq5QqcYz4q2nXOX+Ivn04Yh8AuKqfFVw0SvBpfli49bNAjyE97qbRTLr+irrzRUELgGIkDC14NgugA=="],
"pptxgenjs": ["pptxgenjs@4.0.1", "", { "dependencies": { "@types/node": "^22.8.1", "https": "^1.0.0", "image-size": "^1.2.1", "jszip": "^3.10.1" } }, "sha512-TeJISr8wouAuXw4C1F/mC33xbZs/FuEG6nH9FG1Zj+nuPcGMP5YRHl6X+j3HSUnS1f3at6k75ZZXPMZlA5Lj9A=="],
@@ -4122,6 +4130,10 @@
"@shuding/opentype.js/fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="],
"@smithy/middleware-compression/@smithy/core": ["@smithy/core@3.23.13", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.21", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-J+2TT9D6oGsUVXVEMvz8h2EmdVnkBiy2auCie4aSJMvKlzUtO5hqjEzXhoCUkIMo7gAYjbQcN0g/MMSXEhDs1Q=="],
"@smithy/middleware-compression/fflate": ["fflate@0.8.1", "", {}, "sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ=="],
"@socket.io/redis-adapter/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
"@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
@@ -4408,8 +4420,6 @@
"posthog-js/fflate": ["fflate@0.4.8", "", {}, "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA=="],
"posthog-node/@posthog/core": ["@posthog/core@1.2.2", "", {}, "sha512-f16Ozx6LIigRG+HsJdt+7kgSxZTHeX5f1JlCGKI1lXcvlZgfsCR338FuMI2QRYXGl+jg/vYFzGOTQBxl90lnBg=="],
"pptxgenjs/image-size": ["image-size@1.2.1", "", { "dependencies": { "queue": "6.0.2" }, "bin": { "image-size": "bin/image-size.js" } }, "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw=="],
"protobufjs/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="],
@@ -4662,6 +4672,8 @@
"@shikijs/rehype/shiki/@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="],
"@smithy/middleware-compression/@smithy/core/@smithy/util-stream": ["@smithy/util-stream@4.5.21", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.1", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-KzSg+7KKywLnkoKejRtIBXDmwBfjGvg1U1i/etkC7XSWUyFCoLno1IohV2c74IzQqdhX5y3uE44r/8/wuK+A7Q=="],
"@trigger.dev/core/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.203.0", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/otlp-transformer": "0.203.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Wbxf7k+87KyvxFr5D7uOiSq/vHXWommvdnNE7vECO3tAhsA2GfOlpWINCMWUEPdHZ7tCXxw6Epp3vgx3jU7llQ=="],
@@ -5130,6 +5142,8 @@
"@cerebras/cerebras_cloud_sdk/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"@smithy/middleware-compression/@smithy/core/@smithy/util-stream/@smithy/node-http-handler": ["@smithy/node-http-handler@4.5.1", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-ejjxdAXjkPIs9lyYyVutOGNOraqUE9v/NjGMKwwFrfOM354wfSD8lmlj8hVwUzQmlLLF4+udhfCX9Exnbmvfzw=="],
"@trigger.dev/core/socket.io-client/engine.io-client/ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="],
"@trigger.dev/core/socket.io-client/engine.io-client/xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.0.0", "", {}, "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A=="],

View File

@@ -1,2 +1,2 @@
# Database URL (Required for migrations and database operations)
DATABASE_URL="postgresql://postgres:password@localhost:5432/simstudio"
DATABASE_URL="postgresql://postgres:your_password@localhost:5432/simstudio"

View File

@@ -20,7 +20,7 @@
},
"scripts": {
"db:push": "bunx drizzle-kit push --config=./drizzle.config.ts",
"db:migrate": "bun --env-file=.env --bun x drizzle-kit migrate --config=./drizzle.config.ts",
"db:migrate": "bun --env-file=.env run ./scripts/migrate.ts",
"db:studio": "bunx drizzle-kit studio --config=./drizzle.config.ts",
"type-check": "tsc --noEmit",
"lint": "biome check --write --unsafe .",

View File

@@ -0,0 +1,23 @@
import { drizzle } from 'drizzle-orm/postgres-js'
import { migrate } from 'drizzle-orm/postgres-js/migrator'
import postgres from 'postgres'
const url = process.env.DATABASE_URL
if (!url) {
console.error('ERROR: Missing DATABASE_URL environment variable.')
console.error('Ensure packages/db/.env is configured.')
process.exit(1)
}
const client = postgres(url, { max: 1, connect_timeout: 10 })
try {
await migrate(drizzle(client), { migrationsFolder: './migrations' })
console.log('Migrations applied successfully.')
} catch (error) {
console.error('ERROR: Migration failed.')
console.error(error instanceof Error ? error.message : error)
process.exit(1)
} finally {
await client.end()
}