928 lines
32 KiB
JavaScript
928 lines
32 KiB
JavaScript
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();
|