chore: import upstream snapshot with attribution
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+170
View File
@@ -0,0 +1,170 @@
/**
* Shared constants for the Sim workspace-event trigger.
*
* This module is imported from both client code (trigger/block definitions)
* and server code (the event emitter), so it must stay free of server-only
* dependencies such as the database client.
*/
/** Provider string recorded on webhook rows and execution logs for Sim trigger runs. */
export const SIM_TRIGGER_PROVIDER = 'sim'
/** Trigger ID in the trigger registry. Must equal the block type for pure trigger blocks. */
export const SIM_WORKSPACE_EVENT_TRIGGER_ID = 'sim_workspace_event'
/** Events that fire 1:1 with their source occurrence (no rule evaluation, no cooldown). */
export const SIM_PLAIN_EVENT_TYPES = [
'execution_success',
'execution_error',
'workflow_deployed',
'workflow_undeployed',
] as const
/** Rule-based events ported from the legacy notification alert rules. */
export const SIM_RULE_EVENT_TYPES = [
'consecutive_failures',
'failure_rate',
'latency_threshold',
'latency_spike',
'cost_threshold',
'error_count',
'no_activity',
] as const
export const SIM_EVENT_TYPES = [...SIM_PLAIN_EVENT_TYPES, ...SIM_RULE_EVENT_TYPES] as const
export type SimPlainEventType = (typeof SIM_PLAIN_EVENT_TYPES)[number]
export type SimRuleEventType = (typeof SIM_RULE_EVENT_TYPES)[number]
export type SimEventType = (typeof SIM_EVENT_TYPES)[number]
/**
* Plain events that ARE a run completing. These carry the run summary fields
* (runId, durationMs, cost, finalOutput) at the top level.
*/
const SIM_PLAIN_RUN_EVENT_TYPES = ['execution_success', 'execution_error'] as const
/**
* Rule events tripped by a run completing. The run is evidence for the
* condition rather than the event itself, so its summary nests under
* `triggeringRun`. no_activity is excluded — it has no triggering run.
*/
const SIM_RUN_BACKED_RULE_EVENT_TYPES = SIM_RULE_EVENT_TYPES.filter(
(eventType) => eventType !== 'no_activity'
)
export function isSimRuleEventType(eventType: string): eventType is SimRuleEventType {
return (SIM_RULE_EVENT_TYPES as readonly string[]).includes(eventType)
}
/** Cooldown between firings of the same rule-based subscription. */
export const SIM_RULE_COOLDOWN_HOURS = 1
/** Minimum executions in the window before rate-based rules can fire. */
export const SIM_MIN_EXECUTIONS_FOR_RATE_RULES = 5
/** Default values for rule configuration subblocks, ported from the legacy alert rules. */
export const SIM_RULE_DEFAULTS = {
consecutiveFailures: 3,
failureRatePercent: 50,
windowHours: 24,
durationThresholdMs: 30000,
latencySpikePercent: 100,
/** 200 credits = $1 (1 credit = $0.005). */
costThresholdCredits: 200,
errorCountThreshold: 10,
inactivityHours: 24,
} as const
/** Maximum serialized size of the finalOutput payload field. */
export const SIM_FINAL_OUTPUT_MAX_BYTES = 64 * 1024
interface SimEventPayloadFieldCondition {
field: 'eventType'
value: SimEventType | SimEventType[]
}
interface SimEventPayloadField {
type: 'string' | 'number' | 'json' | 'boolean'
description: string
/** Restricts which event types surface this field in the tag dropdown. */
condition?: SimEventPayloadFieldCondition
/** Nested fields for json outputs, surfaced as dotted paths in the tag dropdown. */
properties?: Record<string, { type: 'string' | 'number' | 'json'; description: string }>
}
/** Run summary fields shared by top-level plain events and the nested triggeringRun. */
const RUN_SUMMARY_FIELDS = {
runId: {
type: 'string',
description: 'The source run ID',
},
durationMs: {
type: 'number',
description: 'Source run duration in milliseconds',
},
cost: {
type: 'number',
description: 'Source run cost in credits',
},
finalOutput: {
type: 'json',
description: 'Final output of the source run (truncated when large)',
},
} as const
/**
* Canonical payload shape delivered to Sim trigger workflows.
*
* The trigger's declared outputs and the runtime payload builder both derive
* from this map so the tag dropdown and the actual payload can never drift
* (enforced by tests on both sides). Conditions narrow the tag dropdown to
* the fields that are meaningful for the selected event type; the runtime
* payload always carries every key (null where not applicable).
*/
export const SIM_EVENT_PAYLOAD_FIELDS = {
event: {
type: 'string',
description: 'The workspace event type that fired this trigger',
},
timestamp: {
type: 'string',
description: 'Event timestamp in ISO format',
},
workflowId: {
type: 'string',
description: 'The source workflow ID',
},
workflowName: {
type: 'string',
description: 'The source workflow name',
},
runId: {
...RUN_SUMMARY_FIELDS.runId,
condition: { field: 'eventType', value: [...SIM_PLAIN_RUN_EVENT_TYPES] },
},
durationMs: {
...RUN_SUMMARY_FIELDS.durationMs,
condition: { field: 'eventType', value: [...SIM_PLAIN_RUN_EVENT_TYPES] },
},
cost: {
...RUN_SUMMARY_FIELDS.cost,
condition: { field: 'eventType', value: [...SIM_PLAIN_RUN_EVENT_TYPES] },
},
finalOutput: {
...RUN_SUMMARY_FIELDS.finalOutput,
condition: { field: 'eventType', value: [...SIM_PLAIN_RUN_EVENT_TYPES] },
},
triggeringRun: {
type: 'json',
description: 'The run that tripped this condition',
condition: { field: 'eventType', value: [...SIM_RUN_BACKED_RULE_EVENT_TYPES] },
properties: RUN_SUMMARY_FIELDS,
},
version: {
type: 'number',
description: 'The deployment version number that was activated',
condition: { field: 'eventType', value: 'workflow_deployed' },
},
} as const satisfies Record<string, SimEventPayloadField>
export type SimEventPayloadFieldKey = keyof typeof SIM_EVENT_PAYLOAD_FIELDS
@@ -0,0 +1,386 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockGetActiveWorkflowContext,
mockFetchSubscriptions,
mockEvaluateRule,
mockReadLastFiredAt,
mockClaimCooldown,
mockProcessPolledWebhookEvent,
} = vi.hoisted(() => ({
mockGetActiveWorkflowContext: vi.fn(),
mockFetchSubscriptions: vi.fn(),
mockEvaluateRule: vi.fn(),
mockReadLastFiredAt: vi.fn(),
mockClaimCooldown: vi.fn(),
mockProcessPolledWebhookEvent: vi.fn(),
}))
vi.mock('@sim/platform-authz/workflow', () => ({
getActiveWorkflowContext: mockGetActiveWorkflowContext,
}))
vi.mock('@/lib/workspace-events/subscriptions', () => ({
fetchSimTriggerSubscriptions: mockFetchSubscriptions,
parseSubscriptionConfig: vi.fn((providerConfig: unknown) => providerConfig),
}))
vi.mock('@/lib/workspace-events/rules', () => ({
evaluateRule: mockEvaluateRule,
}))
vi.mock('@/lib/workspace-events/state', () => ({
readLastFiredAt: mockReadLastFiredAt,
claimCooldown: mockClaimCooldown,
isWithinCooldown: vi.fn(
(lastFiredAt: Date | null, cooldownMs: number) =>
lastFiredAt !== null && Date.now() - lastFiredAt.getTime() < cooldownMs
),
}))
vi.mock('@/lib/webhooks/processor', () => ({
processPolledWebhookEvent: mockProcessPolledWebhookEvent,
}))
import type { WorkflowExecutionLog } from '@/lib/logs/types'
import {
emitExecutionCompletedEvent,
emitWorkflowDeployedEvent,
} from '@/lib/workspace-events/emitter'
import type { SimSubscriptionConfig } from '@/lib/workspace-events/types'
function makeConfig(overrides: Partial<SimSubscriptionConfig> = {}): SimSubscriptionConfig {
return {
eventType: 'execution_error',
workflowIds: [],
consecutiveFailures: 3,
failureRatePercent: 50,
windowHours: 24,
durationThresholdMs: 30000,
latencySpikePercent: 100,
costThresholdCredits: 200,
errorCountThreshold: 10,
inactivityHours: 24,
...overrides,
}
}
function makeSubscription(
config: SimSubscriptionConfig,
overrides: { subscriberWorkflowId?: string; blockId?: string } = {}
) {
const subscriberWorkflowId = overrides.subscriberWorkflowId ?? 'wf-subscriber'
return {
webhook: {
id: `wh-${subscriberWorkflowId}`,
workflowId: subscriberWorkflowId,
blockId: overrides.blockId ?? 'block-1',
path: 'block-1',
provider: 'sim',
providerConfig: config,
isActive: true,
},
workflow: {
id: subscriberWorkflowId,
name: 'Subscriber Workflow',
},
}
}
function makeLog(overrides: Partial<WorkflowExecutionLog> = {}): WorkflowExecutionLog {
return {
id: 'log-1',
workflowId: 'wf-source',
executionId: 'exec-1',
stateSnapshotId: 'snap-1',
level: 'error',
trigger: 'manual',
startedAt: '2026-06-09T00:00:00.000Z',
endedAt: '2026-06-09T00:00:01.000Z',
totalDurationMs: 1000,
executionData: {
error: 'boom',
finalOutput: { result: 42 },
} as WorkflowExecutionLog['executionData'],
cost: { total: 0.25 } as WorkflowExecutionLog['cost'],
createdAt: '2026-06-09T00:00:01.000Z',
...overrides,
}
}
describe('emitExecutionCompletedEvent', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetActiveWorkflowContext.mockResolvedValue({
workflow: { id: 'wf-source', name: 'Source Workflow' },
workspaceId: 'ws-1',
})
mockFetchSubscriptions.mockResolvedValue([])
mockProcessPolledWebhookEvent.mockResolvedValue({ success: true, executionId: 'exec-2' })
mockReadLastFiredAt.mockResolvedValue(null)
mockClaimCooldown.mockResolvedValue(true)
mockEvaluateRule.mockResolvedValue(true)
})
it('never emits for executions started by the sim trigger (loop guard)', async () => {
await emitExecutionCompletedEvent(makeLog({ trigger: 'sim' }))
expect(mockGetActiveWorkflowContext).not.toHaveBeenCalled()
expect(mockFetchSubscriptions).not.toHaveBeenCalled()
expect(mockProcessPolledWebhookEvent).not.toHaveBeenCalled()
})
it('does nothing without a workflow id or workspace context', async () => {
await emitExecutionCompletedEvent(makeLog({ workflowId: null }))
expect(mockFetchSubscriptions).not.toHaveBeenCalled()
mockGetActiveWorkflowContext.mockResolvedValueOnce(null)
await emitExecutionCompletedEvent(makeLog())
expect(mockFetchSubscriptions).not.toHaveBeenCalled()
})
it('looks up subscriptions scoped to the source workspace', async () => {
await emitExecutionCompletedEvent(makeLog())
expect(mockFetchSubscriptions).toHaveBeenCalledWith('ws-1')
})
it('fires execution_error subscribers for error logs but not execution_success ones', async () => {
const errorSub = makeSubscription(makeConfig({ eventType: 'execution_error' }), {
subscriberWorkflowId: 'wf-error-sub',
})
const successSub = makeSubscription(makeConfig({ eventType: 'execution_success' }), {
subscriberWorkflowId: 'wf-success-sub',
})
mockFetchSubscriptions.mockResolvedValueOnce([errorSub, successSub])
await emitExecutionCompletedEvent(makeLog({ level: 'error' }))
expect(mockProcessPolledWebhookEvent).toHaveBeenCalledTimes(1)
expect(mockProcessPolledWebhookEvent).toHaveBeenCalledWith(
errorSub.webhook,
errorSub.workflow,
expect.objectContaining({
event: 'execution_error',
workflowId: 'wf-source',
workflowName: 'Source Workflow',
runId: 'exec-1',
durationMs: 1000,
// $0.25 reported as credits (1 credit = $0.005)
cost: 50,
}),
expect.any(String)
)
})
it('fires execution_success subscribers for info logs', async () => {
const successSub = makeSubscription(makeConfig({ eventType: 'execution_success' }))
mockFetchSubscriptions.mockResolvedValueOnce([successSub])
await emitExecutionCompletedEvent(makeLog({ level: 'info' }))
expect(mockProcessPolledWebhookEvent).toHaveBeenCalledTimes(1)
expect(mockProcessPolledWebhookEvent.mock.calls[0][2]).toMatchObject({
event: 'execution_success',
runId: 'exec-1',
})
})
it('respects the workflow scope filter, ignoring stale workflow ids', async () => {
const matching = makeSubscription(makeConfig({ workflowIds: ['wf-source', 'wf-deleted'] }), {
subscriberWorkflowId: 'wf-a',
})
const nonMatching = makeSubscription(makeConfig({ workflowIds: ['wf-other', 'wf-deleted'] }), {
subscriberWorkflowId: 'wf-b',
})
mockFetchSubscriptions.mockResolvedValueOnce([matching, nonMatching])
await emitExecutionCompletedEvent(makeLog())
expect(mockProcessPolledWebhookEvent).toHaveBeenCalledTimes(1)
expect(mockProcessPolledWebhookEvent.mock.calls[0][0]).toBe(matching.webhook)
})
it('an empty workflow selection watches every workflow', async () => {
const watchAll = makeSubscription(makeConfig({ workflowIds: [] }))
mockFetchSubscriptions.mockResolvedValueOnce([watchAll])
await emitExecutionCompletedEvent(makeLog())
expect(mockProcessPolledWebhookEvent).toHaveBeenCalledTimes(1)
})
it('never fires a subscription for its own workflow, even when watching all workflows', async () => {
const selfSub = makeSubscription(makeConfig({ workflowIds: [] }), {
subscriberWorkflowId: 'wf-source',
})
mockFetchSubscriptions.mockResolvedValueOnce([selfSub])
await emitExecutionCompletedEvent(makeLog())
expect(mockProcessPolledWebhookEvent).not.toHaveBeenCalled()
})
it('plain events bypass cooldown state entirely', async () => {
mockFetchSubscriptions.mockResolvedValueOnce([
makeSubscription(makeConfig({ eventType: 'execution_error' })),
])
await emitExecutionCompletedEvent(makeLog())
expect(mockProcessPolledWebhookEvent).toHaveBeenCalledTimes(1)
expect(mockReadLastFiredAt).not.toHaveBeenCalled()
expect(mockClaimCooldown).not.toHaveBeenCalled()
})
it('rule events evaluate the rule and claim the cooldown before dispatching', async () => {
const sub = makeSubscription(makeConfig({ eventType: 'cost_threshold' }))
mockFetchSubscriptions.mockResolvedValueOnce([sub])
await emitExecutionCompletedEvent(makeLog())
expect(mockEvaluateRule).toHaveBeenCalledTimes(1)
expect(mockClaimCooldown).toHaveBeenCalledWith(
'wf-subscriber',
'block-1',
'',
expect.any(Number)
)
expect(mockProcessPolledWebhookEvent).toHaveBeenCalledTimes(1)
expect(mockProcessPolledWebhookEvent.mock.calls[0][2]).toMatchObject({
event: 'cost_threshold',
runId: null,
triggeringRun: { runId: 'exec-1' },
})
})
it('skips no_activity subscriptions before any cooldown read or rule evaluation (poller-owned)', async () => {
const sub = makeSubscription(makeConfig({ eventType: 'no_activity' }))
mockFetchSubscriptions.mockResolvedValueOnce([sub])
await emitExecutionCompletedEvent(makeLog())
expect(mockReadLastFiredAt).not.toHaveBeenCalled()
expect(mockEvaluateRule).not.toHaveBeenCalled()
expect(mockClaimCooldown).not.toHaveBeenCalled()
expect(mockProcessPolledWebhookEvent).not.toHaveBeenCalled()
})
it('skips rule evaluation while within the cooldown window', async () => {
mockReadLastFiredAt.mockResolvedValueOnce(new Date())
mockFetchSubscriptions.mockResolvedValueOnce([
makeSubscription(makeConfig({ eventType: 'latency_threshold' })),
])
await emitExecutionCompletedEvent(makeLog())
expect(mockEvaluateRule).not.toHaveBeenCalled()
expect(mockProcessPolledWebhookEvent).not.toHaveBeenCalled()
})
it('does not dispatch when the rule does not fire', async () => {
mockEvaluateRule.mockResolvedValueOnce(false)
mockFetchSubscriptions.mockResolvedValueOnce([
makeSubscription(makeConfig({ eventType: 'consecutive_failures' })),
])
await emitExecutionCompletedEvent(makeLog())
expect(mockClaimCooldown).not.toHaveBeenCalled()
expect(mockProcessPolledWebhookEvent).not.toHaveBeenCalled()
})
it('does not dispatch when a concurrent emitter wins the cooldown claim', async () => {
mockClaimCooldown.mockResolvedValueOnce(false)
mockFetchSubscriptions.mockResolvedValueOnce([
makeSubscription(makeConfig({ eventType: 'error_count' })),
])
await emitExecutionCompletedEvent(makeLog())
expect(mockProcessPolledWebhookEvent).not.toHaveBeenCalled()
})
it('always includes the source execution finalOutput', async () => {
mockFetchSubscriptions.mockResolvedValueOnce([makeSubscription(makeConfig())])
await emitExecutionCompletedEvent(makeLog())
expect(mockProcessPolledWebhookEvent).toHaveBeenCalledTimes(1)
expect(mockProcessPolledWebhookEvent.mock.calls[0][2]).toMatchObject({
finalOutput: { result: 42 },
})
})
it('never throws when emission internals fail', async () => {
mockFetchSubscriptions.mockRejectedValueOnce(new Error('db down'))
await expect(emitExecutionCompletedEvent(makeLog())).resolves.toBeUndefined()
mockProcessPolledWebhookEvent.mockRejectedValueOnce(new Error('enqueue failed'))
mockFetchSubscriptions.mockResolvedValueOnce([makeSubscription(makeConfig())])
await expect(emitExecutionCompletedEvent(makeLog())).resolves.toBeUndefined()
})
})
describe('emitWorkflowDeployedEvent', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchSubscriptions.mockResolvedValue([])
mockProcessPolledWebhookEvent.mockResolvedValue({ success: true, executionId: 'exec-2' })
})
const deployParams = {
workflowId: 'wf-source',
workflowName: 'Source Workflow',
workspaceId: 'ws-1',
version: 4,
}
it('fires only workflow_deployed subscribers on deploys', async () => {
const deploySub = makeSubscription(makeConfig({ eventType: 'workflow_deployed' }))
const errorSub = makeSubscription(makeConfig({ eventType: 'execution_error' }), {
subscriberWorkflowId: 'wf-other',
})
mockFetchSubscriptions.mockResolvedValueOnce([deploySub, errorSub])
await emitWorkflowDeployedEvent(deployParams)
expect(mockProcessPolledWebhookEvent).toHaveBeenCalledTimes(1)
expect(mockProcessPolledWebhookEvent.mock.calls[0][2]).toMatchObject({
event: 'workflow_deployed',
workflowId: 'wf-source',
workflowName: 'Source Workflow',
runId: null,
version: 4,
})
})
it('does not fire a subscription when its own workflow is deployed', async () => {
const selfSub = makeSubscription(makeConfig({ eventType: 'workflow_deployed' }), {
subscriberWorkflowId: 'wf-source',
})
mockFetchSubscriptions.mockResolvedValueOnce([selfSub])
await emitWorkflowDeployedEvent(deployParams)
expect(mockProcessPolledWebhookEvent).not.toHaveBeenCalled()
})
it('respects the workflow scope filter', async () => {
const outOfScope = makeSubscription(
makeConfig({ eventType: 'workflow_deployed', workflowIds: ['wf-x'] })
)
mockFetchSubscriptions.mockResolvedValueOnce([outOfScope])
await emitWorkflowDeployedEvent(deployParams)
expect(mockProcessPolledWebhookEvent).not.toHaveBeenCalled()
})
it('never throws when emission internals fail', async () => {
mockFetchSubscriptions.mockRejectedValueOnce(new Error('db down'))
await expect(emitWorkflowDeployedEvent(deployParams)).resolves.toBeUndefined()
})
})
+241
View File
@@ -0,0 +1,241 @@
import { createLogger } from '@sim/logger'
import { getActiveWorkflowContext } from '@sim/platform-authz/workflow'
import { generateShortId } from '@sim/utils/id'
import type { WorkflowExecutionLog } from '@/lib/logs/types'
import {
isSimRuleEventType,
SIM_RULE_COOLDOWN_HOURS,
SIM_TRIGGER_PROVIDER,
} from '@/lib/workspace-events/constants'
import {
buildDeployEventPayload,
buildExecutionEventPayload,
buildUndeployEventPayload,
} from '@/lib/workspace-events/payload'
import { evaluateRule } from '@/lib/workspace-events/rules'
import { claimCooldown, isWithinCooldown, readLastFiredAt } from '@/lib/workspace-events/state'
import {
fetchSimTriggerSubscriptions,
parseSubscriptionConfig,
} from '@/lib/workspace-events/subscriptions'
import type {
ExecutionEventContext,
SimEventPayload,
SimSubscription,
SimSubscriptionConfig,
} from '@/lib/workspace-events/types'
const logger = createLogger('WorkspaceEventEmitter')
const SIM_RULE_COOLDOWN_MS = SIM_RULE_COOLDOWN_HOURS * 60 * 60 * 1000
/** Stable cooldown identity for a subscriber block, surviving redeploys. */
function subscriptionBlockKey(subscription: SimSubscription): string {
return subscription.webhook.blockId ?? subscription.webhook.path ?? ''
}
/**
* Enqueues one side-effect workflow execution for a matched subscription.
*
* Routes through the shared polled-webhook pipeline, which provides admission
* control, billing attribution, deployment checks, and queue-vs-inline
* routing. The processor stack (executor, blocks) is imported lazily so this
* module stays cheap for the execution logger to import.
*/
export async function dispatchSimEvent(
subscription: SimSubscription,
payload: SimEventPayload
): Promise<void> {
const requestId = generateShortId()
try {
const { processPolledWebhookEvent } = await import('@/lib/webhooks/processor')
const result = await processPolledWebhookEvent(
subscription.webhook,
subscription.workflow,
payload,
requestId
)
if (!result.success) {
logger.error(
`[${requestId}] Failed to fire sim trigger for workflow ${subscription.workflow.id}:`,
result.statusCode,
result.error
)
}
} catch (error) {
logger.error(
`[${requestId}] Error firing sim trigger for workflow ${subscription.workflow.id}:`,
error
)
}
}
/** Workflow-scope filter shared by all event kinds. Empty selection watches every workflow. */
function matchesWorkflowScope(config: SimSubscriptionConfig, sourceWorkflowId: string): boolean {
if (config.workflowIds.length === 0) return true
return config.workflowIds.includes(sourceWorkflowId)
}
/**
* Emits workspace events for a completed workflow execution.
*
* Fire-and-forget: errors are logged and never thrown, so event emission can
* never break the source execution. Executions started by the Sim trigger
* itself never emit (loop prevention).
*/
export async function emitExecutionCompletedEvent(log: WorkflowExecutionLog): Promise<void> {
try {
if (!log.workflowId) return
if (log.trigger === SIM_TRIGGER_PROVIDER) return
const workflowContext = await getActiveWorkflowContext(log.workflowId)
if (!workflowContext?.workspaceId) return
const subscriptions = await fetchSimTriggerSubscriptions(workflowContext.workspaceId)
if (subscriptions.length === 0) return
const executionData = (log.executionData ?? {}) as Record<string, unknown>
const context: ExecutionEventContext = {
workflowId: log.workflowId,
executionId: log.executionId,
status: log.level === 'error' ? 'error' : 'success',
durationMs: log.totalDurationMs || 0,
cost: (log.cost as { total?: number } | undefined)?.total || 0,
finalOutput: executionData.finalOutput,
}
for (const subscription of subscriptions) {
const config = parseSubscriptionConfig(subscription.webhook.providerConfig)
if (!config) continue
if (config.eventType === 'workflow_deployed') continue
// no_activity is owned by the inactivity poller and can never fire from
// a completed execution; skip before the rule branch costs a cooldown
// read on this hot path.
if (config.eventType === 'no_activity') continue
if (subscription.webhook.workflowId === log.workflowId) continue
if (!matchesWorkflowScope(config, log.workflowId)) continue
if (config.eventType === 'execution_success' && context.status !== 'success') continue
if (config.eventType === 'execution_error' && context.status !== 'error') continue
if (isSimRuleEventType(config.eventType)) {
const blockKey = subscriptionBlockKey(subscription)
const lastFiredAt = await readLastFiredAt(subscription.webhook.workflowId, blockKey, '')
if (isWithinCooldown(lastFiredAt, SIM_RULE_COOLDOWN_MS)) continue
const ruleFired = await evaluateRule(config.eventType, config, context)
if (!ruleFired) continue
const claimed = await claimCooldown(
subscription.webhook.workflowId,
blockKey,
'',
SIM_RULE_COOLDOWN_MS
)
if (!claimed) continue
logger.info(`Sim trigger rule ${config.eventType} fired`, {
subscriberWorkflowId: subscription.webhook.workflowId,
sourceWorkflowId: log.workflowId,
executionId: log.executionId,
})
}
const payload = buildExecutionEventPayload({
event: config.eventType as Parameters<typeof buildExecutionEventPayload>[0]['event'],
workflowName: workflowContext.workflow.name,
context,
})
await dispatchSimEvent(subscription, payload)
}
} catch (error) {
logger.error('Failed to emit workspace execution event', {
error,
workflowId: log.workflowId,
executionId: log.executionId,
})
}
}
/**
* Shared dispatch loop for workflow lifecycle events: matches subscriptions
* on event type and workflow scope, never notifying the source workflow about
* itself. Fire-and-forget: failures never affect the lifecycle operation.
*/
async function emitWorkflowLifecycleEvent(params: {
eventType: 'workflow_deployed' | 'workflow_undeployed'
workflowId: string
workspaceId: string
payload: SimEventPayload
}): Promise<void> {
try {
const subscriptions = await fetchSimTriggerSubscriptions(params.workspaceId)
if (subscriptions.length === 0) return
for (const subscription of subscriptions) {
const config = parseSubscriptionConfig(subscription.webhook.providerConfig)
if (!config) continue
if (config.eventType !== params.eventType) continue
if (subscription.webhook.workflowId === params.workflowId) continue
if (!matchesWorkflowScope(config, params.workflowId)) continue
await dispatchSimEvent(subscription, params.payload)
}
} catch (error) {
logger.error(`Failed to emit ${params.eventType} event`, {
error,
workflowId: params.workflowId,
})
}
}
/**
* Emits a workflow_deployed event to subscribed side-effect workflows.
*
* Fired on any deployment activation (fresh deploy, redeploy, version
* rollback/activation). Fire-and-forget: failures never affect the deploy.
*/
export async function emitWorkflowDeployedEvent(params: {
workflowId: string
workflowName: string
workspaceId: string
version: number | null
}): Promise<void> {
await emitWorkflowLifecycleEvent({
eventType: 'workflow_deployed',
workflowId: params.workflowId,
workspaceId: params.workspaceId,
payload: buildDeployEventPayload({
workflowId: params.workflowId,
workflowName: params.workflowName,
version: params.version,
}),
})
}
/**
* Emits a workflow_undeployed event to subscribed side-effect workflows.
*
* Fired when a workflow is taken offline. Fire-and-forget: failures never
* affect the undeploy.
*/
export async function emitWorkflowUndeployedEvent(params: {
workflowId: string
workflowName: string
workspaceId: string
}): Promise<void> {
await emitWorkflowLifecycleEvent({
eventType: 'workflow_undeployed',
workflowId: params.workflowId,
workspaceId: params.workspaceId,
payload: buildUndeployEventPayload({
workflowId: params.workflowId,
workflowName: params.workflowName,
}),
})
}
@@ -0,0 +1,286 @@
/**
* @vitest-environment node
*/
import { dbChainMock, dbChainMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockDispatchSimEvent, mockReadLastFiredAt, mockClaimCooldown } = vi.hoisted(() => ({
mockDispatchSimEvent: vi.fn(),
mockReadLastFiredAt: vi.fn(),
mockClaimCooldown: vi.fn(),
}))
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@/lib/workspace-events/emitter', () => ({
dispatchSimEvent: mockDispatchSimEvent,
}))
vi.mock('@/lib/workspace-events/state', () => ({
readLastFiredAt: mockReadLastFiredAt,
claimCooldown: mockClaimCooldown,
isWithinCooldown: vi.fn(
(lastFiredAt: Date | null, cooldownMs: number) =>
lastFiredAt !== null && Date.now() - lastFiredAt.getTime() < cooldownMs
),
}))
vi.mock('@/lib/workspace-events/subscriptions', () => ({
parseSubscriptionConfig: vi.fn((providerConfig: unknown) => providerConfig),
}))
vi.mock('@/lib/workspace-events/rules', () => ({
excludeSimExecutionsCondition: vi.fn(() => ({ type: 'ne', right: 'sim' })),
}))
import {
NO_ACTIVITY_SUBSCRIPTION_PAGE_SIZE,
NO_ACTIVITY_WORKFLOW_PAGE_SIZE,
pollNoActivityEvents,
} from '@/lib/workspace-events/no-activity'
import type { SimSubscriptionConfig } from '@/lib/workspace-events/types'
function makeConfig(overrides: Partial<SimSubscriptionConfig> = {}): SimSubscriptionConfig {
return {
eventType: 'no_activity',
workflowIds: [],
consecutiveFailures: 3,
failureRatePercent: 50,
windowHours: 24,
durationThresholdMs: 30000,
latencySpikePercent: 100,
costThresholdCredits: 200,
errorCountThreshold: 10,
inactivityHours: 24,
...overrides,
}
}
/** Flattens nested and/or condition trees from the drizzle operator mocks. */
function flattenCondition(condition: unknown): unknown[] {
if (!condition || typeof condition !== 'object') return []
const node = condition as { type?: string; conditions?: unknown[] }
if (node.type === 'and' || node.type === 'or') {
return [node, ...(node.conditions ?? []).flatMap(flattenCondition)]
}
return [node]
}
function allWhereConditions(): unknown[] {
return dbChainMockFns.where.mock.calls.flatMap(([condition]) => flattenCondition(condition))
}
function makeSubscriptionRow(config: SimSubscriptionConfig, webhookId = 'wh-1') {
return {
webhook: {
id: webhookId,
workflowId: 'wf-subscriber',
blockId: 'block-1',
path: 'block-1',
provider: 'sim',
providerConfig: config,
isActive: true,
},
workflow: {
id: 'wf-subscriber',
name: 'Subscriber',
workspaceId: 'ws-1',
},
}
}
describe('pollNoActivityEvents', () => {
beforeEach(() => {
vi.clearAllMocks()
mockReadLastFiredAt.mockResolvedValue(null)
mockClaimCooldown.mockResolvedValue(true)
mockDispatchSimEvent.mockResolvedValue(undefined)
})
it('does nothing when there are no no_activity subscriptions', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([])
const result = await pollNoActivityEvents()
expect(result).toEqual({ subscriptions: 0, checked: 0, fired: 0, skipped: 0 })
expect(mockDispatchSimEvent).not.toHaveBeenCalled()
})
it('fires for a watched workflow with no executions in the window', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([makeSubscriptionRow(makeConfig())])
.mockResolvedValueOnce([{ id: 'wf-quiet', name: 'Quiet Workflow' }])
.mockResolvedValueOnce([])
const result = await pollNoActivityEvents()
expect(result.fired).toBe(1)
expect(mockClaimCooldown).toHaveBeenCalledWith(
'wf-subscriber',
'block-1',
'wf-quiet',
expect.any(Number)
)
expect(mockDispatchSimEvent).toHaveBeenCalledTimes(1)
expect(mockDispatchSimEvent.mock.calls[0][1]).toMatchObject({
event: 'no_activity',
workflowId: 'wf-quiet',
workflowName: 'Quiet Workflow',
runId: null,
})
})
it('does not fire for a workflow with recent activity', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([makeSubscriptionRow(makeConfig())])
.mockResolvedValueOnce([{ id: 'wf-busy', name: 'Busy Workflow' }])
.mockResolvedValueOnce([{ id: 'log-1' }])
const result = await pollNoActivityEvents()
expect(result.fired).toBe(0)
expect(result.skipped).toBe(1)
expect(mockDispatchSimEvent).not.toHaveBeenCalled()
})
it('only fires for the inactive workflow when watching several', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([makeSubscriptionRow(makeConfig())])
.mockResolvedValueOnce([
{ id: 'wf-busy', name: 'Busy Workflow' },
{ id: 'wf-quiet', name: 'Quiet Workflow' },
])
.mockResolvedValueOnce([{ id: 'log-1' }])
.mockResolvedValueOnce([])
const result = await pollNoActivityEvents()
expect(result.fired).toBe(1)
expect(mockDispatchSimEvent).toHaveBeenCalledTimes(1)
expect(mockDispatchSimEvent.mock.calls[0][1]).toMatchObject({ workflowId: 'wf-quiet' })
})
it('cooldown is scoped per watched workflow: a cooled-down workflow does not suppress others', async () => {
mockReadLastFiredAt.mockImplementation((_wf: string, _block: string, scopeKey: string) =>
Promise.resolve(scopeKey === 'wf-cooled' ? new Date() : null)
)
dbChainMockFns.limit
.mockResolvedValueOnce([makeSubscriptionRow(makeConfig())])
.mockResolvedValueOnce([
{ id: 'wf-cooled', name: 'Cooled Workflow' },
{ id: 'wf-quiet', name: 'Quiet Workflow' },
])
.mockResolvedValueOnce([])
const result = await pollNoActivityEvents()
expect(result.fired).toBe(1)
expect(result.skipped).toBe(1)
expect(mockDispatchSimEvent.mock.calls[0][1]).toMatchObject({ workflowId: 'wf-quiet' })
})
it('a never-executed workflow fires once, then the lost claim suppresses repeats', async () => {
mockClaimCooldown.mockResolvedValueOnce(true).mockResolvedValueOnce(false)
for (let poll = 0; poll < 2; poll++) {
dbChainMockFns.limit
.mockResolvedValueOnce([makeSubscriptionRow(makeConfig())])
.mockResolvedValueOnce([{ id: 'wf-never-ran', name: 'Never Ran' }])
.mockResolvedValueOnce([])
}
const first = await pollNoActivityEvents()
const second = await pollNoActivityEvents()
expect(first.fired).toBe(1)
expect(second.fired).toBe(0)
expect(mockDispatchSimEvent).toHaveBeenCalledTimes(1)
})
it('scopes the watched-workflow query to the explicit selection in SQL (before the LIMIT)', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([makeSubscriptionRow(makeConfig({ workflowIds: ['wf-watched'] }))])
.mockResolvedValueOnce([{ id: 'wf-watched', name: 'Watched' }])
.mockResolvedValueOnce([])
const result = await pollNoActivityEvents()
expect(result.checked).toBe(1)
expect(mockDispatchSimEvent).toHaveBeenCalledTimes(1)
expect(mockDispatchSimEvent.mock.calls[0][1]).toMatchObject({ workflowId: 'wf-watched' })
expect(allWhereConditions()).toContainEqual(
expect.objectContaining({ type: 'inArray', values: ['wf-watched'] })
)
})
it('pages through subscriptions past the page size with a keyset cursor (no starvation)', async () => {
// Full first page of non-matching subscriptions (skipped without further
// queries), then a second page holding the one real no_activity
// subscription that must still be reached.
const firstPage = Array.from({ length: NO_ACTIVITY_SUBSCRIPTION_PAGE_SIZE }, (_, i) =>
makeSubscriptionRow(makeConfig({ eventType: 'execution_error' }), `wh-page1-${i}`)
)
dbChainMockFns.limit
.mockResolvedValueOnce(firstPage)
.mockResolvedValueOnce([makeSubscriptionRow(makeConfig(), 'wh-page2-0')])
.mockResolvedValueOnce([{ id: 'wf-quiet', name: 'Quiet Workflow' }])
.mockResolvedValueOnce([])
const result = await pollNoActivityEvents()
expect(result.subscriptions).toBe(NO_ACTIVITY_SUBSCRIPTION_PAGE_SIZE + 1)
expect(result.fired).toBe(1)
expect(mockDispatchSimEvent.mock.calls[0][1]).toMatchObject({ workflowId: 'wf-quiet' })
expect(allWhereConditions()).toContainEqual(
expect.objectContaining({
type: 'gt',
right: `wh-page1-${NO_ACTIVITY_SUBSCRIPTION_PAGE_SIZE - 1}`,
})
)
})
it('pages through watched workflows past the page size with a keyset cursor (no lost coverage)', async () => {
// Full first page of watched workflows all inside their cooldown (skipped
// without activity queries), then a second page holding the quiet
// workflow that must still be reached.
mockReadLastFiredAt.mockImplementation((_wf: string, _block: string, scopeKey: string) =>
Promise.resolve(scopeKey.startsWith('wf-p1-') ? new Date() : null)
)
const firstPage = Array.from({ length: NO_ACTIVITY_WORKFLOW_PAGE_SIZE }, (_, i) => ({
id: `wf-p1-${i}`,
name: `Workflow ${i}`,
}))
dbChainMockFns.limit
.mockResolvedValueOnce([makeSubscriptionRow(makeConfig())])
.mockResolvedValueOnce(firstPage)
.mockResolvedValueOnce([{ id: 'wf-quiet', name: 'Quiet Workflow' }])
.mockResolvedValueOnce([])
const result = await pollNoActivityEvents()
expect(result.checked).toBe(NO_ACTIVITY_WORKFLOW_PAGE_SIZE + 1)
expect(result.skipped).toBe(NO_ACTIVITY_WORKFLOW_PAGE_SIZE)
expect(result.fired).toBe(1)
expect(mockDispatchSimEvent.mock.calls[0][1]).toMatchObject({ workflowId: 'wf-quiet' })
expect(allWhereConditions()).toContainEqual(
expect.objectContaining({
type: 'gt',
right: `wf-p1-${NO_ACTIVITY_WORKFLOW_PAGE_SIZE - 1}`,
})
)
})
it('excludes the subscriber workflow in SQL (before the LIMIT)', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([makeSubscriptionRow(makeConfig())])
.mockResolvedValueOnce([])
const result = await pollNoActivityEvents()
expect(result.checked).toBe(0)
expect(mockDispatchSimEvent).not.toHaveBeenCalled()
expect(allWhereConditions()).toContainEqual(
expect.objectContaining({ type: 'ne', right: 'wf-subscriber' })
)
})
})
@@ -0,0 +1,274 @@
import { db, dbReplica } from '@sim/db'
import { webhook, workflow, workflowDeploymentVersion, workflowExecutionLogs } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, asc, eq, gt, gte, inArray, isNull, ne, or, sql } from 'drizzle-orm'
import { SIM_RULE_COOLDOWN_HOURS, SIM_TRIGGER_PROVIDER } from '@/lib/workspace-events/constants'
import { dispatchSimEvent } from '@/lib/workspace-events/emitter'
import { buildNoActivityEventPayload } from '@/lib/workspace-events/payload'
import { excludeSimExecutionsCondition } from '@/lib/workspace-events/rules'
import { claimCooldown, isWithinCooldown, readLastFiredAt } from '@/lib/workspace-events/state'
import { parseSubscriptionConfig } from '@/lib/workspace-events/subscriptions'
import type { SimSubscription, SimSubscriptionConfig } from '@/lib/workspace-events/types'
const logger = createLogger('WorkspaceEventNoActivity')
/**
* Page size for the keyset-paginated subscription scan. Every subscription is
* visited each poll — pagination bounds memory, not total work.
*/
export const NO_ACTIVITY_SUBSCRIPTION_PAGE_SIZE = 500
/**
* Page size for the keyset-paginated watched-workflow scan. Every watched
* workflow is visited each poll — pagination bounds memory, not total work.
*/
export const NO_ACTIVITY_WORKFLOW_PAGE_SIZE = 500
export interface NoActivityPollResult {
subscriptions: number
checked: number
fired: number
skipped: number
}
/**
* Fetches one page of deployed Sim-trigger subscriptions configured for
* no_activity, across all workspaces, keyset-paginated by webhook id. A
* fixed cap would silently starve subscriptions beyond it; paging visits
* every subscription while keeping memory bounded. This runs from a
* low-frequency cron, so a global paged scan is acceptable — unlike the hot
* execution-completion path.
*/
async function fetchNoActivitySubscriptionPage(
afterWebhookId: string | null
): Promise<SimSubscription[]> {
const rows = await dbReplica
.select({ webhook, workflow })
.from(webhook)
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
.leftJoin(
workflowDeploymentVersion,
and(
eq(workflowDeploymentVersion.workflowId, workflow.id),
eq(workflowDeploymentVersion.isActive, true)
)
)
.where(
and(
eq(webhook.provider, SIM_TRIGGER_PROVIDER),
eq(webhook.isActive, true),
isNull(webhook.archivedAt),
eq(workflow.isDeployed, true),
isNull(workflow.archivedAt),
sql`${webhook.providerConfig}->>'eventType' = 'no_activity'`,
or(
eq(webhook.deploymentVersionId, workflowDeploymentVersion.id),
and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId))
),
afterWebhookId === null ? undefined : gt(webhook.id, afterWebhookId)
)
)
.orderBy(asc(webhook.id))
.limit(NO_ACTIVITY_SUBSCRIPTION_PAGE_SIZE)
return rows
}
/**
* Fetches one page of the workflows a no_activity subscription watches:
* deployed, active workflows in the subscriber's workspace, minus the
* subscriber itself, narrowed to the explicit selection when one is set
* (empty selection watches everything). Deployed-only keeps never-runnable
* draft workflows from alerting forever. Keyset-paginated by workflow id so
* watch-everything subscriptions in large workspaces never silently lose
* coverage past a cap.
*/
async function fetchWatchedWorkflowPage(
workspaceId: string,
subscriberWorkflowId: string,
config: SimSubscriptionConfig,
afterWorkflowId: string | null
): Promise<Array<{ id: string; name: string }>> {
// Subscriber exclusion and the explicit selection must be SQL conditions:
// filtering in memory after the LIMIT could drop an explicitly watched
// workflow. The ORDER BY drives the keyset cursor.
const conditions = [
eq(workflow.workspaceId, workspaceId),
eq(workflow.isDeployed, true),
isNull(workflow.archivedAt),
ne(workflow.id, subscriberWorkflowId),
]
if (config.workflowIds.length > 0) {
conditions.push(inArray(workflow.id, config.workflowIds))
}
if (afterWorkflowId !== null) {
conditions.push(gt(workflow.id, afterWorkflowId))
}
return dbReplica
.select({ id: workflow.id, name: workflow.name })
.from(workflow)
.where(and(...conditions))
.orderBy(asc(workflow.id))
.limit(NO_ACTIVITY_WORKFLOW_PAGE_SIZE)
}
/** True when the workflow had at least one qualifying execution inside the window. */
async function hasRecentActivity(
workflowId: string,
config: SimSubscriptionConfig
): Promise<boolean> {
const windowStart = new Date(Date.now() - config.inactivityHours * 60 * 60 * 1000)
const recentLogs = await db
.select({ id: workflowExecutionLogs.id })
.from(workflowExecutionLogs)
.where(
and(
eq(workflowExecutionLogs.workflowId, workflowId),
gte(workflowExecutionLogs.startedAt, windowStart),
excludeSimExecutionsCondition()
)
)
.limit(1)
return recentLogs.length > 0
}
/**
* Cooldown for no_activity firings. At least the inactivity window itself —
* an hour-long cooldown with a multi-hour window would re-alert every hour
* for the same ongoing inactivity.
*/
function noActivityCooldownMs(config: SimSubscriptionConfig): number {
return Math.max(SIM_RULE_COOLDOWN_HOURS, config.inactivityHours) * 60 * 60 * 1000
}
/**
* Checks one watched workflow and fires when it has gone quiet, accumulating
* counts into `result`.
*/
async function checkWatchedWorkflow(
subscription: SimSubscription,
config: SimSubscriptionConfig,
sourceWorkflow: { id: string; name: string },
result: NoActivityPollResult
): Promise<void> {
result.checked++
const blockKey = subscription.webhook.blockId ?? subscription.webhook.path ?? ''
const cooldownMs = noActivityCooldownMs(config)
const lastFiredAt = await readLastFiredAt(
subscription.webhook.workflowId,
blockKey,
sourceWorkflow.id
)
if (isWithinCooldown(lastFiredAt, cooldownMs)) {
result.skipped++
return
}
if (await hasRecentActivity(sourceWorkflow.id, config)) {
result.skipped++
return
}
const claimed = await claimCooldown(
subscription.webhook.workflowId,
blockKey,
sourceWorkflow.id,
cooldownMs
)
if (!claimed) {
result.skipped++
return
}
const payload = buildNoActivityEventPayload({
workflowId: sourceWorkflow.id,
workflowName: sourceWorkflow.name,
})
await dispatchSimEvent(subscription, payload)
result.fired++
logger.info(`no_activity event fired for workflow ${sourceWorkflow.id}`, {
subscriberWorkflowId: subscription.webhook.workflowId,
inactivityHours: config.inactivityHours,
})
}
/**
* Checks a single no_activity subscription's watched workflows and fires
* events for the inactive ones, accumulating counts into `result`. The
* watched-workflow scan is keyset-paginated, so coverage is complete even in
* workspaces with more workflows than one page.
*/
async function checkSubscription(
subscription: SimSubscription,
result: NoActivityPollResult
): Promise<void> {
const config = parseSubscriptionConfig(subscription.webhook.providerConfig)
if (!config || config.eventType !== 'no_activity') return
const workspaceId = subscription.workflow.workspaceId
if (!workspaceId) return
let cursor: string | null = null
while (true) {
const page = await fetchWatchedWorkflowPage(
workspaceId,
subscription.webhook.workflowId,
config,
cursor
)
if (page.length === 0) break
cursor = page[page.length - 1].id
for (const sourceWorkflow of page) {
await checkWatchedWorkflow(subscription, config, sourceWorkflow, result)
}
if (page.length < NO_ACTIVITY_WORKFLOW_PAGE_SIZE) break
}
}
/**
* Checks every no_activity subscription and fires side-effect workflows for
* watched workflows with no qualifying executions inside the window. The
* subscription scan is keyset-paginated, so every subscription is visited
* each poll regardless of how many exist.
*
* Cooldown state is keyed per (subscriber block × watched workflow), so one
* inactive workflow never suppresses alerts for others — a deliberate fix
* over the legacy per-subscription cooldown. A deployed workflow that has
* never executed fires once, then respects the cooldown.
*/
export async function pollNoActivityEvents(): Promise<NoActivityPollResult> {
const result: NoActivityPollResult = { subscriptions: 0, checked: 0, fired: 0, skipped: 0 }
let cursor: string | null = null
while (true) {
const page = await fetchNoActivitySubscriptionPage(cursor)
if (page.length === 0) break
result.subscriptions += page.length
cursor = page[page.length - 1].webhook.id
for (const subscription of page) {
await checkSubscription(subscription, result)
}
if (page.length < NO_ACTIVITY_SUBSCRIPTION_PAGE_SIZE) break
}
if (result.subscriptions === 0) return result
logger.info(
`no_activity poll completed: ${result.fired} fired, ${result.skipped} skipped of ${result.checked} checked`
)
return result
}
@@ -0,0 +1,144 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
SIM_EVENT_PAYLOAD_FIELDS,
SIM_FINAL_OUTPUT_MAX_BYTES,
} from '@/lib/workspace-events/constants'
import {
buildDeployEventPayload,
buildExecutionEventPayload,
buildNoActivityEventPayload,
} from '@/lib/workspace-events/payload'
import type { ExecutionEventContext } from '@/lib/workspace-events/types'
const payloadKeys = Object.keys(SIM_EVENT_PAYLOAD_FIELDS).sort()
function makeContext(overrides: Partial<ExecutionEventContext> = {}): ExecutionEventContext {
return {
workflowId: 'wf-source',
executionId: 'exec-1',
status: 'error',
durationMs: 1000,
cost: 0.25,
finalOutput: { result: 42 },
...overrides,
}
}
describe('payload builders align with the shared field constants', () => {
it('run-backed event payload has exactly the declared keys', () => {
const payload = buildExecutionEventPayload({
event: 'execution_error',
workflowName: 'Source',
context: makeContext(),
})
expect(Object.keys(payload).sort()).toEqual(payloadKeys)
expect(payload).toMatchObject({
event: 'execution_error',
workflowId: 'wf-source',
workflowName: 'Source',
runId: 'exec-1',
durationMs: 1000,
// $0.25 reported as credits (1 credit = $0.005)
cost: 50,
})
})
it('deploy event payload has exactly the declared keys with run fields null', () => {
const payload = buildDeployEventPayload({
workflowId: 'wf-source',
workflowName: 'Source',
version: 3,
})
expect(Object.keys(payload).sort()).toEqual(payloadKeys)
expect(payload).toMatchObject({
event: 'workflow_deployed',
workflowId: 'wf-source',
workflowName: 'Source',
runId: null,
durationMs: null,
cost: null,
finalOutput: null,
version: 3,
})
})
it('rule event payload nests the triggering run instead of top-level run fields', () => {
const payload = buildExecutionEventPayload({
event: 'cost_threshold',
workflowName: 'Source',
context: makeContext(),
})
expect(Object.keys(payload).sort()).toEqual(payloadKeys)
expect(payload).toMatchObject({
event: 'cost_threshold',
runId: null,
durationMs: null,
cost: null,
finalOutput: null,
triggeringRun: {
runId: 'exec-1',
durationMs: 1000,
// $0.25 reported as credits (1 credit = $0.005)
cost: 50,
finalOutput: { result: 42 },
},
})
})
it('no-activity payload has exactly the declared keys with run fields null', () => {
const payload = buildNoActivityEventPayload({
workflowId: 'wf-source',
workflowName: 'Source',
})
expect(Object.keys(payload).sort()).toEqual(payloadKeys)
expect(payload).toMatchObject({
event: 'no_activity',
runId: null,
finalOutput: null,
})
})
})
describe('finalOutput handling', () => {
it('passes small outputs through untouched', () => {
const payload = buildExecutionEventPayload({
event: 'execution_error',
workflowName: 'Source',
context: makeContext(),
})
expect(payload.finalOutput).toEqual({ result: 42 })
})
it('serializes and truncates oversized outputs', () => {
const huge = { blob: 'x'.repeat(SIM_FINAL_OUTPUT_MAX_BYTES + 1024) }
const payload = buildExecutionEventPayload({
event: 'execution_error',
workflowName: 'Source',
context: makeContext({ finalOutput: huge }),
})
expect(typeof payload.finalOutput).toBe('string')
expect((payload.finalOutput as string).length).toBeLessThanOrEqual(SIM_FINAL_OUTPUT_MAX_BYTES)
})
it('is nested under triggeringRun for rule events', () => {
const payload = buildExecutionEventPayload({
event: 'cost_threshold',
workflowName: 'Source',
context: makeContext(),
})
expect(payload.finalOutput).toBeNull()
expect(payload.triggeringRun?.finalOutput).toEqual({ result: 42 })
})
it('is null when the source run produced no output', () => {
const payload = buildExecutionEventPayload({
event: 'execution_success',
workflowName: 'Source',
context: makeContext({ status: 'success', finalOutput: undefined }),
})
expect(payload.finalOutput).toBeNull()
})
})
+124
View File
@@ -0,0 +1,124 @@
import { truncate } from '@sim/utils/string'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import {
SIM_FINAL_OUTPUT_MAX_BYTES,
type SimEventType,
type SimPlainEventType,
type SimRuleEventType,
} from '@/lib/workspace-events/constants'
import type {
ExecutionEventContext,
SimEventPayload,
SimRunSummary,
} from '@/lib/workspace-events/types'
/**
* Bounds the finalOutput field. Trace spans are never included; the payload
* travels through the job queue, so large outputs are serialized and
* truncated instead of being passed through whole.
*/
function boundFinalOutput(finalOutput: unknown): unknown {
if (finalOutput === undefined || finalOutput === null) return null
try {
const serialized = JSON.stringify(finalOutput)
if (serialized === undefined) return null
if (serialized.length <= SIM_FINAL_OUTPUT_MAX_BYTES) return finalOutput
const suffix = '... [truncated]'
return truncate(serialized, SIM_FINAL_OUTPUT_MAX_BYTES - suffix.length, suffix)
} catch {
return null
}
}
function basePayload(params: {
event: SimEventType
workflowId: string
workflowName: string
}): SimEventPayload {
return {
event: params.event,
timestamp: new Date().toISOString(),
workflowId: params.workflowId,
workflowName: params.workflowName,
runId: null,
durationMs: null,
cost: null,
finalOutput: null,
triggeringRun: null,
version: null,
}
}
/** Run summary in user-facing units: cost in credits, finalOutput bounded. */
function summarizeRun(context: ExecutionEventContext): SimRunSummary {
return {
runId: context.executionId,
durationMs: context.durationMs,
// Costs are stored in dollars; credits are the user-facing unit.
cost: dollarsToCredits(context.cost),
finalOutput: boundFinalOutput(context.finalOutput),
}
}
/**
* Payload for run-backed events. Plain success/error events ARE the run, so
* its summary sits at the top level; for rule events the run is evidence for
* the condition that fired, so it nests under `triggeringRun`.
*/
export function buildExecutionEventPayload(params: {
event: Exclude<SimPlainEventType, 'workflow_deployed' | 'workflow_undeployed'> | SimRuleEventType
workflowName: string
context: ExecutionEventContext
}): SimEventPayload {
const { event, workflowName, context } = params
const base = basePayload({ event, workflowId: context.workflowId, workflowName })
const run = summarizeRun(context)
if (event === 'execution_success' || event === 'execution_error') {
return { ...base, ...run }
}
return { ...base, triggeringRun: run }
}
/** Payload for workflow_deployed events. */
export function buildDeployEventPayload(params: {
workflowId: string
workflowName: string
version: number | null
}): SimEventPayload {
return {
...basePayload({
event: 'workflow_deployed',
workflowId: params.workflowId,
workflowName: params.workflowName,
}),
version: params.version,
}
}
/** Payload for workflow_undeployed events. */
export function buildUndeployEventPayload(params: {
workflowId: string
workflowName: string
}): SimEventPayload {
return basePayload({
event: 'workflow_undeployed',
workflowId: params.workflowId,
workflowName: params.workflowName,
})
}
/** Payload for no_activity events (no source run exists). */
export function buildNoActivityEventPayload(params: {
workflowId: string
workflowName: string
}): SimEventPayload {
return basePayload({
event: 'no_activity',
workflowId: params.workflowId,
workflowName: params.workflowName,
})
}
+209
View File
@@ -0,0 +1,209 @@
/**
* @vitest-environment node
*/
import { dbChainMock, dbChainMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/db', () => dbChainMock)
import { evaluateRule, excludeSimExecutionsCondition } from '@/lib/workspace-events/rules'
import type { ExecutionEventContext, SimSubscriptionConfig } from '@/lib/workspace-events/types'
function makeConfig(overrides: Partial<SimSubscriptionConfig> = {}): SimSubscriptionConfig {
return {
eventType: 'execution_error',
workflowIds: [],
consecutiveFailures: 3,
failureRatePercent: 50,
windowHours: 24,
durationThresholdMs: 30000,
latencySpikePercent: 100,
costThresholdCredits: 200,
errorCountThreshold: 10,
inactivityHours: 24,
...overrides,
}
}
function makeContext(overrides: Partial<ExecutionEventContext> = {}): ExecutionEventContext {
return {
workflowId: 'wf-source',
executionId: 'exec-1',
status: 'error',
trigger: 'manual',
durationMs: 1000,
cost: 0.25,
errorMessage: 'boom',
finalOutput: null,
...overrides,
}
}
describe('excludeSimExecutionsCondition', () => {
it('excludes sim-triggered executions from rule statistics', () => {
const condition = excludeSimExecutionsCondition() as unknown as {
type: string
right?: unknown
}
expect(condition).toMatchObject({ type: 'ne', right: 'sim' })
})
})
describe('evaluateRule', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('consecutive_failures', () => {
it('fires when the last N executions all failed', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([
{ level: 'error' },
{ level: 'error' },
{ level: 'error' },
])
await expect(evaluateRule('consecutive_failures', makeConfig(), makeContext())).resolves.toBe(
true
)
})
it('does not fire when any recent execution succeeded', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([
{ level: 'error' },
{ level: 'info' },
{ level: 'error' },
])
await expect(evaluateRule('consecutive_failures', makeConfig(), makeContext())).resolves.toBe(
false
)
})
it('does not fire with fewer executions than the threshold', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([{ level: 'error' }, { level: 'error' }])
await expect(evaluateRule('consecutive_failures', makeConfig(), makeContext())).resolves.toBe(
false
)
})
it('only runs on failed executions', async () => {
await expect(
evaluateRule('consecutive_failures', makeConfig(), makeContext({ status: 'success' }))
).resolves.toBe(false)
expect(dbChainMockFns.select).not.toHaveBeenCalled()
})
})
describe('failure_rate', () => {
it('fires when the in-window failure rate meets the threshold (fixed legacy dead code)', async () => {
dbChainMockFns.where.mockImplementationOnce(() => Promise.resolve([{ total: 6, errors: 4 }]))
await expect(evaluateRule('failure_rate', makeConfig(), makeContext())).resolves.toBe(true)
})
it('does not fire below the minimum execution count', async () => {
dbChainMockFns.where.mockImplementationOnce(() => Promise.resolve([{ total: 4, errors: 4 }]))
await expect(evaluateRule('failure_rate', makeConfig(), makeContext())).resolves.toBe(false)
})
it('does not fire when the rate is below the threshold', async () => {
dbChainMockFns.where.mockImplementationOnce(() => Promise.resolve([{ total: 5, errors: 1 }]))
await expect(evaluateRule('failure_rate', makeConfig(), makeContext())).resolves.toBe(false)
})
})
describe('latency_threshold', () => {
it('fires when duration exceeds the threshold', async () => {
await expect(
evaluateRule(
'latency_threshold',
makeConfig({ durationThresholdMs: 1000 }),
makeContext({ durationMs: 1001 })
)
).resolves.toBe(true)
})
it('does not fire at exactly the threshold', async () => {
await expect(
evaluateRule(
'latency_threshold',
makeConfig({ durationThresholdMs: 1000 }),
makeContext({ durationMs: 1000 })
)
).resolves.toBe(false)
})
})
describe('latency_spike', () => {
it('fires when the execution is slower than the spike threshold over the average', async () => {
dbChainMockFns.where.mockImplementationOnce(() =>
Promise.resolve([{ avgDuration: '1000', count: 5 }])
)
await expect(
evaluateRule('latency_spike', makeConfig(), makeContext({ durationMs: 2001 }))
).resolves.toBe(true)
})
it('does not fire at exactly the spike threshold', async () => {
dbChainMockFns.where.mockImplementationOnce(() =>
Promise.resolve([{ avgDuration: '1000', count: 5 }])
)
await expect(
evaluateRule('latency_spike', makeConfig(), makeContext({ durationMs: 2000 }))
).resolves.toBe(false)
})
it('does not fire below the minimum execution count', async () => {
dbChainMockFns.where.mockImplementationOnce(() =>
Promise.resolve([{ avgDuration: '1000', count: 4 }])
)
await expect(
evaluateRule('latency_spike', makeConfig(), makeContext({ durationMs: 5000 }))
).resolves.toBe(false)
})
})
describe('cost_threshold', () => {
it('fires when the run cost exceeds the credit-denominated threshold', async () => {
// 200 credits = $1; a $1.50 run exceeds it.
await expect(
evaluateRule(
'cost_threshold',
makeConfig({ costThresholdCredits: 200 }),
makeContext({ cost: 1.5 })
)
).resolves.toBe(true)
})
it('does not fire at exactly the threshold', async () => {
await expect(
evaluateRule(
'cost_threshold',
makeConfig({ costThresholdCredits: 200 }),
makeContext({ cost: 1 })
)
).resolves.toBe(false)
})
})
describe('error_count', () => {
it('fires when the in-window error count reaches the threshold', async () => {
dbChainMockFns.where.mockImplementationOnce(() => Promise.resolve([{ count: 10 }]))
await expect(evaluateRule('error_count', makeConfig(), makeContext())).resolves.toBe(true)
})
it('does not fire below the threshold', async () => {
dbChainMockFns.where.mockImplementationOnce(() => Promise.resolve([{ count: 9 }]))
await expect(evaluateRule('error_count', makeConfig(), makeContext())).resolves.toBe(false)
})
it('only runs on failed executions', async () => {
await expect(
evaluateRule('error_count', makeConfig(), makeContext({ status: 'success' }))
).resolves.toBe(false)
expect(dbChainMockFns.select).not.toHaveBeenCalled()
})
})
it('no_activity never fires at execution time (owned by the poller)', async () => {
await expect(evaluateRule('no_activity', makeConfig(), makeContext())).resolves.toBe(false)
expect(dbChainMockFns.select).not.toHaveBeenCalled()
})
})
+179
View File
@@ -0,0 +1,179 @@
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
}
}
+72
View File
@@ -0,0 +1,72 @@
import { db } from '@sim/db'
import { simTriggerState } from '@sim/db/schema'
import { and, eq, sql } from 'drizzle-orm'
/**
* Reads the last firing time for a subscription scope. Cheap pre-check used
* to skip rule SQL while a subscription is cooling down; the atomic claim in
* {@link claimCooldown} remains the source of truth.
*/
export async function readLastFiredAt(
workflowId: string,
blockId: string,
scopeKey: string
): Promise<Date | null> {
const rows = await db
.select({ lastFiredAt: simTriggerState.lastFiredAt })
.from(simTriggerState)
.where(
and(
eq(simTriggerState.workflowId, workflowId),
eq(simTriggerState.blockId, blockId),
eq(simTriggerState.scopeKey, scopeKey)
)
)
.limit(1)
return rows[0]?.lastFiredAt ?? null
}
/**
* Atomically claims a cooldown slot for a subscription scope.
*
* Uses an upsert whose update only applies when the previous firing is
* outside the cooldown window, so concurrent qualifying events can never
* double-fire: exactly one caller gets a row back.
*
* State is keyed by (workflowId, blockId, scopeKey) rather than the webhook
* row so cooldowns survive redeploys (webhook rows are recreated per
* deployment version).
*/
export async function claimCooldown(
workflowId: string,
blockId: string,
scopeKey: string,
cooldownMs: number
): Promise<boolean> {
const now = new Date()
const threshold = new Date(now.getTime() - cooldownMs)
const rows = await db
.insert(simTriggerState)
.values({
workflowId,
blockId,
scopeKey,
lastFiredAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: [simTriggerState.workflowId, simTriggerState.blockId, simTriggerState.scopeKey],
set: { lastFiredAt: now, updatedAt: now },
setWhere: sql`${simTriggerState.lastFiredAt} IS NULL OR ${simTriggerState.lastFiredAt} < ${threshold}`,
})
.returning({ workflowId: simTriggerState.workflowId })
return rows.length > 0
}
export function isWithinCooldown(lastFiredAt: Date | null, cooldownMs: number): boolean {
if (!lastFiredAt) return false
return Date.now() - lastFiredAt.getTime() < cooldownMs
}
@@ -0,0 +1,75 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { SIM_RULE_DEFAULTS } from '@/lib/workspace-events/constants'
import { parseSubscriptionConfig } from '@/lib/workspace-events/subscriptions'
describe('parseSubscriptionConfig', () => {
it('returns null for configs without a recognizable event type', () => {
expect(parseSubscriptionConfig(null)).toBeNull()
expect(parseSubscriptionConfig({})).toBeNull()
expect(parseSubscriptionConfig({ eventType: 'bogus' })).toBeNull()
expect(parseSubscriptionConfig('not-an-object')).toBeNull()
})
it('parses workflow ids from arrays and comma-separated strings', () => {
expect(
parseSubscriptionConfig({ eventType: 'execution_error', workflowIds: ['a', 'b', ''] })
?.workflowIds
).toEqual(['a', 'b'])
expect(
parseSubscriptionConfig({ eventType: 'execution_error', workflowIds: 'a, b,' })?.workflowIds
).toEqual(['a', 'b'])
})
it('treats a missing workflow selection as watching every workflow (empty list)', () => {
expect(parseSubscriptionConfig({ eventType: 'execution_error' })?.workflowIds).toEqual([])
})
it('coerces numeric rule fields and falls back to defaults for invalid values', () => {
const config = parseSubscriptionConfig({
eventType: 'consecutive_failures',
consecutiveFailures: '5',
windowHours: 'not-a-number',
costThresholdCredits: -2,
})
expect(config?.consecutiveFailures).toBe(5)
expect(config?.windowHours).toBe(SIM_RULE_DEFAULTS.windowHours)
expect(config?.costThresholdCredits).toBe(SIM_RULE_DEFAULTS.costThresholdCredits)
})
it('clamps rule fields to the legacy bounds (hot-path queries must stay bounded)', () => {
const config = parseSubscriptionConfig({
eventType: 'failure_rate',
windowHours: 1_000_000,
consecutiveFailures: 5000,
failureRatePercent: 250,
durationThresholdMs: 5,
latencySpikePercent: 1,
costThresholdCredits: 10_000_000,
errorCountThreshold: 99999,
inactivityHours: 0.01,
})
expect(config?.windowHours).toBe(168)
expect(config?.consecutiveFailures).toBe(100)
expect(config?.failureRatePercent).toBe(100)
expect(config?.durationThresholdMs).toBe(1000)
expect(config?.latencySpikePercent).toBe(10)
expect(config?.costThresholdCredits).toBe(200_000)
expect(config?.errorCountThreshold).toBe(1000)
expect(config?.inactivityHours).toBe(1)
})
it('rounds fractional integer fields (counts feed SQL LIMIT) but keeps credits fractional', () => {
const config = parseSubscriptionConfig({
eventType: 'consecutive_failures',
consecutiveFailures: '2.5',
windowHours: 12.4,
costThresholdCredits: 250.5,
})
expect(config?.consecutiveFailures).toBe(3)
expect(config?.windowHours).toBe(12)
expect(config?.costThresholdCredits).toBe(250.5)
})
})
@@ -0,0 +1,158 @@
import { db } from '@sim/db'
import { webhook, workflow, workflowDeploymentVersion } from '@sim/db/schema'
import { and, eq, isNull, or } from 'drizzle-orm'
import {
SIM_EVENT_TYPES,
SIM_RULE_DEFAULTS,
SIM_TRIGGER_PROVIDER,
type SimEventType,
} from '@/lib/workspace-events/constants'
import type { SimSubscription, SimSubscriptionConfig } from '@/lib/workspace-events/types'
/**
* Fetches active Sim-trigger subscriptions for one workspace.
*
* Workspace-scoped on purpose: execution completion is the hottest event in
* the platform, so this must never degrade into a global provider scan. The
* deployment-version join enforces that subscribers are deployed and the
* webhook row belongs to the active deployment version.
*/
export async function fetchSimTriggerSubscriptions(
workspaceId: string
): Promise<SimSubscription[]> {
const rows = await db
.select({ webhook, workflow })
.from(webhook)
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
.leftJoin(
workflowDeploymentVersion,
and(
eq(workflowDeploymentVersion.workflowId, workflow.id),
eq(workflowDeploymentVersion.isActive, true)
)
)
.where(
and(
eq(webhook.provider, SIM_TRIGGER_PROVIDER),
eq(webhook.isActive, true),
isNull(webhook.archivedAt),
eq(workflow.workspaceId, workspaceId),
eq(workflow.isDeployed, true),
isNull(workflow.archivedAt),
or(
eq(webhook.deploymentVersionId, workflowDeploymentVersion.id),
and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId))
)
)
)
return rows
}
function parseStringArray(value: unknown): string[] {
if (Array.isArray(value)) {
return value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)
}
if (typeof value === 'string' && value.length > 0) {
return value
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
}
return []
}
/**
* Per-field bounds ported from the legacy notifications contract. Rule SQL
* runs on the execution-completion hot path, so windows and counts must stay
* inside the designed envelope regardless of what the free-text subblocks
* contain. Integer fields are rounded — counts feed SQL LIMIT, which rejects
* fractional values. The credit bounds are the legacy dollar bounds
* ($0.01-$1000) at 200 credits per dollar; credits stay fractional like the
* legacy dollar threshold.
*/
const SIM_RULE_BOUNDS = {
consecutiveFailures: { min: 1, max: 100, integer: true },
failureRatePercent: { min: 1, max: 100, integer: true },
windowHours: { min: 1, max: 168, integer: true },
durationThresholdMs: { min: 1000, max: 3_600_000, integer: true },
latencySpikePercent: { min: 10, max: 1000, integer: true },
costThresholdCredits: { min: 2, max: 200_000 },
errorCountThreshold: { min: 1, max: 1000, integer: true },
inactivityHours: { min: 1, max: 168, integer: true },
} as const
function parseBoundedNumber(
value: unknown,
fallback: number,
bounds: { min: number; max: number; integer?: boolean }
): number {
const parsed = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(parsed) || parsed <= 0) return fallback
const clamped = Math.min(Math.max(parsed, bounds.min), bounds.max)
return bounds.integer ? Math.round(clamped) : clamped
}
/**
* Parses a webhook row's providerConfig into a typed subscription config.
* Returns null when the config has no recognizable event type.
*/
export function parseSubscriptionConfig(providerConfig: unknown): SimSubscriptionConfig | null {
const config =
providerConfig && typeof providerConfig === 'object' && !Array.isArray(providerConfig)
? (providerConfig as Record<string, unknown>)
: {}
const eventType = config.eventType
if (
typeof eventType !== 'string' ||
!(SIM_EVENT_TYPES as readonly string[]).includes(eventType)
) {
return null
}
return {
eventType: eventType as SimEventType,
workflowIds: parseStringArray(config.workflowIds),
consecutiveFailures: parseBoundedNumber(
config.consecutiveFailures,
SIM_RULE_DEFAULTS.consecutiveFailures,
SIM_RULE_BOUNDS.consecutiveFailures
),
failureRatePercent: parseBoundedNumber(
config.failureRatePercent,
SIM_RULE_DEFAULTS.failureRatePercent,
SIM_RULE_BOUNDS.failureRatePercent
),
windowHours: parseBoundedNumber(
config.windowHours,
SIM_RULE_DEFAULTS.windowHours,
SIM_RULE_BOUNDS.windowHours
),
durationThresholdMs: parseBoundedNumber(
config.durationThresholdMs,
SIM_RULE_DEFAULTS.durationThresholdMs,
SIM_RULE_BOUNDS.durationThresholdMs
),
latencySpikePercent: parseBoundedNumber(
config.latencySpikePercent,
SIM_RULE_DEFAULTS.latencySpikePercent,
SIM_RULE_BOUNDS.latencySpikePercent
),
costThresholdCredits: parseBoundedNumber(
config.costThresholdCredits,
SIM_RULE_DEFAULTS.costThresholdCredits,
SIM_RULE_BOUNDS.costThresholdCredits
),
errorCountThreshold: parseBoundedNumber(
config.errorCountThreshold,
SIM_RULE_DEFAULTS.errorCountThreshold,
SIM_RULE_BOUNDS.errorCountThreshold
),
inactivityHours: parseBoundedNumber(
config.inactivityHours,
SIM_RULE_DEFAULTS.inactivityHours,
SIM_RULE_BOUNDS.inactivityHours
),
}
}
+64
View File
@@ -0,0 +1,64 @@
import type { webhook, workflow } from '@sim/db/schema'
import type { SimEventPayloadFieldKey, SimEventType } from '@/lib/workspace-events/constants'
/** A deployed Sim-trigger block subscribed to workspace events. */
export interface SimSubscription {
webhook: typeof webhook.$inferSelect
workflow: typeof workflow.$inferSelect
}
/**
* Parsed, coerced subscription configuration. Provider config values arrive as
* raw subblock values (numbers as strings, arrays sometimes serialized), so all
* consumers go through the parser in subscriptions.ts rather than reading
* providerConfig directly.
*/
export interface SimSubscriptionConfig {
eventType: SimEventType
/** Source workflows to watch. Empty means every workflow in the workspace. */
workflowIds: string[]
consecutiveFailures: number
failureRatePercent: number
windowHours: number
durationThresholdMs: number
latencySpikePercent: number
costThresholdCredits: number
errorCountThreshold: number
inactivityHours: number
}
/** Facts a completed run contributes to event matching and rule evaluation. */
export interface ExecutionEventContext {
workflowId: string
executionId: string
status: 'success' | 'error'
durationMs: number
/** Run cost in dollars (the storage unit); converted to credits at the payload boundary. */
cost: number
finalOutput: unknown
}
/** Summary of the run behind an event, in user-facing units (cost in credits). */
export interface SimRunSummary {
runId: string
durationMs: number
cost: number
finalOutput: unknown
}
/**
* Wire payload delivered to a Sim trigger workflow. Keys must align with
* SIM_EVENT_PAYLOAD_FIELDS — enforced by tests on the payload builders.
*/
export type SimEventPayload = Record<SimEventPayloadFieldKey, unknown> & {
event: SimEventType
timestamp: string
workflowId: string
workflowName: string
runId: string | null
durationMs: number | null
cost: number | null
finalOutput: unknown
triggeringRun: SimRunSummary | null
version: number | null
}