const { getDb } = require('../db/database'); const logger = require('./logger').child('SCRIPT_CHAINS'); const runtimeActivityService = require('./runtimeActivityService'); const { spawnTrackedProcess } = require('./processRunner'); const { errorToMeta } = require('../utils/errorMeta'); const CHAIN_NAME_MAX_LENGTH = 120; const STEP_TYPE_SCRIPT = 'script'; const STEP_TYPE_WAIT = 'wait'; const VALID_STEP_TYPES = new Set([STEP_TYPE_SCRIPT, STEP_TYPE_WAIT]); function normalizeChainId(rawValue) { const value = Number(rawValue); if (!Number.isFinite(value) || value <= 0) { return null; } return Math.trunc(value); } function createValidationError(message, details = null) { const error = new Error(message); error.statusCode = 400; if (details) { error.details = details; } return error; } function mapChainRow(row, steps = []) { if (!row) { return null; } return { id: Number(row.id), name: String(row.name || ''), orderIndex: Number(row.order_index || 0), steps: steps.map(mapStepRow), createdAt: row.created_at, updatedAt: row.updated_at }; } function mapStepRow(row) { if (!row) { return null; } return { id: Number(row.id), position: Number(row.position), stepType: String(row.step_type || ''), scriptId: row.script_id != null ? Number(row.script_id) : null, scriptName: row.script_name != null ? String(row.script_name) : null, waitSeconds: row.wait_seconds != null ? Number(row.wait_seconds) : null }; } function terminateChildProcess(child, { immediate = false } = {}) { if (!child) { return; } const signal = immediate ? 'SIGKILL' : 'SIGTERM'; const pid = Number(child.pid); if (Number.isFinite(pid) && pid > 0) { try { // For detached children this targets the full process group. process.kill(-pid, signal); return; } catch (_error) { // Fall through to direct child signal. } } try { child.kill(signal); } catch (_error) { return; } } function appendTailText(currentValue, nextChunk, maxChars = 12000) { const chunk = String(nextChunk || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n'); if (!chunk) { return { value: currentValue || '', truncated: false }; } const normalizedChunk = chunk.endsWith('\n') ? chunk : `${chunk}\n`; const combined = `${String(currentValue || '')}${normalizedChunk}`; if (combined.length <= maxChars) { return { value: combined, truncated: false }; } return { value: combined.slice(-maxChars), truncated: true }; } function validateSteps(rawSteps) { const steps = Array.isArray(rawSteps) ? rawSteps : []; const errors = []; const normalized = []; for (let i = 0; i < steps.length; i++) { const step = steps[i] && typeof steps[i] === 'object' ? steps[i] : {}; const stepType = String(step.stepType || step.step_type || '').trim(); if (!VALID_STEP_TYPES.has(stepType)) { errors.push({ field: `steps[${i}].stepType`, message: `Ungültiger Schritt-Typ: '${stepType}'. Erlaubt: script, wait.` }); continue; } if (stepType === STEP_TYPE_SCRIPT) { const scriptId = Number(step.scriptId ?? step.script_id); if (!Number.isFinite(scriptId) || scriptId <= 0) { errors.push({ field: `steps[${i}].scriptId`, message: 'scriptId fehlt oder ist ungültig.' }); continue; } normalized.push({ stepType, scriptId: Math.trunc(scriptId), waitSeconds: null }); } else if (stepType === STEP_TYPE_WAIT) { const waitSeconds = Number(step.waitSeconds ?? step.wait_seconds); if (!Number.isFinite(waitSeconds) || waitSeconds < 1 || waitSeconds > 3600) { errors.push({ field: `steps[${i}].waitSeconds`, message: 'waitSeconds muss zwischen 1 und 3600 liegen.' }); continue; } normalized.push({ stepType, scriptId: null, waitSeconds: Math.round(waitSeconds) }); } } if (errors.length > 0) { throw createValidationError('Ungültige Schritte in der Skriptkette.', errors); } return normalized; } async function getStepsForChain(db, chainId) { return db.all( ` SELECT s.id, s.chain_id, s.position, s.step_type, s.script_id, s.wait_seconds, sc.name AS script_name FROM script_chain_steps s LEFT JOIN scripts sc ON sc.id = s.script_id WHERE s.chain_id = ? ORDER BY s.position ASC, s.id ASC `, [chainId] ); } class ScriptChainService { async listChains() { const db = await getDb(); const rows = await db.all( ` SELECT id, name, order_index, created_at, updated_at FROM script_chains ORDER BY order_index ASC, id ASC ` ); if (rows.length === 0) { return []; } const chainIds = rows.map((row) => Number(row.id)); const placeholders = chainIds.map(() => '?').join(', '); const stepRows = await db.all( ` SELECT s.id, s.chain_id, s.position, s.step_type, s.script_id, s.wait_seconds, sc.name AS script_name FROM script_chain_steps s LEFT JOIN scripts sc ON sc.id = s.script_id WHERE s.chain_id IN (${placeholders}) ORDER BY s.chain_id ASC, s.position ASC, s.id ASC `, chainIds ); const stepsByChain = new Map(); for (const step of stepRows) { const cid = Number(step.chain_id); if (!stepsByChain.has(cid)) { stepsByChain.set(cid, []); } stepsByChain.get(cid).push(step); } return rows.map((row) => mapChainRow(row, stepsByChain.get(Number(row.id)) || [])); } async getChainById(chainId) { const normalizedId = normalizeChainId(chainId); if (!normalizedId) { throw createValidationError('Ungültige chainId.'); } const db = await getDb(); const row = await db.get( `SELECT id, name, order_index, created_at, updated_at FROM script_chains WHERE id = ?`, [normalizedId] ); if (!row) { const error = new Error(`Skriptkette #${normalizedId} wurde nicht gefunden.`); error.statusCode = 404; throw error; } const steps = await getStepsForChain(db, normalizedId); return mapChainRow(row, steps); } async getChainsByIds(rawIds = []) { const ids = Array.isArray(rawIds) ? rawIds.map(normalizeChainId).filter(Boolean) : []; if (ids.length === 0) { return []; } const db = await getDb(); const placeholders = ids.map(() => '?').join(', '); const rows = await db.all( `SELECT id, name, order_index, created_at, updated_at FROM script_chains WHERE id IN (${placeholders})`, ids ); const stepRows = await db.all( ` SELECT s.id, s.chain_id, s.position, s.step_type, s.script_id, s.wait_seconds, sc.name AS script_name FROM script_chain_steps s LEFT JOIN scripts sc ON sc.id = s.script_id WHERE s.chain_id IN (${placeholders}) ORDER BY s.chain_id ASC, s.position ASC, s.id ASC `, ids ); const stepsByChain = new Map(); for (const step of stepRows) { const cid = Number(step.chain_id); if (!stepsByChain.has(cid)) { stepsByChain.set(cid, []); } stepsByChain.get(cid).push(step); } const byId = new Map(rows.map((row) => [ Number(row.id), mapChainRow(row, stepsByChain.get(Number(row.id)) || []) ])); return ids.map((id) => byId.get(id)).filter(Boolean); } async createChain(payload = {}) { const body = payload && typeof payload === 'object' ? payload : {}; const name = String(body.name || '').trim(); if (!name) { throw createValidationError('Skriptkettenname darf nicht leer sein.', [{ field: 'name', message: 'Name darf nicht leer sein.' }]); } if (name.length > CHAIN_NAME_MAX_LENGTH) { throw createValidationError('Skriptkettenname zu lang.', [{ field: 'name', message: `Maximal ${CHAIN_NAME_MAX_LENGTH} Zeichen.` }]); } const steps = validateSteps(body.steps); const db = await getDb(); try { const nextOrderIndex = await this._getNextOrderIndex(db); const result = await db.run( ` INSERT INTO script_chains (name, order_index, created_at, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) `, [name, nextOrderIndex] ); const chainId = result.lastID; await this._saveSteps(db, chainId, steps); return this.getChainById(chainId); } catch (error) { if (String(error?.message || '').includes('UNIQUE constraint failed')) { throw createValidationError(`Skriptkettenname "${name}" existiert bereits.`, [{ field: 'name', message: 'Name muss eindeutig sein.' }]); } throw error; } } async updateChain(chainId, payload = {}) { const normalizedId = normalizeChainId(chainId); if (!normalizedId) { throw createValidationError('Ungültige chainId.'); } const body = payload && typeof payload === 'object' ? payload : {}; const name = String(body.name || '').trim(); if (!name) { throw createValidationError('Skriptkettenname darf nicht leer sein.', [{ field: 'name', message: 'Name darf nicht leer sein.' }]); } if (name.length > CHAIN_NAME_MAX_LENGTH) { throw createValidationError('Skriptkettenname zu lang.', [{ field: 'name', message: `Maximal ${CHAIN_NAME_MAX_LENGTH} Zeichen.` }]); } const steps = validateSteps(body.steps); await this.getChainById(normalizedId); const db = await getDb(); try { await db.run( `UPDATE script_chains SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [name, normalizedId] ); await db.run(`DELETE FROM script_chain_steps WHERE chain_id = ?`, [normalizedId]); await this._saveSteps(db, normalizedId, steps); return this.getChainById(normalizedId); } catch (error) { if (String(error?.message || '').includes('UNIQUE constraint failed')) { throw createValidationError(`Skriptkettenname "${name}" existiert bereits.`, [{ field: 'name', message: 'Name muss eindeutig sein.' }]); } throw error; } } async deleteChain(chainId) { const normalizedId = normalizeChainId(chainId); if (!normalizedId) { throw createValidationError('Ungültige chainId.'); } const existing = await this.getChainById(normalizedId); const db = await getDb(); await db.run(`DELETE FROM script_chains WHERE id = ?`, [normalizedId]); return existing; } async reorderChains(orderedIds = []) { const providedIds = Array.isArray(orderedIds) ? orderedIds.map(normalizeChainId).filter(Boolean) : []; const db = await getDb(); const rows = await db.all( ` SELECT id FROM script_chains ORDER BY order_index ASC, id ASC ` ); if (rows.length === 0) { return []; } const existingIds = rows.map((row) => Number(row.id)).filter((id) => Number.isFinite(id) && id > 0); const existingSet = new Set(existingIds); const used = new Set(); const nextOrder = []; for (const id of providedIds) { if (!existingSet.has(id) || used.has(id)) { continue; } used.add(id); nextOrder.push(id); } for (const id of existingIds) { if (used.has(id)) { continue; } used.add(id); nextOrder.push(id); } await db.exec('BEGIN'); try { for (let i = 0; i < nextOrder.length; i += 1) { await db.run( ` UPDATE script_chains SET order_index = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? `, [i + 1, nextOrder[i]] ); } await db.exec('COMMIT'); } catch (error) { await db.exec('ROLLBACK'); throw error; } return this.listChains(); } async _getNextOrderIndex(db) { const row = await db.get( ` SELECT COALESCE(MAX(order_index), 0) AS max_order_index FROM script_chains ` ); const maxOrder = Number(row?.max_order_index || 0); if (!Number.isFinite(maxOrder) || maxOrder < 0) { return 1; } return Math.trunc(maxOrder) + 1; } async _saveSteps(db, chainId, steps) { for (let i = 0; i < steps.length; i++) { const step = steps[i]; await db.run( ` INSERT INTO script_chain_steps (chain_id, position, step_type, script_id, wait_seconds, created_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `, [chainId, i + 1, step.stepType, step.scriptId ?? null, step.waitSeconds ?? null] ); } } async executeChain(chainId, context = {}, { appendLog = null } = {}) { const chain = await this.getChainById(chainId); logger.info('chain:execute:start', { chainId, chainName: chain.name, steps: chain.steps.length }); const totalSteps = chain.steps.length; const activityId = runtimeActivityService.startActivity('chain', { name: chain.name, source: context?.source || 'chain', chainId: chain.id, jobId: context?.jobId || null, cronJobId: context?.cronJobId || null, parentActivityId: context?.runtimeParentActivityId || null, currentStep: totalSteps > 0 ? `Schritt 1/${totalSteps}` : 'Keine Schritte' }); const controlState = { cancelRequested: false, cancelReason: null, currentStepType: null, activeWaitResolve: null, activeChild: null, activeChildTermination: null }; const emitRuntimeStep = (payload = {}) => { if (typeof context?.onRuntimeStep !== 'function') { return; } try { context.onRuntimeStep({ chainId: chain.id, chainName: chain.name, ...payload }); } catch (_error) { // ignore runtime callback errors } }; const requestCancel = async (payload = {}) => { if (controlState.cancelRequested) { return { accepted: true, alreadyRequested: true, message: 'Abbruch bereits angefordert.' }; } controlState.cancelRequested = true; controlState.cancelReason = String(payload?.reason || '').trim() || 'Von Benutzer abgebrochen'; runtimeActivityService.updateActivity(activityId, { message: 'Abbruch angefordert', currentStep: controlState.currentStepType ? `Abbruch läuft (${controlState.currentStepType})` : 'Abbruch angefordert' }); if (typeof appendLog === 'function') { try { await appendLog('SYSTEM', `Kette "${chain.name}" - Abbruch angefordert.`); } catch (_error) { // ignore appendLog failures for control actions } } if (controlState.currentStepType === STEP_TYPE_WAIT && typeof controlState.activeWaitResolve === 'function') { controlState.activeWaitResolve('cancel'); } else if (controlState.currentStepType === STEP_TYPE_SCRIPT && controlState.activeChild) { controlState.activeChildTermination = 'cancel'; terminateChildProcess(controlState.activeChild, { immediate: true }); } return { accepted: true, message: 'Abbruch angefordert.' }; }; const requestNextStep = async () => { if (controlState.cancelRequested) { return { accepted: false, message: 'Kette wird bereits abgebrochen.' }; } if (controlState.currentStepType === STEP_TYPE_WAIT && typeof controlState.activeWaitResolve === 'function') { controlState.activeWaitResolve('skip'); runtimeActivityService.updateActivity(activityId, { message: 'Nächster Schritt angefordert (Wait übersprungen)' }); if (typeof appendLog === 'function') { try { await appendLog('SYSTEM', `Kette "${chain.name}" - Wait-Schritt manuell übersprungen.`); } catch (_error) { // ignore appendLog failures for control actions } } return { accepted: true, message: 'Wait-Schritt übersprungen.' }; } if (controlState.currentStepType === STEP_TYPE_SCRIPT && controlState.activeChild) { controlState.activeChildTermination = 'skip'; terminateChildProcess(controlState.activeChild, { immediate: true }); runtimeActivityService.updateActivity(activityId, { message: 'Nächster Schritt angefordert (aktuelles Skript wird übersprungen)' }); if (typeof appendLog === 'function') { try { await appendLog('SYSTEM', `Kette "${chain.name}" - Skript-Schritt manuell übersprungen.`); } catch (_error) { // ignore appendLog failures for control actions } } return { accepted: true, message: 'Skript-Schritt wird übersprungen.' }; } return { accepted: false, message: 'Kein aktiver Schritt zum Überspringen.' }; }; runtimeActivityService.setControls(activityId, { cancel: requestCancel, nextStep: requestNextStep }); const results = []; let completionPayload = null; let abortedByUser = false; try { for (let index = 0; index < chain.steps.length; index += 1) { if (controlState.cancelRequested) { abortedByUser = true; break; } const step = chain.steps[index]; const stepIndex = index + 1; if (step.stepType === STEP_TYPE_WAIT) { const seconds = Math.max(1, Number(step.waitSeconds || 1)); const waitLabel = `Warte ${seconds} Sekunde(n)`; controlState.currentStepType = STEP_TYPE_WAIT; runtimeActivityService.updateActivity(activityId, { currentStepType: 'wait', currentStep: waitLabel, currentScriptName: null, stepIndex, stepTotal: totalSteps }); emitRuntimeStep({ stepType: 'wait', stepIndex, stepTotal: totalSteps, currentStep: waitLabel }); logger.info('chain:step:wait', { chainId, seconds }); if (typeof appendLog === 'function') { await appendLog('SYSTEM', `Kette "${chain.name}" - Warte ${seconds} Sekunde(n)...`); } const waitOutcome = await new Promise((resolve) => { const timer = setTimeout(() => { controlState.activeWaitResolve = null; resolve('done'); }, seconds * 1000); controlState.activeWaitResolve = (mode = 'done') => { clearTimeout(timer); controlState.activeWaitResolve = null; resolve(mode); }; }); controlState.currentStepType = null; if (waitOutcome === 'skip') { results.push({ stepType: 'wait', waitSeconds: seconds, success: true, skipped: true, reason: 'skipped_by_user' }); continue; } if (waitOutcome === 'cancel' || controlState.cancelRequested) { abortedByUser = true; results.push({ stepType: 'wait', waitSeconds: seconds, success: false, aborted: true, reason: 'cancelled_by_user' }); break; } results.push({ stepType: 'wait', waitSeconds: seconds, success: true }); } else if (step.stepType === STEP_TYPE_SCRIPT) { if (!step.scriptId) { logger.warn('chain:step:script-missing', { chainId, stepId: step.id }); results.push({ stepType: 'script', scriptId: null, success: false, skipped: true, reason: 'scriptId fehlt' }); continue; } const scriptService = require('./scriptService'); let script; try { script = await scriptService.getScriptById(step.scriptId); } catch (error) { logger.warn('chain:step:script-not-found', { chainId, scriptId: step.scriptId, error: errorToMeta(error) }); results.push({ stepType: 'script', scriptId: step.scriptId, success: false, skipped: true, reason: 'Skript nicht gefunden' }); continue; } controlState.currentStepType = STEP_TYPE_SCRIPT; runtimeActivityService.updateActivity(activityId, { currentStepType: 'script', currentStep: `Skript: ${script.name}`, currentScriptName: script.name, stepIndex, stepTotal: totalSteps, scriptId: script.id }); emitRuntimeStep({ stepType: 'script', stepIndex, stepTotal: totalSteps, scriptId: script.id, scriptName: script.name, currentScriptName: script.name, currentStep: `Skript: ${script.name}` }); if (typeof appendLog === 'function') { await appendLog('SYSTEM', `Kette "${chain.name}" - Skript: ${script.name}`); } const scriptActivityId = runtimeActivityService.startActivity('script', { name: script.name, source: context?.source || 'chain', scriptId: script.id, chainId: chain.id, jobId: context?.jobId || null, cronJobId: context?.cronJobId || null, parentActivityId: activityId, currentStep: `Kette: ${chain.name}` }); let prepared = null; try { prepared = await scriptService.createExecutableScriptFile(script, { ...context, scriptId: script.id, scriptName: script.name, source: context?.source || 'chain' }); let stdout = ''; let stderr = ''; let stdoutTruncated = false; let stderrTruncated = false; const processHandle = spawnTrackedProcess({ cmd: prepared.cmd, args: prepared.args, context: { source: context?.source || 'chain', chainId: chain.id, scriptId: script.id }, onStart: (child) => { controlState.activeChild = child; controlState.activeChildTermination = null; }, onStdoutLine: (line) => { const next = appendTailText(stdout, line); stdout = next.value; stdoutTruncated = stdoutTruncated || next.truncated; runtimeActivityService.appendActivityOutput(scriptActivityId, { stdout: line }); }, onStderrLine: (line) => { const next = appendTailText(stderr, line); stderr = next.value; stderrTruncated = stderrTruncated || next.truncated; runtimeActivityService.appendActivityOutput(scriptActivityId, { stderr: line }); } }); let runError = null; let exitCode = 0; let signal = null; try { const result = await processHandle.promise; exitCode = Number.isFinite(Number(result?.code)) ? Number(result.code) : 0; signal = result?.signal || null; } catch (error) { runError = error; exitCode = Number.isFinite(Number(error?.code)) ? Number(error.code) : null; signal = error?.signal || null; } const termination = controlState.activeChildTermination; controlState.activeChild = null; controlState.activeChildTermination = null; if (runError && exitCode === null && !termination) { throw runError; } const run = { code: exitCode, signal, stdout, stderr, stdoutTruncated, stderrTruncated, termination }; controlState.currentStepType = null; if (run.termination === 'skip') { runtimeActivityService.completeActivity(scriptActivityId, { status: 'success', success: true, outcome: 'skipped', skipped: true, currentStep: null, message: 'Schritt übersprungen', output: [run.stdout || '', run.stderr || ''].filter(Boolean).join('\n').trim() || null, stdout: run.stdout || null, stderr: run.stderr || null, stdoutTruncated: Boolean(run.stdoutTruncated), stderrTruncated: Boolean(run.stderrTruncated) }); if (typeof appendLog === 'function') { try { await appendLog('SYSTEM', `Kette "${chain.name}" - Skript "${script.name}" übersprungen.`); } catch (_error) { // ignore appendLog failures on skip path } } results.push({ stepType: 'script', scriptId: script.id, scriptName: script.name, success: true, skipped: true, reason: 'skipped_by_user' }); continue; } if (run.termination === 'cancel' || controlState.cancelRequested) { abortedByUser = true; runtimeActivityService.completeActivity(scriptActivityId, { status: 'error', success: false, outcome: 'cancelled', cancelled: true, currentStep: null, message: controlState.cancelReason || 'Von Benutzer abgebrochen', output: [run.stdout || '', run.stderr || ''].filter(Boolean).join('\n').trim() || null, stdout: run.stdout || null, stderr: run.stderr || null, stdoutTruncated: Boolean(run.stdoutTruncated), stderrTruncated: Boolean(run.stderrTruncated), errorMessage: controlState.cancelReason || 'Von Benutzer abgebrochen' }); if (typeof appendLog === 'function') { try { await appendLog('SYSTEM', `Kette "${chain.name}" - Skript "${script.name}" abgebrochen.`); } catch (_error) { // ignore appendLog failures on cancel path } } results.push({ stepType: 'script', scriptId: script.id, scriptName: script.name, success: false, aborted: true, reason: 'cancelled_by_user' }); break; } const success = run.code === 0; runtimeActivityService.completeActivity(scriptActivityId, { status: success ? 'success' : 'error', success, outcome: success ? 'success' : 'error', exitCode: run.code, currentStep: null, message: success ? null : `Fehler (Exit ${run.code})`, output: success ? null : [run.stdout || '', run.stderr || ''].filter(Boolean).join('\n').trim() || null, stderr: success ? null : (run.stderr || null), stdout: success ? null : (run.stdout || null), stdoutTruncated: Boolean(run.stdoutTruncated), stderrTruncated: Boolean(run.stderrTruncated), errorMessage: success ? null : `Fehler (Exit ${run.code})` }); logger.info('chain:step:script-done', { chainId, scriptId: script.id, exitCode: run.code, success }); if (typeof appendLog === 'function') { await appendLog( success ? 'SYSTEM' : 'ERROR', `Kette "${chain.name}" - Skript "${script.name}": ${success ? 'OK' : `Fehler (Exit ${run.code})`}` ); } results.push({ stepType: 'script', scriptId: script.id, scriptName: script.name, success, exitCode: run.code, stdout: run.stdout || '', stderr: run.stderr || '' }); if (!success) { logger.warn('chain:step:script-failed', { chainId, scriptId: script.id, exitCode: run.code }); break; } } catch (error) { controlState.currentStepType = null; if (controlState.cancelRequested) { abortedByUser = true; runtimeActivityService.completeActivity(scriptActivityId, { status: 'error', success: false, outcome: 'cancelled', cancelled: true, message: controlState.cancelReason || 'Von Benutzer abgebrochen', errorMessage: controlState.cancelReason || 'Von Benutzer abgebrochen' }); if (typeof appendLog === 'function') { try { await appendLog('SYSTEM', `Kette "${chain.name}" - Skript "${script.name}" abgebrochen.`); } catch (_error) { // ignore appendLog failures on cancel path } } results.push({ stepType: 'script', scriptId: script.id, scriptName: script.name, success: false, aborted: true, reason: 'cancelled_by_user' }); break; } runtimeActivityService.completeActivity(scriptActivityId, { status: 'error', success: false, outcome: 'error', message: error?.message || 'unknown', errorMessage: error?.message || 'unknown' }); logger.error('chain:step:script-error', { chainId, scriptId: step.scriptId, error: errorToMeta(error) }); if (typeof appendLog === 'function') { await appendLog('ERROR', `Kette "${chain.name}" - Skript-Fehler: ${error.message}`); } results.push({ stepType: 'script', scriptId: step.scriptId, success: false, error: error.message }); break; } finally { controlState.activeChild = null; controlState.activeChildTermination = null; if (prepared?.cleanup) { await prepared.cleanup(); } } } } const succeeded = results.filter((r) => r.success).length; const skipped = results.filter((r) => r.skipped).length; const failed = results.filter((r) => !r.success && !r.skipped && !r.aborted).length; logger.info('chain:execute:done', { chainId, steps: results.length, succeeded, failed, skipped, abortedByUser }); if (abortedByUser) { completionPayload = { status: 'error', success: false, outcome: 'cancelled', cancelled: true, currentStep: null, currentScriptName: null, message: controlState.cancelReason || 'Von Benutzer abgebrochen', errorMessage: controlState.cancelReason || 'Von Benutzer abgebrochen' }; emitRuntimeStep({ finished: true, success: false, aborted: true, failed, succeeded }); return { chainId, chainName: chain.name, steps: results.length, succeeded, failed, skipped, aborted: true, abortedByUser: true, results }; } completionPayload = { status: failed > 0 ? 'error' : 'success', success: failed === 0, outcome: failed > 0 ? 'error' : (skipped > 0 ? 'skipped' : 'success'), skipped: skipped > 0, currentStep: null, currentScriptName: null, message: failed > 0 ? `${failed} Schritt(e) fehlgeschlagen` : (skipped > 0 ? `${succeeded} Schritt(e) erfolgreich, ${skipped} übersprungen` : `${succeeded} Schritt(e) erfolgreich`) }; emitRuntimeStep({ finished: true, success: failed === 0, failed, succeeded }); return { chainId, chainName: chain.name, steps: results.length, succeeded, failed, skipped, aborted: failed > 0, results }; } catch (error) { completionPayload = { status: 'error', success: false, outcome: 'error', message: error?.message || 'unknown', errorMessage: error?.message || 'unknown', currentStep: null }; throw error; } finally { runtimeActivityService.completeActivity(activityId, completionPayload || { status: 'error', success: false, outcome: 'error', message: 'Kette unerwartet beendet', errorMessage: 'Kette unerwartet beendet', currentStep: null }); } } } module.exports = new ScriptChainService();