Files
simstudioai--sim/apps/sim/lib/workspace-events/rules.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

180 lines
5.6 KiB
TypeScript

import { db } from '@sim/db'
import { workflowExecutionLogs } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, avg, count, desc, eq, gte, ne, type SQL, sql } from 'drizzle-orm'
import { creditsToDollars } from '@/lib/billing/credits/conversion'
import {
SIM_MIN_EXECUTIONS_FOR_RATE_RULES,
SIM_TRIGGER_PROVIDER,
type SimRuleEventType,
} from '@/lib/workspace-events/constants'
import type { ExecutionEventContext, SimSubscriptionConfig } from '@/lib/workspace-events/types'
const logger = createLogger('WorkspaceEventRules')
/**
* Excludes executions started by the Sim trigger from rule statistics, so
* side-effect runs never pollute failure/latency counts for workflows that
* are both source and subscriber.
*/
export function excludeSimExecutionsCondition(): SQL {
return ne(workflowExecutionLogs.trigger, SIM_TRIGGER_PROVIDER)
}
async function checkConsecutiveFailures(workflowId: string, threshold: number): Promise<boolean> {
const recentLogs = await db
.select({ level: workflowExecutionLogs.level })
.from(workflowExecutionLogs)
.where(and(eq(workflowExecutionLogs.workflowId, workflowId), excludeSimExecutionsCondition()))
.orderBy(desc(workflowExecutionLogs.startedAt))
.limit(threshold)
if (recentLogs.length < threshold) return false
return recentLogs.every((log) => log.level === 'error')
}
/**
* Fires when the in-window failure rate meets the threshold with at least
* SIM_MIN_EXECUTIONS_FOR_RATE_RULES executions.
*
* Intentionally diverges from the legacy notification rule, which required
* the oldest in-window log to predate the window start — a condition that is
* false for every in-window log, making the legacy rule dead code.
*/
async function checkFailureRate(
workflowId: string,
ratePercent: number,
windowHours: number
): Promise<boolean> {
const windowStart = new Date(Date.now() - windowHours * 60 * 60 * 1000)
// Single DB-side aggregate: the window is user-configured and this runs on
// the execution-completion path, so never materialize the in-window rows.
const result = await db
.select({
total: count(),
errors: count(sql`case when ${workflowExecutionLogs.level} = 'error' then 1 end`),
})
.from(workflowExecutionLogs)
.where(
and(
eq(workflowExecutionLogs.workflowId, workflowId),
gte(workflowExecutionLogs.startedAt, windowStart),
excludeSimExecutionsCondition()
)
)
const total = result[0]?.total ?? 0
if (total < SIM_MIN_EXECUTIONS_FOR_RATE_RULES) return false
const errorCount = result[0]?.errors ?? 0
const failureRate = (errorCount / total) * 100
return failureRate >= ratePercent
}
async function checkLatencySpike(
workflowId: string,
currentDurationMs: number,
spikePercent: number,
windowHours: number
): Promise<boolean> {
const windowStart = new Date(Date.now() - windowHours * 60 * 60 * 1000)
const result = await db
.select({
avgDuration: avg(workflowExecutionLogs.totalDurationMs),
count: count(),
})
.from(workflowExecutionLogs)
.where(
and(
eq(workflowExecutionLogs.workflowId, workflowId),
gte(workflowExecutionLogs.startedAt, windowStart),
excludeSimExecutionsCondition()
)
)
const avgDuration = result[0]?.avgDuration
const execCount = result[0]?.count || 0
if (!avgDuration || execCount < SIM_MIN_EXECUTIONS_FOR_RATE_RULES) return false
const avgMs = Number(avgDuration)
const threshold = avgMs * (1 + spikePercent / 100)
return currentDurationMs > threshold
}
async function checkErrorCount(
workflowId: string,
threshold: number,
windowHours: number
): Promise<boolean> {
const windowStart = new Date(Date.now() - windowHours * 60 * 60 * 1000)
const result = await db
.select({ count: count() })
.from(workflowExecutionLogs)
.where(
and(
eq(workflowExecutionLogs.workflowId, workflowId),
eq(workflowExecutionLogs.level, 'error'),
gte(workflowExecutionLogs.startedAt, windowStart),
excludeSimExecutionsCondition()
)
)
const errorCount = result[0]?.count || 0
return errorCount >= threshold
}
/**
* Evaluates a rule-based event type against a completed execution.
* `no_activity` always returns false here — it has no triggering execution
* and is owned by the inactivity poller.
*/
export async function evaluateRule(
eventType: SimRuleEventType,
config: SimSubscriptionConfig,
context: ExecutionEventContext
): Promise<boolean> {
switch (eventType) {
case 'consecutive_failures':
if (context.status !== 'error') return false
return checkConsecutiveFailures(context.workflowId, config.consecutiveFailures)
case 'failure_rate':
if (context.status !== 'error') return false
return checkFailureRate(context.workflowId, config.failureRatePercent, config.windowHours)
case 'latency_threshold':
return context.durationMs > config.durationThresholdMs
case 'latency_spike':
return checkLatencySpike(
context.workflowId,
context.durationMs,
config.latencySpikePercent,
config.windowHours
)
case 'cost_threshold':
// The threshold is credit-denominated (the UI unit); run costs are
// stored in dollars, so convert the threshold for the comparison.
return context.cost > creditsToDollars(config.costThresholdCredits)
case 'error_count':
if (context.status !== 'error') return false
return checkErrorCount(context.workflowId, config.errorCountThreshold, config.windowHours)
case 'no_activity':
return false
default:
logger.warn(`Unknown sim trigger rule: ${eventType}`)
return false
}
}