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

275 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}