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