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
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user