From 2c19b3db177410dcddaaa94e78d3e1189de28c89 Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Thu, 13 Aug 2026 12:02:16 -0700 Subject: [PATCH 1/6] Support better logging --- .../Admin/ScriptExecutorController.php | 9 +- .../Api/ScriptExecutorController.php | 39 +++++- .../script-executors/ScriptExecutors.vue | 125 +++++++++++++----- .../admin/script-executors/index.blade.php | 1 + 4 files changed, 135 insertions(+), 39 deletions(-) diff --git a/ProcessMaker/Http/Controllers/Admin/ScriptExecutorController.php b/ProcessMaker/Http/Controllers/Admin/ScriptExecutorController.php index 230458ad89..5dfe1df1fd 100644 --- a/ProcessMaker/Http/Controllers/Admin/ScriptExecutorController.php +++ b/ProcessMaker/Http/Controllers/Admin/ScriptExecutorController.php @@ -8,15 +8,20 @@ class ScriptExecutorController extends Controller { - public function index(Request $request) + public function index(Request $request, ScriptMicroserviceService $service) { if (!config('app.custom_executors')) { abort(404); } + $scriptMicroserviceEnabled = config('script-runner-microservice.enabled'); + return view('admin.script-executors.index', [ - 'script_microservice_enabled' => config('script-runner-microservice.enabled'), + 'script_microservice_enabled' => $scriptMicroserviceEnabled, + 'script_microservice_tenant_id' => $scriptMicroserviceEnabled + ? $service->getInstanceUuid() + : null, ]); } } diff --git a/ProcessMaker/Http/Controllers/Api/ScriptExecutorController.php b/ProcessMaker/Http/Controllers/Api/ScriptExecutorController.php index 7627c9d7a4..36425976ce 100644 --- a/ProcessMaker/Http/Controllers/Api/ScriptExecutorController.php +++ b/ProcessMaker/Http/Controllers/Api/ScriptExecutorController.php @@ -4,6 +4,7 @@ use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Http\Client\RequestException; use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; use ProcessMaker\Enums\ScriptExecutorType; @@ -124,7 +125,15 @@ public function store(Request $request, ScriptMicroserviceService $service) ScriptExecutorCreated::dispatch($scriptExecutor->getAttributes()); BuildScriptExecutor::dispatch($scriptExecutor->id, $request->user()->id); } else { - $service->createCustomExecutor($scriptExecutor); + try { + $service->createCustomExecutor($scriptExecutor); + } catch (RequestException $e) { + // The remote executor was rejected, so keeping the local record would leave + // an executor that can never be built. + $scriptExecutor->delete(); + + $this->throwMicroserviceError($e); + } } return ['status' => 'started', 'uuid' => $scriptExecutor->uuid, 'id' => $scriptExecutor->id]; @@ -189,7 +198,11 @@ public function update(Request $request, ScriptExecutor $scriptExecutor, ScriptM ); if (config('script-runner-microservice.enabled') && $scriptExecutor->type == ScriptExecutorType::Custom) { - $service->updateCustomExecutor($scriptExecutor); + try { + $service->updateCustomExecutor($scriptExecutor); + } catch (RequestException $e) { + $this->throwMicroserviceError($e); + } } else { if (!empty($scriptExecutor->getChanges())) { ScriptExecutorUpdated::dispatch($scriptExecutor->id, $original, $scriptExecutor->getChanges()); @@ -274,6 +287,28 @@ public function delete(Request $request, ScriptExecutor $scriptExecutor, ScriptM return ['status' => 'done']; } + /** + * Report a script microservice rejection on the form, since the build itself + * is only reported over the websocket channel. + * + * @param RequestException $e Failed script microservice response + * + * @return never + * + * @throws ValidationException|RequestException + */ + private function throwMicroserviceError(RequestException $e): never + { + if (!$e->response->clientError()) { + throw $e; + } + + $detail = $e->response->json('detail'); + $message = is_string($detail) && $detail !== '' ? $detail : $e->getMessage(); + + throw ValidationException::withMessages(['language' => [$message]]); + } + private function checkAuth($request) { if (!config('app.custom_executors')) { diff --git a/resources/js/admin/script-executors/ScriptExecutors.vue b/resources/js/admin/script-executors/ScriptExecutors.vue index 63970dc86c..76653b711e 100644 --- a/resources/js/admin/script-executors/ScriptExecutors.vue +++ b/resources/js/admin/script-executors/ScriptExecutors.vue @@ -252,6 +252,7 @@ export default { "filter", "permission", "script_microservice_enabled", + "script_microservice_tenant_id", ], data() { return { @@ -271,7 +272,8 @@ export default { exitCode: 0, showDockerfile: false, loading: true, - script_microservice_broadcast_uui: null, + activeBuildUuid: null, + pendingBuildEvents: [], localLoadOnStart: true, orderBy: "language", @@ -356,16 +358,15 @@ export default { } ); } + + if (this.script_microservice_enabled && this.script_microservice_tenant_id) { + this.subscribeToTenantBuildChannel(); + } }, watch: { commandOutput() { this.scrollToBottom(); }, - script_microservice_broadcast_uui(newVal) { - if (newVal) { - this.subscribeToScriptMicroserviceChannel(newVal); - } - }, }, computed: { modalTitle() { @@ -434,7 +435,13 @@ export default { }, setErrors(errors) { this.status = "error"; - this.errors = errors.response.data.errors; + this.errors = _.get(errors, "response.data.errors", {}); + const messages = Object.values(this.errors) + .flat() + .filter((message) => typeof message === "string" && message !== ""); + if (messages.length) { + this.output(messages.join("\n") + "\n"); + } }, doNotHideIfRunning(e) { if (this.isRunning) { @@ -472,13 +479,15 @@ export default { save() { this.resetProcessInfo(); this.status = "saving"; + this.activeBuildUuid = this.formData.uuid || null; + this.pendingBuildEvents = []; if (this.formData.id) { const path = "/script-executors/" + this.formData.id; ProcessMaker.apiClient .put(path, this.formData) .then((result) => { this.status = _.get(result, "data.status", "error"); - this.script_microservice_broadcast_uui = result.data.uuid; + this.setActiveBuildUuid(_.get(result, "data.uuid", this.formData.uuid)); }) .catch((e) => { this.setErrors(e); @@ -489,9 +498,10 @@ export default { .post(path, this.formData) .then((result) => { this.status = _.get(result, "data.status", "error"); - this.script_microservice_broadcast_uui = result.data.uuid; + this.setActiveBuildUuid(_.get(result, "data.uuid")); if (this.status === "started") { this.formData.id = result.data.id; + this.formData.uuid = result.data.uuid; this.fetch(); // refresh the table (beneath the modal) } }) @@ -505,13 +515,17 @@ export default { }, edit(row) { this.formData = _.cloneDeep(row); + this.activeBuildUuid = row.uuid || null; this.$refs.edit.show(); }, reset() { this.formData = _.cloneDeep(this.emptyFormData); this.errors = {}; this.showDockerfile = false; - (this.status = "idle"), this.resetProcessInfo(); + this.status = "idle"; + this.activeBuildUuid = null; + this.pendingBuildEvents = []; + this.resetProcessInfo(); }, resetProcessInfo() { this.commandOutput = ""; @@ -550,31 +564,72 @@ export default { onAddToBundle(data) { this.$root.$emit('add-to-bundle', data); }, - subscribeToScriptMicroserviceChannel(name) { - const channel = `build-image-${name}`; - if (this.script_microservice_enabled) { - // Subscribe to new channel - window.ScriptMicroserviceEcho - .channel(channel) - .listenToAll((eventName, data) => { - this.status = this.status === "idle" ? "starting" : this.status; - switch (eventName) { - case ".build-image": - this.output(`${data}\n`); - break; - case ".build-finished": - this.pidFile = null; - this.exitCode = 0; - this.status = "done"; - break; - case ".build-error": - this.output(data); - this.pidFile = null; - this.exitCode = 1; - this.status = "done"; - break; - } - }); + subscribeToTenantBuildChannel() { + if (!window.ScriptMicroserviceEcho) { + return; + } + const channel = `tenant-${this.script_microservice_tenant_id}-builds`; + window.ScriptMicroserviceEcho.channel(channel).listen( + ".executor-build", + (data) => { + this.handleExecutorBuildEvent(data); + } + ); + }, + setActiveBuildUuid(uuid) { + if (!uuid) { + return; + } + this.activeBuildUuid = uuid; + const pending = this.pendingBuildEvents.filter( + (event) => event.executor_id === uuid + ); + this.pendingBuildEvents = []; + pending.forEach((event) => this.applyExecutorBuildEvent(event)); + }, + handleExecutorBuildEvent(data) { + if (!data || typeof data !== "object") { + return; + } + if (!this.isRunning) { + return; + } + if (!this.activeBuildUuid) { + this.pendingBuildEvents.push(data); + return; + } + if (data.executor_id !== this.activeBuildUuid) { + return; + } + this.applyExecutorBuildEvent(data); + }, + applyExecutorBuildEvent(data) { + if (this.status === "saving") { + this.status = "starting"; + } else if (this.status === "idle") { + this.status = "starting"; + } + + if (data.type === "status") { + this.output(`[${data.phase}] ${data.message || ""}\n`); + } else if (data.message) { + const message = data.message.endsWith("\n") + ? data.message + : `${data.message}\n`; + this.output(message); + } + + if (data.phase === "completed" && data.type === "status") { + this.pidFile = null; + this.exitCode = 0; + this.status = "done"; + return; + } + + if (data.phase === "error" || data.type === "error") { + this.pidFile = null; + this.exitCode = 1; + this.status = "done"; } }, }, diff --git a/resources/views/admin/script-executors/index.blade.php b/resources/views/admin/script-executors/index.blade.php index cb3146fabe..bc70a1ddfb 100644 --- a/resources/views/admin/script-executors/index.blade.php +++ b/resources/views/admin/script-executors/index.blade.php @@ -19,6 +19,7 @@
From 631288f529f1030e5054a9ab1ea6da26d576b32c Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Thu, 13 Aug 2026 18:08:10 -0700 Subject: [PATCH 2/6] Initial work on realtime executor --- ProcessMaker/Enums/ScriptExecutorType.php | 6 + .../Http/Controllers/Api/ScriptController.php | 21 ++- .../Api/ScriptExecutorController.php | 21 ++- ProcessMaker/Models/ScriptExecutor.php | 7 + .../ScriptMicroserviceRunner.php | 5 +- .../Services/ScriptMicroserviceService.php | 8 +- .../script-executors/ScriptExecutors.vue | 76 ++++++++--- .../scripts/components/ScriptEditor.vue | 22 +++ .../realtime/javascript.Dockerfile | 116 ++++++++++++++++ .../script-executors/realtime/php.Dockerfile | 92 +++++++++++++ .../realtime/python.Dockerfile | 128 ++++++++++++++++++ .../views/processes/scripts/builder.blade.php | 2 +- .../views/processes/scripts/preview.blade.php | 2 +- .../ScriptMicroserviceServiceTest.php | 7 +- 14 files changed, 486 insertions(+), 27 deletions(-) create mode 100644 resources/script-executors/realtime/javascript.Dockerfile create mode 100644 resources/script-executors/realtime/php.Dockerfile create mode 100644 resources/script-executors/realtime/python.Dockerfile diff --git a/ProcessMaker/Enums/ScriptExecutorType.php b/ProcessMaker/Enums/ScriptExecutorType.php index c4e7774c90..9a217ea478 100644 --- a/ProcessMaker/Enums/ScriptExecutorType.php +++ b/ProcessMaker/Enums/ScriptExecutorType.php @@ -7,4 +7,10 @@ enum ScriptExecutorType:string case System = 'system'; case Custom = 'custom'; case Duplicate = 'duplicate'; + case Realtime = 'realtime'; + + public function isCustomOrRealtime(): bool + { + return $this === self::Custom || $this === self::Realtime; + } } diff --git a/ProcessMaker/Http/Controllers/Api/ScriptController.php b/ProcessMaker/Http/Controllers/Api/ScriptController.php index e3cca93814..77f0e3b05e 100644 --- a/ProcessMaker/Http/Controllers/Api/ScriptController.php +++ b/ProcessMaker/Http/Controllers/Api/ScriptController.php @@ -4,6 +4,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; +use ProcessMaker\Enums\ScriptExecutorType; use ProcessMaker\Events\ScriptCreated; use ProcessMaker\Events\ScriptDeleted; use ProcessMaker\Events\ScriptDuplicated; @@ -178,7 +179,7 @@ public function index(Request $request) * * @OA\Response( * response=200, - * description="success if the script was queued", + * description="The queued status or realtime execution response", * ), * ), * ) @@ -190,6 +191,24 @@ public function preview(Request $request, Script $script) $code = $request->get('code'); $nonce = $request->get('nonce'); + if ($script->scriptExecutor->type === ScriptExecutorType::Realtime) { + // Set the preview code without persisting it and wait for the realtime + // executor to return its response in this request. + $script->code = $code; + + return $script->runScript( + $data, + $config, + '', + $request->get('timeout'), + 0, + [ + 'nonce' => $nonce, + 'current_user' => $request->user()?->id, + ] + ); + } + TestScript::dispatch($script, $request->user(), $code, $data, $config, $nonce)->onQueue('bpmn'); return ['status' => 'success']; diff --git a/ProcessMaker/Http/Controllers/Api/ScriptExecutorController.php b/ProcessMaker/Http/Controllers/Api/ScriptExecutorController.php index 36425976ce..80bc624891 100644 --- a/ProcessMaker/Http/Controllers/Api/ScriptExecutorController.php +++ b/ProcessMaker/Http/Controllers/Api/ScriptExecutorController.php @@ -197,7 +197,7 @@ public function update(Request $request, ScriptExecutor $scriptExecutor, ScriptM $request->only($scriptExecutor->getFillable()) ); - if (config('script-runner-microservice.enabled') && $scriptExecutor->type == ScriptExecutorType::Custom) { + if (config('script-runner-microservice.enabled') && $scriptExecutor->type?->isCustomOrRealtime()) { try { $service->updateCustomExecutor($scriptExecutor); } catch (RequestException $e) { @@ -416,11 +416,30 @@ public function availableLanguages() $languages[] = [ 'value' => $key, 'text' => $config['name'], + 'language' => $key, + 'realtime' => false, 'initDockerfile' => ScriptExecutor::initDockerfile($key), + 'configExample' => '', ]; } } + foreach (['php', 'python', 'javascript'] as $language) { + $dockerfilePath = resource_path("script-executors/realtime/{$language}.Dockerfile"); + if (!file_exists($dockerfilePath)) { + continue; + } + $label = $language === 'javascript' ? 'nodejs (realtime)' : "{$language} (realtime)"; + $languages[] = [ + 'value' => "{$language}-realtime", + 'text' => $label, + 'language' => $language, + 'realtime' => true, + 'initDockerfile' => '', + 'configExample' => file_get_contents($dockerfilePath), + ]; + } + return ['languages' => $languages]; } } diff --git a/ProcessMaker/Models/ScriptExecutor.php b/ProcessMaker/Models/ScriptExecutor.php index 6e5438f279..408b9e457d 100644 --- a/ProcessMaker/Models/ScriptExecutor.php +++ b/ProcessMaker/Models/ScriptExecutor.php @@ -32,6 +32,7 @@ * @OA\Property(property="language", type="string"), * @OA\Property(property="config", type="string"), * @OA\Property(property="is_system", type="boolean"), + * @OA\Property(property="type", type="string"), * ), * @OA\Schema( * schema="scriptExecutors", @@ -177,6 +178,12 @@ public static function rules($existing = null) }); } + // Realtime executors may use php/python/javascript even if a package is not installed + $allowedLanguages = array_values(array_unique(array_merge( + $allowedLanguages, + ['php', 'python', 'javascript'] + ))); + return [ 'title' => 'required', 'language' => [ diff --git a/ProcessMaker/ScriptRunners/ScriptMicroserviceRunner.php b/ProcessMaker/ScriptRunners/ScriptMicroserviceRunner.php index dc4d5f3a96..a4c603d117 100644 --- a/ProcessMaker/ScriptRunners/ScriptMicroserviceRunner.php +++ b/ProcessMaker/ScriptRunners/ScriptMicroserviceRunner.php @@ -40,7 +40,7 @@ public function run($code, array $data, array $config, $timeout, $user, $sync, $ $scriptRunner = $this->service->getScriptRunner( $this->language, $this->script->scriptExecutor->uuid, - $this->script->scriptExecutor->type === ScriptExecutorType::Custom + $this->script->scriptExecutor->type?->isCustomOrRealtime() ); if (!$scriptRunner) { @@ -62,7 +62,8 @@ public function run($code, array $data, array $config, $timeout, $user, $sync, $ 'callback_token' => $environmentVariables['API_TOKEN'], 'debug' => true, 'timeout' => $timeout, - 'sync' => $sync, + // Realtime executors always run synchronously (service ignores sync too) + 'sync' => $this->script->scriptExecutor->type === ScriptExecutorType::Realtime ? true : $sync, ]; Log::debug('Payload: ' . print_r($payload, true)); diff --git a/ProcessMaker/Services/ScriptMicroserviceService.php b/ProcessMaker/Services/ScriptMicroserviceService.php index b3a8a114ce..e430656873 100644 --- a/ProcessMaker/Services/ScriptMicroserviceService.php +++ b/ProcessMaker/Services/ScriptMicroserviceService.php @@ -9,6 +9,7 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use ProcessMaker\Enums\ScriptExecutorType; use ProcessMaker\Events\ScriptResponseEvent; use ProcessMaker\Exception\ScriptException; use ProcessMaker\Jobs\CompleteActivity; @@ -87,6 +88,7 @@ public function createCustomExecutor(ScriptExecutor $scriptExecutor) 'language' => strtolower($scriptExecutor->language), 'version' => config('script-runner-microservice.version'), 'config' => $scriptExecutor->config, + 'realtime' => $scriptExecutor->type === ScriptExecutorType::Realtime, ]; Log::debug('Payload: ', $payload); @@ -114,6 +116,7 @@ public function updateCustomExecutor(ScriptExecutor $scriptExecutor) 'language' => strtolower($scriptExecutor->language), 'version' => config('script-runner-microservice.version'), 'config' => $scriptExecutor->config, + 'realtime' => $scriptExecutor->type === ScriptExecutorType::Realtime, ]; Log::debug('Payload: ', $payload); @@ -195,6 +198,7 @@ public function getScriptRunner(string $language, string $executorUuid, bool $cu if (Cache::has($cacheKey)) { Log::debug('Cache hit for script runner', ['cacheKey' => $cacheKey]); + return Cache::get($cacheKey); } @@ -211,7 +215,9 @@ public function getScriptRunner(string $language, string $executorUuid, bool $cu return isset($item['language'], $item['id']) && $item['language'] === $language && $item['id'] === $executorUuid; })->first(); - if (!empty($result)) Cache::put($cacheKey, $result, now()->addHour()); + if (!empty($result)) { + Cache::put($cacheKey, $result, now()->addHour()); + } return $result; } diff --git a/resources/js/admin/script-executors/ScriptExecutors.vue b/resources/js/admin/script-executors/ScriptExecutors.vue index 76653b711e..1de44521cb 100644 --- a/resources/js/admin/script-executors/ScriptExecutors.vue +++ b/resources/js/admin/script-executors/ScriptExecutors.vue @@ -32,7 +32,7 @@ {{ props.rowData.title }}