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
287 lines
10 KiB
TypeScript
287 lines
10 KiB
TypeScript
/**
|
|
* @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' })
|
|
)
|
|
})
|
|
})
|