* feat(frontend): add script recorder for offline replay of script test executions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(frontend): use Video icon for recording instead of Circle Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(frontend): use Disc icon for recording Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(frontend): improve script recorder replay and recording privacy - Record schema at capture time in ScriptRecording (lockfile unavailable for previews) - Read schema from recording instead of job object in replay view - Remove lockfile tab (not available via normal job API for preview jobs) - Use text-xs for code/schema views, remove max-height limits - Disable log download button in replay (endpoint won't work without real job) - Truncate UUIDs in downloaded recordings (last 8 chars) for privacy - Make activeReplay a $state so $derived(isReplay) in FlowStatusViewerInner updates reactively, preventing stale reads that caused API calls during replay - Use JSON round-trip instead of structuredClone to unwrap $state proxies Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
884 lines
22 KiB
Svelte
884 lines
22 KiB
Svelte
<script module lang="ts">
|
|
import pLimit from 'p-limit'
|
|
|
|
const plimit = pLimit(5)
|
|
</script>
|
|
|
|
<script lang="ts">
|
|
import {
|
|
type Job,
|
|
JobService,
|
|
type FlowStatus,
|
|
type Preview,
|
|
type GetJobUpdatesResponse,
|
|
type WorkflowStatus,
|
|
type OpenFlow
|
|
} from '$lib/gen'
|
|
import { workspaceStore } from '$lib/stores'
|
|
import { getContext, onDestroy, tick, untrack } from 'svelte'
|
|
import type { SupportedLanguage } from '$lib/common'
|
|
import { sendUserToast } from '$lib/toast'
|
|
import { DynamicInput, isScriptPreview } from '$lib/utils'
|
|
import {
|
|
getActiveRecording,
|
|
getActiveReplay,
|
|
getReplayStartTime
|
|
} from './recording/flowRecording.svelte'
|
|
|
|
// Will be set to number if job is not a flow
|
|
|
|
export type Callbacks = {
|
|
done?: (x: Job & { result?: any }) => void
|
|
doneResult?: ({ id, result }: { id: string; result: any }) => void
|
|
doneError?: ({ id, error }: { id?: string; error: Error }) => void
|
|
change?: (x: Job) => void
|
|
cancel?: ({ id }: { id: string }) => void
|
|
started?: ({ id }: { id: string }) => void
|
|
running?: ({ id }: { id: string }) => void
|
|
resultStreamUpdate?: ({ id, result_stream }: { id: string; result_stream?: string }) => void
|
|
loadExtraLogs?: ({ id, logs }: { id: string; logs: string }) => void
|
|
}
|
|
|
|
interface Props {
|
|
isLoading?: boolean
|
|
job?: (Job & { result_stream?: string }) | undefined
|
|
noCode?: boolean
|
|
noLogs?: boolean
|
|
workspaceOverride?: string | undefined
|
|
notfound?: boolean
|
|
allowConcurentRequests?: boolean
|
|
jobUpdateLastFetch?: Date | undefined
|
|
toastError?: boolean
|
|
onlyResult?: boolean
|
|
loadPlaceholderJobOnStart?: Job
|
|
// If you want to find out progress of subjobs of a flow, check job.flow_status.progress
|
|
scriptProgress?: number | undefined
|
|
|
|
children?: import('svelte').Snippet<[any]>
|
|
}
|
|
|
|
let {
|
|
isLoading = $bindable(false),
|
|
job = $bindable(undefined),
|
|
noCode = false,
|
|
allowConcurentRequests = false,
|
|
workspaceOverride = undefined,
|
|
notfound = $bindable(false),
|
|
jobUpdateLastFetch = $bindable(undefined),
|
|
toastError = false,
|
|
onlyResult = false,
|
|
loadPlaceholderJobOnStart = undefined,
|
|
scriptProgress = $bindable(undefined),
|
|
noLogs = false,
|
|
children
|
|
}: Props = $props()
|
|
|
|
let workspace = $derived(workspaceOverride ?? $workspaceStore)
|
|
|
|
let syncIteration: number = 0
|
|
let errorIteration = 0
|
|
|
|
let logOffset = 0
|
|
let resultStreamOffset = 0
|
|
let lastCallbacks: Callbacks | undefined = undefined
|
|
|
|
let finished: string[] = []
|
|
let ITERATIONS_BEFORE_SLOW_REFRESH = 10
|
|
let ITERATIONS_BEFORE_SUPER_SLOW_REFRESH = 100
|
|
|
|
let lastStartedAt: number = Date.now()
|
|
let currentId: string | undefined = $state(undefined)
|
|
let noPingTimeout: number | undefined = undefined
|
|
let lastNoLogs = $state(noLogs)
|
|
let lastCompletedJobId = $state<string | undefined>(undefined)
|
|
|
|
let token = getContext<{ token?: string }>('AuthToken')
|
|
|
|
$effect(() => {
|
|
let newIsLoading = currentId !== undefined
|
|
untrack(() => {
|
|
if (isLoading !== newIsLoading) {
|
|
isLoading = newIsLoading
|
|
}
|
|
})
|
|
})
|
|
|
|
const noLogsChangeRestartEvent = 'SSE restart after no logs change'
|
|
$effect(() => {
|
|
if (noLogs != lastNoLogs) {
|
|
lastNoLogs = noLogs
|
|
if (!noLogs) {
|
|
currentEventSource?.onerror?.(new Event(noLogsChangeRestartEvent))
|
|
const lastJobId = lastCompletedJobId
|
|
if (lastJobId && (job || lastCallbacks?.loadExtraLogs)) {
|
|
plimit(() =>
|
|
JobService.getCompletedJobLogsTail({
|
|
workspace: $workspaceStore!,
|
|
id: lastJobId
|
|
})
|
|
).then((res) => {
|
|
if (res && job) {
|
|
job.logs = res
|
|
}
|
|
if (res && lastCallbacks?.loadExtraLogs) {
|
|
lastCallbacks.loadExtraLogs({ id: lastJobId, logs: res })
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
function clearCurrentId() {
|
|
if (currentId) {
|
|
if (allowConcurentRequests) {
|
|
finished.push(currentId)
|
|
} else {
|
|
currentId = undefined
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function abstractRun(fn: () => Promise<string>, callbacks?: Callbacks) {
|
|
try {
|
|
isLoading = true
|
|
scriptProgress = undefined
|
|
lastCompletedJobId = undefined
|
|
clearCurrentJob()
|
|
lastCallbacks = callbacks
|
|
noPingTimeout = undefined
|
|
const startedAt = Date.now()
|
|
const testId = await fn()
|
|
|
|
if (lastStartedAt < startedAt || allowConcurentRequests) {
|
|
lastStartedAt = startedAt
|
|
if (testId) {
|
|
callbacks?.started?.({ id: testId })
|
|
try {
|
|
await watchJob(testId, callbacks)
|
|
} catch {
|
|
if (currentId === testId) {
|
|
clearCurrentId()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return testId
|
|
} catch (err) {
|
|
if (toastError) {
|
|
sendUserToast(err.body, true)
|
|
}
|
|
callbacks?.doneError?.({ error: err })
|
|
// if error happens on submitting the job, reset UI state so the user can try again
|
|
isLoading = false
|
|
currentId = undefined
|
|
job = undefined
|
|
throw err
|
|
}
|
|
}
|
|
|
|
export async function runScriptByPath(
|
|
path: string,
|
|
args: Record<string, any>,
|
|
callbacks?: Callbacks
|
|
): Promise<string> {
|
|
return abstractRun(
|
|
() =>
|
|
JobService.runScriptByPath({
|
|
workspace: $workspaceStore!,
|
|
path: path ?? '',
|
|
requestBody: args,
|
|
skipPreprocessor: true
|
|
}),
|
|
callbacks
|
|
)
|
|
}
|
|
|
|
export async function runScriptByHash(
|
|
hash: string,
|
|
args: Record<string, any>,
|
|
callbacks?: Callbacks
|
|
): Promise<string> {
|
|
return abstractRun(
|
|
() =>
|
|
JobService.runScriptByHash({
|
|
workspace: $workspaceStore!,
|
|
hash: hash ?? '',
|
|
requestBody: args,
|
|
skipPreprocessor: true
|
|
}),
|
|
callbacks
|
|
)
|
|
}
|
|
|
|
export async function runFlowByPath(
|
|
path: string,
|
|
args: Record<string, any>,
|
|
callbacks?: Callbacks
|
|
): Promise<string> {
|
|
return abstractRun(
|
|
() =>
|
|
JobService.runFlowByPath({
|
|
workspace: $workspaceStore!,
|
|
path: path ?? '',
|
|
requestBody: args,
|
|
skipPreprocessor: true
|
|
}),
|
|
callbacks
|
|
)
|
|
}
|
|
|
|
export async function runFlowPreview(
|
|
args: Record<string, any>,
|
|
flow: OpenFlow & { tag?: string },
|
|
callbacks?: Callbacks,
|
|
path?: string
|
|
): Promise<string> {
|
|
return abstractRun(
|
|
() =>
|
|
JobService.runFlowPreview({
|
|
workspace: $workspaceStore!,
|
|
requestBody: {
|
|
args,
|
|
value: flow.value,
|
|
tag: flow.tag,
|
|
path
|
|
}
|
|
}),
|
|
callbacks
|
|
)
|
|
}
|
|
|
|
function refreshLogOffset() {
|
|
if (logOffset == 0) {
|
|
logOffset = job?.logs?.length ? job.logs?.length + 1 : 0
|
|
}
|
|
}
|
|
export async function getLogs() {
|
|
if (job) {
|
|
refreshLogOffset()
|
|
const getUpdate = await JobService.getJobUpdates({
|
|
workspace: workspace!,
|
|
id: job.id,
|
|
running: `running` in job && job.running,
|
|
logOffset: logOffset
|
|
})
|
|
|
|
if ((job?.logs ?? '').length == 0) {
|
|
job.logs = getUpdate.new_logs ?? ''
|
|
logOffset = getUpdate.log_offset ?? 0
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function runDynamicInputScript(
|
|
entrypoint_function: string,
|
|
runnable_ref: DynamicInput.HelperScript,
|
|
args: Record<string, any>,
|
|
callbacks?: Callbacks
|
|
): Promise<string> {
|
|
return abstractRun(
|
|
() =>
|
|
JobService.runDynamicSelect({
|
|
workspace: $workspaceStore!,
|
|
requestBody: { entrypoint_function, args, runnable_ref }
|
|
}),
|
|
callbacks
|
|
)
|
|
}
|
|
|
|
export async function runPreview(
|
|
path: string | undefined,
|
|
code: string,
|
|
lang: SupportedLanguage | undefined,
|
|
args: Record<string, any>,
|
|
tag: string | undefined,
|
|
lock?: string,
|
|
hash?: string,
|
|
callbacks?: Callbacks,
|
|
flowPath?: string
|
|
): Promise<string> {
|
|
return abstractRun(
|
|
() =>
|
|
JobService.runScriptPreview({
|
|
workspace: $workspaceStore!,
|
|
requestBody: {
|
|
path,
|
|
content: code,
|
|
args,
|
|
language: lang as Preview['language'],
|
|
tag,
|
|
lock,
|
|
script_hash: hash,
|
|
flow_path: flowPath
|
|
}
|
|
}),
|
|
callbacks
|
|
)
|
|
}
|
|
|
|
export async function cancelJob() {
|
|
const id = currentId
|
|
if (id) {
|
|
lastCallbacks?.cancel?.({ id })
|
|
clearCurrentId()
|
|
// Clean up SSE connection
|
|
currentEventSource?.close()
|
|
currentEventSource = undefined
|
|
try {
|
|
await JobService.cancelQueuedJob({
|
|
workspace: $workspaceStore ?? '',
|
|
id,
|
|
requestBody: {}
|
|
})
|
|
} catch (err) {
|
|
console.error(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function clearCurrentJob() {
|
|
if (currentId && !allowConcurentRequests) {
|
|
job = undefined
|
|
lastCallbacks?.cancel?.({ id: currentId })
|
|
await cancelJob()
|
|
}
|
|
}
|
|
|
|
function supportsSSE() {
|
|
const sseSupported = typeof EventSource !== 'undefined'
|
|
if (!sseSupported) {
|
|
console.error('SSE not supported, falling back to polling')
|
|
}
|
|
return sseSupported
|
|
}
|
|
|
|
let replayTimeouts: ReturnType<typeof setTimeout>[] = []
|
|
|
|
let startedWatchingJob: number | undefined = undefined
|
|
export async function watchJob(testId: string, callbacks?: Callbacks) {
|
|
logOffset = 0
|
|
resultStreamOffset = 0
|
|
syncIteration = 0
|
|
errorIteration = 0
|
|
currentId = testId
|
|
scriptProgress = undefined
|
|
|
|
// Replay mode: feed recorded events instead of real SSE
|
|
const replay = getActiveReplay()
|
|
if (replay) {
|
|
const recorded = replay.jobs[testId]
|
|
if (recorded) {
|
|
job = JSON.parse(JSON.stringify(recorded.initial_job))
|
|
callbacks?.change?.(job!)
|
|
// Compute delays relative to replay start so sub-jobs (discovered
|
|
// later by FlowStatusViewerInner) stay in sync with the root job.
|
|
const elapsed = Date.now() - getReplayStartTime()
|
|
for (const event of recorded.events) {
|
|
const delay = Math.max(0, event.t - elapsed)
|
|
const timeout = setTimeout(() => {
|
|
if (job) {
|
|
updateJobFromProgress(event.data, job, callbacks)
|
|
}
|
|
if (event.data.completed) {
|
|
const njob = (event.data as any).job as Job & { result_stream?: string }
|
|
if (njob) {
|
|
// Use whichever logs are more complete (longer)
|
|
const streamedLogs = job?.logs ?? ''
|
|
const completedLogs = njob.logs ?? ''
|
|
njob.logs =
|
|
streamedLogs.length >= completedLogs.length ? streamedLogs : completedLogs
|
|
const streamedResult = job?.result_stream ?? ''
|
|
const completedResult = njob.result_stream ?? ''
|
|
njob.result_stream =
|
|
streamedResult.length >= completedResult.length ? streamedResult : completedResult
|
|
job = njob
|
|
onJobCompleted(testId, job, callbacks)
|
|
}
|
|
}
|
|
}, delay)
|
|
replayTimeouts.push(timeout)
|
|
}
|
|
return
|
|
}
|
|
// Job not in recording — stub it to prevent API calls
|
|
const stubJob = { id: testId, type: 'CompletedJob', success: true } as Job
|
|
job = stubJob
|
|
callbacks?.change?.(stubJob)
|
|
clearCurrentId()
|
|
return
|
|
}
|
|
|
|
if (loadPlaceholderJobOnStart) {
|
|
job = structuredClone(loadPlaceholderJobOnStart)
|
|
} else {
|
|
job = undefined
|
|
}
|
|
startedWatchingJob = Date.now()
|
|
|
|
// Clean up any existing SSE connection
|
|
currentEventSource?.close()
|
|
currentEventSource = undefined
|
|
lastCallbacks = callbacks
|
|
// Try SSE first, fall back to polling if needed
|
|
if (supportsSSE()) {
|
|
await loadTestJobWithSSE(testId, 0, callbacks)
|
|
} else {
|
|
syncer(testId, callbacks)
|
|
}
|
|
}
|
|
|
|
const clamp = (num: number, min: number, max: number) => Math.min(Math.max(num, min), max)
|
|
|
|
function updateJobFromProgress(
|
|
previewJobUpdates: GetJobUpdatesResponse,
|
|
job: Job & { result_stream?: string },
|
|
callbacks: Callbacks | undefined
|
|
) {
|
|
// Clamp number between two values with the following line:
|
|
if (previewJobUpdates.running) {
|
|
if (job && job.type == 'QueuedJob' && !job.running) {
|
|
callbacks?.running?.({ id: job.id })
|
|
job.running = true
|
|
}
|
|
}
|
|
if (previewJobUpdates.progress) {
|
|
console.log('progress', previewJobUpdates.progress)
|
|
// Progress cannot go back and cannot be set to 100
|
|
scriptProgress = clamp(previewJobUpdates.progress, scriptProgress ?? 0, 99)
|
|
}
|
|
|
|
if (previewJobUpdates.new_logs) {
|
|
if (logOffset == 0) {
|
|
job.logs = previewJobUpdates.new_logs ?? ''
|
|
} else {
|
|
job.logs = (job?.logs ?? '').concat(previewJobUpdates.new_logs)
|
|
}
|
|
}
|
|
|
|
if (previewJobUpdates.new_result_stream) {
|
|
if (!job.result_stream) {
|
|
job.result_stream = previewJobUpdates.new_result_stream
|
|
} else {
|
|
job.result_stream = job.result_stream.concat(previewJobUpdates.new_result_stream)
|
|
}
|
|
callbacks?.resultStreamUpdate?.({
|
|
id: job.id,
|
|
result_stream: job.result_stream
|
|
})
|
|
}
|
|
|
|
if (previewJobUpdates.log_offset) {
|
|
logOffset = previewJobUpdates.log_offset ?? 0
|
|
}
|
|
|
|
if (previewJobUpdates.stream_offset) {
|
|
resultStreamOffset = previewJobUpdates.stream_offset ?? 0
|
|
}
|
|
|
|
if (previewJobUpdates.flow_status) {
|
|
job.flow_status = previewJobUpdates.flow_status as FlowStatus
|
|
}
|
|
if (previewJobUpdates.workflow_as_code_status) {
|
|
job.workflow_as_code_status = previewJobUpdates.workflow_as_code_status as WorkflowStatus
|
|
}
|
|
if (previewJobUpdates.mem_peak && job) {
|
|
job.mem_peak = previewJobUpdates.mem_peak
|
|
}
|
|
|
|
if (
|
|
job &&
|
|
(previewJobUpdates.running ||
|
|
previewJobUpdates.progress ||
|
|
previewJobUpdates.log_offset ||
|
|
previewJobUpdates.flow_status ||
|
|
previewJobUpdates.mem_peak ||
|
|
previewJobUpdates.stream_offset)
|
|
) {
|
|
callbacks?.change?.(job)
|
|
}
|
|
}
|
|
async function loadTestJob(id: string, callbacks?: Callbacks): Promise<boolean> {
|
|
let isCompleted = false
|
|
if (isCurrentJob(id)) {
|
|
try {
|
|
if (job && `running` in job) {
|
|
callbacks?.running?.({ id })
|
|
|
|
refreshLogOffset()
|
|
|
|
let previewJobUpdates = await JobService.getJobUpdates({
|
|
workspace: workspace!,
|
|
id,
|
|
running: job.running,
|
|
logOffset: logOffset,
|
|
streamOffset: resultStreamOffset,
|
|
getProgress: false
|
|
})
|
|
|
|
if ((previewJobUpdates.running ?? false) || (previewJobUpdates.completed ?? false)) {
|
|
job = await JobService.getJob({
|
|
workspace: workspace!,
|
|
id,
|
|
noCode,
|
|
noLogs: onlyResult || noLogs
|
|
})
|
|
callbacks?.change?.(job)
|
|
}
|
|
|
|
updateJobFromProgress(previewJobUpdates, job, callbacks)
|
|
} else {
|
|
job = await JobService.getJob({
|
|
workspace: workspace!,
|
|
id,
|
|
noLogs: onlyResult || noLogs,
|
|
noCode
|
|
})
|
|
}
|
|
jobUpdateLastFetch = new Date()
|
|
|
|
if (job?.type === 'CompletedJob') {
|
|
//only CompletedJob has success property
|
|
isCompleted = true
|
|
if (onlyResult) {
|
|
callbacks?.doneResult?.({
|
|
id,
|
|
result: job?.result
|
|
})
|
|
clearCurrentId()
|
|
} else {
|
|
onJobCompleted(id, job, callbacks)
|
|
}
|
|
}
|
|
notfound = false
|
|
} catch (err) {
|
|
errorIteration += 1
|
|
if (errorIteration == 5) {
|
|
notfound = true
|
|
job = undefined
|
|
clearCurrentId()
|
|
}
|
|
callbacks?.doneError?.({ error: err, id })
|
|
console.warn(err)
|
|
}
|
|
return isCompleted
|
|
} else {
|
|
return true
|
|
}
|
|
}
|
|
|
|
let currentEventSource: EventSource | undefined = undefined
|
|
|
|
async function onJobCompleted(
|
|
id: string,
|
|
job: Job & { result?: any; success?: boolean },
|
|
callbacks?: Callbacks
|
|
) {
|
|
if (isCurrentJob(id)) {
|
|
await tick()
|
|
if (
|
|
callbacks?.doneError &&
|
|
!job?.success &&
|
|
typeof job?.result == 'object' &&
|
|
'error' in (job?.result ?? {})
|
|
) {
|
|
callbacks?.doneError?.({
|
|
id,
|
|
error: job.result.error
|
|
})
|
|
} else {
|
|
callbacks?.done?.(job)
|
|
}
|
|
callbacks?.change?.(job)
|
|
|
|
clearCurrentId()
|
|
lastCompletedJobId = id
|
|
}
|
|
}
|
|
|
|
function setNoPingTimeout(id: string, attempt: number, callbacks?: Callbacks) {
|
|
if (noPingTimeout) {
|
|
clearTimeout(noPingTimeout)
|
|
}
|
|
if (isCurrentJob(id)) {
|
|
noPingTimeout = setTimeout(() => {
|
|
if (isCurrentJob(id)) {
|
|
currentEventSource?.close()
|
|
currentEventSource = undefined
|
|
loadTestJobWithSSE(id, attempt + 1, callbacks)
|
|
}
|
|
}, 10000)
|
|
}
|
|
}
|
|
|
|
function isCurrentJob(id: string) {
|
|
return currentId === id || (allowConcurentRequests && !finished.includes(id))
|
|
}
|
|
|
|
async function loadTestJobWithSSE(
|
|
id: string,
|
|
attempt: number,
|
|
callbacks?: Callbacks
|
|
): Promise<boolean> {
|
|
let isCompleted = false
|
|
let resultOnlyResultStream: string = ''
|
|
if (isCurrentJob(id)) {
|
|
try {
|
|
// First load the job to get initial state
|
|
if ((!job || job.id == '') && !onlyResult) {
|
|
job = await JobService.getJob({
|
|
workspace: workspace!,
|
|
id,
|
|
noLogs: noLogs,
|
|
noCode
|
|
})
|
|
|
|
callbacks?.change?.(job)
|
|
getActiveRecording()?.recordInitialJob(id, job)
|
|
}
|
|
|
|
if (!onlyResult) {
|
|
callbacks?.resultStreamUpdate?.({
|
|
id,
|
|
result_stream: undefined
|
|
})
|
|
}
|
|
|
|
// If job is already completed, don't start SSE
|
|
if (job?.type === 'CompletedJob') {
|
|
isCompleted = true
|
|
if (isCurrentJob(id)) {
|
|
onJobCompleted(id, job, callbacks)
|
|
}
|
|
return isCompleted
|
|
} else if (onlyResult || (job?.type === 'QueuedJob' && !currentEventSource)) {
|
|
if (job?.type === 'QueuedJob' && job?.running) {
|
|
callbacks?.running?.({ id })
|
|
if (!job.running) {
|
|
job.running = true
|
|
}
|
|
}
|
|
|
|
let getProgress: boolean | undefined =
|
|
onlyResult || !job
|
|
? undefined
|
|
: job.job_kind == 'script' || isScriptPreview(job.job_kind)
|
|
|
|
refreshLogOffset()
|
|
// Build SSE URL with query parameters
|
|
const params = new URLSearchParams({
|
|
log_offset: logOffset.toString()
|
|
})
|
|
if (job && job.type === 'QueuedJob') {
|
|
params.set('running', job.running.toString())
|
|
} else if (onlyResult && callbacks?.running) {
|
|
params.set('running', 'false')
|
|
}
|
|
|
|
if (getProgress !== undefined) {
|
|
params.set('get_progress', getProgress.toString())
|
|
}
|
|
if (onlyResult) {
|
|
params.set('only_result', 'true')
|
|
}
|
|
|
|
if (startedWatchingJob && startedWatchingJob > Date.now() - 5000) {
|
|
params.set('fast', 'true')
|
|
}
|
|
if (noLogs) {
|
|
params.set('no_logs', 'true')
|
|
}
|
|
if (resultStreamOffset) {
|
|
params.set('stream_offset', resultStreamOffset.toString())
|
|
}
|
|
if (job) {
|
|
params.set(
|
|
'is_flow',
|
|
(job.job_kind === 'flow' || job.job_kind === 'flowpreview').toString()
|
|
)
|
|
}
|
|
|
|
if (token?.token && token.token != '') {
|
|
params.set('token', token.token)
|
|
}
|
|
|
|
const sseUrl = `/api/w/${workspace}/jobs_u/getupdate_sse/${id}?${params.toString()}`
|
|
|
|
currentEventSource = new EventSource(sseUrl)
|
|
|
|
setNoPingTimeout(id, attempt, callbacks)
|
|
currentEventSource.onmessage = async (event) => {
|
|
if (!isCurrentJob(id)) {
|
|
currentEventSource?.close()
|
|
currentEventSource = undefined
|
|
return
|
|
}
|
|
|
|
try {
|
|
const previewJobUpdates = JSON.parse(event.data)
|
|
let type = previewJobUpdates.type
|
|
if (type == 'timeout') {
|
|
currentEventSource?.close()
|
|
currentEventSource = undefined
|
|
loadTestJobWithSSE(id, 0, callbacks)
|
|
return
|
|
} else if (type == 'ping') {
|
|
setNoPingTimeout(id, attempt, callbacks)
|
|
return
|
|
} else if (type == 'error') {
|
|
currentEventSource?.close()
|
|
currentEventSource = undefined
|
|
console.error('SSE error:', previewJobUpdates)
|
|
throw new Error('SSE error: ' + previewJobUpdates)
|
|
} else if (type == 'not_found') {
|
|
currentEventSource?.close()
|
|
currentEventSource = undefined
|
|
console.error('Not found')
|
|
throw new Error('Not found')
|
|
}
|
|
jobUpdateLastFetch = new Date()
|
|
getActiveRecording()?.recordEvent(id, previewJobUpdates)
|
|
|
|
if (job) {
|
|
updateJobFromProgress(previewJobUpdates, job, callbacks)
|
|
} else if (onlyResult && callbacks?.running && previewJobUpdates.running) {
|
|
callbacks?.running?.({ id })
|
|
}
|
|
|
|
if (onlyResult && previewJobUpdates.new_result_stream) {
|
|
resultOnlyResultStream = resultOnlyResultStream.concat(
|
|
previewJobUpdates.new_result_stream
|
|
)
|
|
// console.log('resultOnlyResultStream', resultOnlyResultStream)
|
|
callbacks?.resultStreamUpdate?.({
|
|
id,
|
|
result_stream: resultOnlyResultStream
|
|
})
|
|
}
|
|
|
|
// Check if job is completed
|
|
if (previewJobUpdates.completed) {
|
|
currentEventSource?.close()
|
|
currentEventSource = undefined
|
|
if (noPingTimeout) {
|
|
clearTimeout(noPingTimeout)
|
|
noPingTimeout = undefined
|
|
}
|
|
isCompleted = true
|
|
if (onlyResult) {
|
|
callbacks?.doneResult?.({
|
|
id,
|
|
result: previewJobUpdates?.only_result
|
|
})
|
|
clearCurrentId()
|
|
} else {
|
|
const njob = previewJobUpdates.job as Job & { result_stream?: string }
|
|
njob.logs = job?.logs ?? ''
|
|
njob.result_stream = job?.result_stream ?? ''
|
|
job = njob
|
|
onJobCompleted(id, job, callbacks)
|
|
}
|
|
}
|
|
} catch (parseErr) {
|
|
console.warn('Failed to parse SSE data:', parseErr)
|
|
}
|
|
}
|
|
|
|
currentEventSource.onerror = (error) => {
|
|
console.warn('SSE error:', error)
|
|
currentEventSource?.close()
|
|
currentEventSource = undefined
|
|
let delay = 1000
|
|
let isNoLogsChange = error.type == noLogsChangeRestartEvent
|
|
if (isNoLogsChange) {
|
|
delay = 0
|
|
}
|
|
if (attempt < 3 || isNoLogsChange) {
|
|
if (!isNoLogsChange) {
|
|
console.log(`SSE error (1), retrying ... attempt: ${attempt + 1}/3`)
|
|
}
|
|
setTimeout(() => loadTestJobWithSSE(id, attempt + 1, callbacks), delay)
|
|
} else {
|
|
// Fall back to polling on error
|
|
setTimeout(() => syncer(id, callbacks), 1000)
|
|
}
|
|
}
|
|
|
|
currentEventSource.onopen = () => {
|
|
console.log('SSE connection opened for job:', id)
|
|
}
|
|
}
|
|
|
|
notfound = false
|
|
} catch (err) {
|
|
errorIteration += 1
|
|
if (errorIteration == 5) {
|
|
notfound = true
|
|
job = undefined
|
|
}
|
|
console.warn(err)
|
|
// Fall back to polling on error
|
|
currentEventSource?.close()
|
|
currentEventSource = undefined
|
|
|
|
if (attempt < 3) {
|
|
console.log(`SSE error (2), retrying ... attempt: ${attempt}/3`)
|
|
attempt++
|
|
loadTestJobWithSSE(id, attempt, callbacks)
|
|
} else {
|
|
// Fall back to polling on error
|
|
setTimeout(() => syncer(id, callbacks), 1000)
|
|
}
|
|
}
|
|
return isCompleted
|
|
} else {
|
|
// Clean up SSE connection if current ID changed
|
|
currentEventSource?.close()
|
|
currentEventSource = undefined
|
|
return true
|
|
}
|
|
}
|
|
|
|
async function syncer(id: string, callbacks?: Callbacks): Promise<void> {
|
|
if (!isCurrentJob(id)) {
|
|
callbacks?.cancel?.({ id })
|
|
return
|
|
}
|
|
syncIteration++
|
|
if (await loadTestJob(id, callbacks)) {
|
|
return
|
|
}
|
|
let nextIteration = 50
|
|
if (syncIteration > ITERATIONS_BEFORE_SLOW_REFRESH) {
|
|
nextIteration = 500
|
|
} else if (syncIteration > ITERATIONS_BEFORE_SUPER_SLOW_REFRESH) {
|
|
nextIteration = 2000
|
|
}
|
|
setTimeout(() => syncer(id, callbacks), nextIteration)
|
|
}
|
|
|
|
onDestroy(async () => {
|
|
clearCurrentId()
|
|
currentEventSource?.close()
|
|
currentEventSource = undefined
|
|
replayTimeouts.forEach(clearTimeout)
|
|
replayTimeouts = []
|
|
})
|
|
</script>
|
|
|
|
{@render children?.({
|
|
job,
|
|
isLoading,
|
|
workspaceOverride,
|
|
notfound,
|
|
abstractRun,
|
|
runScriptByPath,
|
|
runFlowByPath,
|
|
runPreview,
|
|
cancelJob,
|
|
clearCurrentJob,
|
|
watchJob,
|
|
loadTestJob,
|
|
syncer
|
|
})}
|