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,246 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
SOCKET_JOIN_RETRY_BASE_DELAY_MS,
|
||||
SOCKET_JOIN_RETRY_MAX_DELAY_MS,
|
||||
SocketJoinController,
|
||||
} from '@/app/workspace/providers/socket-join-controller'
|
||||
|
||||
describe('SocketJoinController', () => {
|
||||
it('blocks rejoining a deleted workflow until the desired workflow changes', () => {
|
||||
const controller = new SocketJoinController()
|
||||
|
||||
expect(controller.setConnected(true)).toEqual([])
|
||||
expect(controller.requestWorkflow('workflow-a')).toEqual([
|
||||
{ type: 'join', workflowId: 'workflow-a' },
|
||||
])
|
||||
expect(controller.handleJoinSuccess('workflow-a')).toMatchObject({
|
||||
apply: true,
|
||||
ignored: false,
|
||||
commands: [],
|
||||
workflowId: 'workflow-a',
|
||||
})
|
||||
|
||||
expect(controller.handleWorkflowDeleted('workflow-a')).toEqual({
|
||||
shouldClearCurrent: true,
|
||||
commands: [],
|
||||
})
|
||||
expect(controller.requestWorkflow('workflow-a')).toEqual([])
|
||||
expect(controller.requestWorkflow('workflow-b')).toEqual([
|
||||
{ type: 'join', workflowId: 'workflow-b' },
|
||||
])
|
||||
})
|
||||
|
||||
it('joins only the latest desired workflow after rapid A to B to C switching', () => {
|
||||
const controller = new SocketJoinController()
|
||||
|
||||
controller.setConnected(true)
|
||||
controller.requestWorkflow('workflow-a')
|
||||
controller.handleJoinSuccess('workflow-a')
|
||||
|
||||
expect(controller.requestWorkflow('workflow-b')).toEqual([
|
||||
{ type: 'join', workflowId: 'workflow-b' },
|
||||
])
|
||||
expect(controller.requestWorkflow('workflow-c')).toEqual([])
|
||||
|
||||
expect(controller.handleJoinSuccess('workflow-b')).toMatchObject({
|
||||
apply: false,
|
||||
ignored: true,
|
||||
workflowId: 'workflow-b',
|
||||
commands: [{ type: 'join', workflowId: 'workflow-c' }],
|
||||
})
|
||||
expect(controller.handleJoinSuccess('workflow-c')).toMatchObject({
|
||||
apply: true,
|
||||
ignored: false,
|
||||
workflowId: 'workflow-c',
|
||||
commands: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejoins the original workflow when a stale success lands after switching back', () => {
|
||||
const controller = new SocketJoinController()
|
||||
|
||||
controller.setConnected(true)
|
||||
controller.requestWorkflow('workflow-a')
|
||||
controller.handleJoinSuccess('workflow-a')
|
||||
|
||||
expect(controller.requestWorkflow('workflow-b')).toEqual([
|
||||
{ type: 'join', workflowId: 'workflow-b' },
|
||||
])
|
||||
expect(controller.requestWorkflow('workflow-a')).toEqual([])
|
||||
|
||||
expect(controller.handleJoinSuccess('workflow-b')).toMatchObject({
|
||||
apply: false,
|
||||
ignored: true,
|
||||
workflowId: 'workflow-b',
|
||||
commands: [{ type: 'join', workflowId: 'workflow-a' }],
|
||||
})
|
||||
expect(controller.handleJoinSuccess('workflow-a')).toMatchObject({
|
||||
apply: true,
|
||||
ignored: false,
|
||||
workflowId: 'workflow-a',
|
||||
commands: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves the room when a late join succeeds after navigating away', () => {
|
||||
const controller = new SocketJoinController()
|
||||
|
||||
controller.setConnected(true)
|
||||
controller.requestWorkflow('workflow-a')
|
||||
controller.handleJoinSuccess('workflow-a')
|
||||
|
||||
expect(controller.requestWorkflow('workflow-b')).toEqual([
|
||||
{ type: 'join', workflowId: 'workflow-b' },
|
||||
])
|
||||
expect(controller.requestWorkflow(null)).toEqual([])
|
||||
|
||||
expect(controller.handleJoinSuccess('workflow-b')).toMatchObject({
|
||||
apply: false,
|
||||
ignored: true,
|
||||
workflowId: 'workflow-b',
|
||||
commands: [{ type: 'leave' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves the last joined workflow during retryable switch failures', () => {
|
||||
const controller = new SocketJoinController()
|
||||
|
||||
controller.setConnected(true)
|
||||
expect(controller.requestWorkflow('workflow-a')).toEqual([
|
||||
{ type: 'join', workflowId: 'workflow-a' },
|
||||
])
|
||||
controller.handleJoinSuccess('workflow-a')
|
||||
|
||||
expect(controller.requestWorkflow('workflow-b')).toEqual([
|
||||
{ type: 'join', workflowId: 'workflow-b' },
|
||||
])
|
||||
|
||||
const errorResult = controller.handleJoinError({
|
||||
workflowId: 'workflow-b',
|
||||
retryable: true,
|
||||
})
|
||||
|
||||
expect(errorResult.apply).toBe(false)
|
||||
expect(errorResult.retryScheduled).toBe(true)
|
||||
expect(errorResult.commands).toEqual([
|
||||
{
|
||||
type: 'schedule-retry',
|
||||
workflowId: 'workflow-b',
|
||||
attempt: 1,
|
||||
delayMs: SOCKET_JOIN_RETRY_BASE_DELAY_MS,
|
||||
},
|
||||
])
|
||||
expect(controller.getJoinedWorkflowId()).toBe('workflow-a')
|
||||
expect(controller.retryJoin('workflow-b')).toEqual([{ type: 'join', workflowId: 'workflow-b' }])
|
||||
})
|
||||
|
||||
it('uses capped exponential backoff for retryable join failures', () => {
|
||||
const controller = new SocketJoinController()
|
||||
|
||||
controller.setConnected(true)
|
||||
controller.requestWorkflow('workflow-a')
|
||||
|
||||
const first = controller.handleJoinError({ workflowId: 'workflow-a', retryable: true })
|
||||
expect(first.commands).toEqual([
|
||||
{
|
||||
type: 'schedule-retry',
|
||||
workflowId: 'workflow-a',
|
||||
attempt: 1,
|
||||
delayMs: SOCKET_JOIN_RETRY_BASE_DELAY_MS,
|
||||
},
|
||||
])
|
||||
|
||||
controller.retryJoin('workflow-a')
|
||||
const second = controller.handleJoinError({ workflowId: 'workflow-a', retryable: true })
|
||||
expect(second.commands).toEqual([
|
||||
{
|
||||
type: 'schedule-retry',
|
||||
workflowId: 'workflow-a',
|
||||
attempt: 2,
|
||||
delayMs: SOCKET_JOIN_RETRY_BASE_DELAY_MS * 2,
|
||||
},
|
||||
])
|
||||
|
||||
controller.retryJoin('workflow-a')
|
||||
controller.handleJoinError({ workflowId: 'workflow-a', retryable: true })
|
||||
controller.retryJoin('workflow-a')
|
||||
const fourth = controller.handleJoinError({ workflowId: 'workflow-a', retryable: true })
|
||||
expect(fourth.commands).toEqual([
|
||||
{
|
||||
type: 'schedule-retry',
|
||||
workflowId: 'workflow-a',
|
||||
attempt: 4,
|
||||
delayMs: SOCKET_JOIN_RETRY_BASE_DELAY_MS * 8,
|
||||
},
|
||||
])
|
||||
|
||||
controller.retryJoin('workflow-a')
|
||||
const fifth = controller.handleJoinError({ workflowId: 'workflow-a', retryable: true })
|
||||
expect(fifth.commands).toEqual([
|
||||
{
|
||||
type: 'schedule-retry',
|
||||
workflowId: 'workflow-a',
|
||||
attempt: 5,
|
||||
delayMs: SOCKET_JOIN_RETRY_MAX_DELAY_MS,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('blocks a permanently failed workflow and leaves the fallback room cleanly', () => {
|
||||
const controller = new SocketJoinController()
|
||||
|
||||
controller.setConnected(true)
|
||||
controller.requestWorkflow('workflow-a')
|
||||
controller.handleJoinSuccess('workflow-a')
|
||||
|
||||
expect(controller.requestWorkflow('workflow-b')).toEqual([
|
||||
{ type: 'join', workflowId: 'workflow-b' },
|
||||
])
|
||||
|
||||
const errorResult = controller.handleJoinError({
|
||||
workflowId: 'workflow-b',
|
||||
retryable: false,
|
||||
})
|
||||
|
||||
expect(errorResult.apply).toBe(true)
|
||||
expect(errorResult.commands).toEqual([{ type: 'leave' }])
|
||||
expect(controller.getJoinedWorkflowId()).toBeNull()
|
||||
expect(controller.requestWorkflow('workflow-b')).toEqual([])
|
||||
expect(controller.requestWorkflow('workflow-c')).toEqual([
|
||||
{ type: 'join', workflowId: 'workflow-c' },
|
||||
])
|
||||
})
|
||||
|
||||
it('rejoins the desired workflow when the server session is lost', () => {
|
||||
const controller = new SocketJoinController()
|
||||
|
||||
controller.setConnected(true)
|
||||
controller.requestWorkflow('workflow-a')
|
||||
controller.handleJoinSuccess('workflow-a')
|
||||
|
||||
expect(controller.forceRejoinWorkflow('workflow-a')).toEqual([
|
||||
{ type: 'join', workflowId: 'workflow-a' },
|
||||
])
|
||||
expect(controller.getJoinedWorkflowId()).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves retryable errors without workflowId against the pending join', () => {
|
||||
const controller = new SocketJoinController()
|
||||
|
||||
controller.setConnected(true)
|
||||
controller.requestWorkflow('workflow-a')
|
||||
|
||||
const errorResult = controller.handleJoinError({ retryable: true })
|
||||
|
||||
expect(errorResult.workflowId).toBe('workflow-a')
|
||||
expect(errorResult.retryScheduled).toBe(true)
|
||||
expect(errorResult.commands).toEqual([
|
||||
{
|
||||
type: 'schedule-retry',
|
||||
workflowId: 'workflow-a',
|
||||
attempt: 1,
|
||||
delayMs: SOCKET_JOIN_RETRY_BASE_DELAY_MS,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,294 @@
|
||||
export const SOCKET_JOIN_RETRY_BASE_DELAY_MS = 1000
|
||||
export const SOCKET_JOIN_RETRY_MAX_DELAY_MS = 10000
|
||||
|
||||
export type SocketJoinCommand =
|
||||
| { type: 'cancel-retry' }
|
||||
| { type: 'join'; workflowId: string }
|
||||
| { type: 'leave' }
|
||||
| {
|
||||
type: 'schedule-retry'
|
||||
workflowId: string
|
||||
attempt: number
|
||||
delayMs: number
|
||||
}
|
||||
|
||||
interface SocketJoinSuccessResult {
|
||||
apply: boolean
|
||||
commands: SocketJoinCommand[]
|
||||
ignored: boolean
|
||||
workflowId: string
|
||||
}
|
||||
|
||||
interface SocketJoinErrorResult {
|
||||
apply: boolean
|
||||
commands: SocketJoinCommand[]
|
||||
ignored: boolean
|
||||
retryScheduled: boolean
|
||||
workflowId: string | null
|
||||
}
|
||||
|
||||
interface SocketJoinDeleteResult {
|
||||
commands: SocketJoinCommand[]
|
||||
shouldClearCurrent: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinates desired workflow room membership with async socket join results.
|
||||
*/
|
||||
export class SocketJoinController {
|
||||
private desiredWorkflowId: string | null = null
|
||||
private joinedWorkflowId: string | null = null
|
||||
private pendingJoinWorkflowId: string | null = null
|
||||
private blockedWorkflowId: string | null = null
|
||||
private retryWorkflowId: string | null = null
|
||||
private retryAttempt = 0
|
||||
private isConnected = false
|
||||
|
||||
getJoinedWorkflowId(): string | null {
|
||||
return this.joinedWorkflowId
|
||||
}
|
||||
|
||||
setConnected(connected: boolean): SocketJoinCommand[] {
|
||||
this.isConnected = connected
|
||||
if (!connected) {
|
||||
this.pendingJoinWorkflowId = null
|
||||
this.joinedWorkflowId = null
|
||||
return this.clearRetryCommands()
|
||||
}
|
||||
|
||||
return this.flush()
|
||||
}
|
||||
|
||||
requestWorkflow(workflowId: string | null): SocketJoinCommand[] {
|
||||
const commands = this.takeRetryResetCommands(workflowId)
|
||||
this.desiredWorkflowId = workflowId
|
||||
|
||||
if (workflowId !== this.blockedWorkflowId) {
|
||||
this.blockedWorkflowId = null
|
||||
}
|
||||
|
||||
return [...commands, ...this.flush()]
|
||||
}
|
||||
|
||||
forceRejoinWorkflow(workflowId: string | null): SocketJoinCommand[] {
|
||||
const commands = this.requestWorkflow(workflowId)
|
||||
const alreadyChangingRooms = commands.some(
|
||||
(command) => command.type === 'join' || command.type === 'leave'
|
||||
)
|
||||
|
||||
if (
|
||||
alreadyChangingRooms ||
|
||||
!this.isConnected ||
|
||||
!this.desiredWorkflowId ||
|
||||
this.pendingJoinWorkflowId === this.desiredWorkflowId ||
|
||||
this.blockedWorkflowId === this.desiredWorkflowId
|
||||
) {
|
||||
return commands
|
||||
}
|
||||
|
||||
this.joinedWorkflowId = null
|
||||
|
||||
return [...commands, ...this.flush()]
|
||||
}
|
||||
|
||||
handleWorkflowDeleted(workflowId: string): SocketJoinDeleteResult {
|
||||
const commands = this.takeRetryResetCommands(
|
||||
this.retryWorkflowId === workflowId ? null : this.retryWorkflowId
|
||||
)
|
||||
|
||||
if (this.desiredWorkflowId === workflowId) {
|
||||
this.blockedWorkflowId = workflowId
|
||||
}
|
||||
|
||||
if (this.pendingJoinWorkflowId === workflowId) {
|
||||
this.pendingJoinWorkflowId = null
|
||||
}
|
||||
|
||||
const shouldClearCurrent = this.joinedWorkflowId === workflowId
|
||||
if (shouldClearCurrent) {
|
||||
this.joinedWorkflowId = null
|
||||
}
|
||||
|
||||
return {
|
||||
commands: [...commands, ...this.flush()],
|
||||
shouldClearCurrent,
|
||||
}
|
||||
}
|
||||
|
||||
handleJoinSuccess(workflowId: string): SocketJoinSuccessResult {
|
||||
const commands = this.clearRetryCommands(workflowId)
|
||||
this.pendingJoinWorkflowId = null
|
||||
this.joinedWorkflowId = workflowId
|
||||
|
||||
const apply = this.desiredWorkflowId === workflowId && this.blockedWorkflowId !== workflowId
|
||||
|
||||
return {
|
||||
apply,
|
||||
commands: [...commands, ...this.flush()],
|
||||
ignored: !apply,
|
||||
workflowId,
|
||||
}
|
||||
}
|
||||
|
||||
handleJoinError({
|
||||
workflowId,
|
||||
retryable,
|
||||
}: {
|
||||
workflowId?: string | null
|
||||
retryable?: boolean
|
||||
}): SocketJoinErrorResult {
|
||||
const resolvedWorkflowId = workflowId ?? this.pendingJoinWorkflowId
|
||||
|
||||
if (resolvedWorkflowId && this.pendingJoinWorkflowId === resolvedWorkflowId) {
|
||||
this.pendingJoinWorkflowId = null
|
||||
if (this.joinedWorkflowId === resolvedWorkflowId) {
|
||||
this.joinedWorkflowId = null
|
||||
}
|
||||
}
|
||||
|
||||
const isCurrentDesired =
|
||||
Boolean(resolvedWorkflowId) &&
|
||||
this.desiredWorkflowId === resolvedWorkflowId &&
|
||||
this.blockedWorkflowId !== resolvedWorkflowId
|
||||
|
||||
const baseCommands =
|
||||
resolvedWorkflowId !== null
|
||||
? this.takeRetryResetCommands(resolvedWorkflowId)
|
||||
: this.clearRetryCommands()
|
||||
|
||||
if (!isCurrentDesired) {
|
||||
return {
|
||||
apply: false,
|
||||
commands: [...baseCommands, ...this.flush()],
|
||||
ignored: true,
|
||||
retryScheduled: false,
|
||||
workflowId: resolvedWorkflowId,
|
||||
}
|
||||
}
|
||||
|
||||
if (retryable && resolvedWorkflowId) {
|
||||
const commands = this.scheduleRetry(resolvedWorkflowId)
|
||||
|
||||
return {
|
||||
apply: false,
|
||||
commands: [...baseCommands, ...commands],
|
||||
ignored: false,
|
||||
retryScheduled: true,
|
||||
workflowId: resolvedWorkflowId,
|
||||
}
|
||||
}
|
||||
|
||||
const leaveCommands = this.blockWorkflow(resolvedWorkflowId)
|
||||
|
||||
return {
|
||||
apply: true,
|
||||
commands: [...this.clearRetryCommands(), ...leaveCommands, ...this.flush()],
|
||||
ignored: false,
|
||||
retryScheduled: false,
|
||||
workflowId: resolvedWorkflowId,
|
||||
}
|
||||
}
|
||||
|
||||
retryJoin(workflowId: string): SocketJoinCommand[] {
|
||||
if (
|
||||
this.retryWorkflowId !== workflowId ||
|
||||
this.desiredWorkflowId !== workflowId ||
|
||||
this.blockedWorkflowId === workflowId
|
||||
) {
|
||||
return []
|
||||
}
|
||||
|
||||
return this.flush()
|
||||
}
|
||||
|
||||
private flush(): SocketJoinCommand[] {
|
||||
if (!this.isConnected || this.pendingJoinWorkflowId) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (!this.desiredWorkflowId) {
|
||||
if (!this.joinedWorkflowId) {
|
||||
return []
|
||||
}
|
||||
|
||||
this.joinedWorkflowId = null
|
||||
return [{ type: 'leave' }]
|
||||
}
|
||||
|
||||
if (this.blockedWorkflowId === this.desiredWorkflowId) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (this.joinedWorkflowId === this.desiredWorkflowId) {
|
||||
return []
|
||||
}
|
||||
|
||||
this.pendingJoinWorkflowId = this.desiredWorkflowId
|
||||
|
||||
return [{ type: 'join', workflowId: this.desiredWorkflowId }]
|
||||
}
|
||||
|
||||
private scheduleRetry(workflowId: string): SocketJoinCommand[] {
|
||||
const nextAttempt = this.retryWorkflowId === workflowId ? this.retryAttempt + 1 : 1
|
||||
const delayMs = Math.min(
|
||||
SOCKET_JOIN_RETRY_BASE_DELAY_MS * 2 ** Math.max(0, nextAttempt - 1),
|
||||
SOCKET_JOIN_RETRY_MAX_DELAY_MS
|
||||
)
|
||||
|
||||
this.retryWorkflowId = workflowId
|
||||
this.retryAttempt = nextAttempt
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'schedule-retry',
|
||||
workflowId,
|
||||
attempt: nextAttempt,
|
||||
delayMs,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
private takeRetryResetCommands(nextWorkflowId?: string | null): SocketJoinCommand[] {
|
||||
const shouldClearRetry =
|
||||
this.retryWorkflowId !== null &&
|
||||
(nextWorkflowId === undefined || this.retryWorkflowId !== nextWorkflowId)
|
||||
|
||||
if (!shouldClearRetry) {
|
||||
return []
|
||||
}
|
||||
|
||||
this.retryWorkflowId = null
|
||||
this.retryAttempt = 0
|
||||
|
||||
return [{ type: 'cancel-retry' }]
|
||||
}
|
||||
|
||||
private clearRetryCommands(workflowId?: string): SocketJoinCommand[] {
|
||||
const shouldClear =
|
||||
this.retryWorkflowId !== null &&
|
||||
(workflowId === undefined || this.retryWorkflowId === workflowId)
|
||||
|
||||
if (!shouldClear) {
|
||||
return []
|
||||
}
|
||||
|
||||
this.retryWorkflowId = null
|
||||
this.retryAttempt = 0
|
||||
|
||||
return [{ type: 'cancel-retry' }]
|
||||
}
|
||||
|
||||
private blockWorkflow(workflowId: string | null): SocketJoinCommand[] {
|
||||
if (workflowId) {
|
||||
this.blockedWorkflowId = workflowId
|
||||
}
|
||||
|
||||
if (!this.joinedWorkflowId) {
|
||||
return []
|
||||
}
|
||||
|
||||
this.joinedWorkflowId = null
|
||||
|
||||
return [{ type: 'leave' }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isSocketWorkflowVisible,
|
||||
resolveSocketWorkflowTarget,
|
||||
} from '@/app/workspace/providers/socket-join-target'
|
||||
|
||||
describe('socket join target helpers', () => {
|
||||
it('uses the route workflow when there is no explicit workflow target', () => {
|
||||
expect(
|
||||
resolveSocketWorkflowTarget({
|
||||
routeWorkflowId: 'workflow-route',
|
||||
explicitWorkflowId: null,
|
||||
})
|
||||
).toBe('workflow-route')
|
||||
})
|
||||
|
||||
it('prefers the explicit workflow target for embedded workflows', () => {
|
||||
expect(
|
||||
resolveSocketWorkflowTarget({
|
||||
routeWorkflowId: null,
|
||||
explicitWorkflowId: 'workflow-embedded',
|
||||
})
|
||||
).toBe('workflow-embedded')
|
||||
})
|
||||
|
||||
it('lets an explicit workflow override the route workflow', () => {
|
||||
expect(
|
||||
resolveSocketWorkflowTarget({
|
||||
routeWorkflowId: 'workflow-route',
|
||||
explicitWorkflowId: 'workflow-embedded',
|
||||
})
|
||||
).toBe('workflow-embedded')
|
||||
})
|
||||
|
||||
it('treats the explicit embedded workflow as visible', () => {
|
||||
expect(
|
||||
isSocketWorkflowVisible({
|
||||
workflowId: 'workflow-embedded',
|
||||
routeWorkflowId: null,
|
||||
explicitWorkflowId: 'workflow-embedded',
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects mismatched workflow visibility', () => {
|
||||
expect(
|
||||
isSocketWorkflowVisible({
|
||||
workflowId: 'workflow-other',
|
||||
routeWorkflowId: 'workflow-route',
|
||||
explicitWorkflowId: null,
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
interface ResolveSocketWorkflowTargetArgs {
|
||||
routeWorkflowId?: string | null
|
||||
explicitWorkflowId?: string | null
|
||||
}
|
||||
|
||||
export function resolveSocketWorkflowTarget({
|
||||
routeWorkflowId,
|
||||
explicitWorkflowId,
|
||||
}: ResolveSocketWorkflowTargetArgs): string | null {
|
||||
return explicitWorkflowId ?? routeWorkflowId ?? null
|
||||
}
|
||||
|
||||
interface IsSocketWorkflowVisibleArgs extends ResolveSocketWorkflowTargetArgs {
|
||||
workflowId?: string | null
|
||||
}
|
||||
|
||||
export function isSocketWorkflowVisible({
|
||||
workflowId,
|
||||
routeWorkflowId,
|
||||
explicitWorkflowId,
|
||||
}: IsSocketWorkflowVisibleArgs): boolean {
|
||||
const targetWorkflowId = workflowId ?? null
|
||||
if (!targetWorkflowId) {
|
||||
return false
|
||||
}
|
||||
|
||||
return targetWorkflowId === resolveSocketWorkflowTarget({ routeWorkflowId, explicitWorkflowId })
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user