From 448ae733896df529a06e65be07581899069abadd Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 12 Jun 2026 11:31:58 -0300 Subject: [PATCH] Add expression autocomplete, side-panel editor, and richer HTTP fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automations editor: - {{ }} expression autocomplete in CodeMirror, scoped to the braces and graph-aware (suggests only what upstream nodes provide + variables + now); migrate the Generate prompt to CodeMirror so it shares the same completions - Expandable editors: an expand button slides out a side-by-side panel (matching the sidebar card), with a minimize control; the inline field collapses to a hint while editing in the panel - Hover-revealed editor toolbar (expand/copy) with styled tooltips so the buttons no longer obscure the text while reading - Beta badge on the Automations sidebar item - Delete a single connection with Backspace/Delete (edge selection) - Re-key node config so switching between same-type nodes refreshes the form HTTP fetch node — cover every JSON response shape: - Top-level array, object map (items_path=*), array of primitives, and NDJSON - Key-based dedup via item_key_path (seen-set, FIFO-capped) for feeds without dates; first poll records a baseline and emits nothing (date path too) Fan-out test visibility: - root_run_id links every forked branch back to the run that started a test, so the test panel aggregates all branches instead of one Fix a few pre-existing type issues (ScheduleData import, padded minute, optional created_at). --- .../Automation/Node/RunFetchRssNode.php | 43 +-- .../Automation/Node/RunHttpRequestNode.php | 309 +++++++++++++----- .../Automation/Node/RunWebhookNode.php | 17 +- .../Automation/Run/AdvanceAutomationRun.php | 54 ++- .../Automation/Run/DispatchAutomationRun.php | 21 +- app/Actions/Automation/Run/TestAutomation.php | 26 +- .../DispatchPostTriggerAutomations.php | 13 +- app/Events/AutomationRunUpdated.php | 1 + .../Controllers/App/AutomationController.php | 15 +- app/Models/AutomationRun.php | 10 + .../Automation/ExpressionResolver.php | 23 +- ...d_root_run_id_to_automation_runs_table.php | 28 ++ lang/en/automations.php | 53 ++- lang/es/automations.php | 53 ++- lang/pt-BR/automations.php | 53 ++- resources/js/components/AppSidebar.vue | 1 + resources/js/components/CodeEditor.vue | 148 ++++++++- resources/js/components/NavMain.vue | 8 + .../js/components/automations/EditorGuide.vue | 72 ++++ .../components/automations/TestRunPanel.vue | 8 +- .../automations/config/GenerateNodeConfig.vue | 13 +- .../config/HttpRequestNodeConfig.vue | 35 +- .../automations/config/TriggerNodeConfig.vue | 2 +- .../automations/config/WebhookNodeConfig.vue | 8 +- .../automations/schedule-summary.ts | 2 +- .../composables/useExpressionCompletions.ts | 168 ++++++++++ resources/js/pages/automations/Form.vue | 78 ++++- resources/js/pages/automations/Index.vue | 2 +- resources/js/types/index.d.ts | 1 + .../Automation/Node/FetchRssNodeTest.php | 45 ++- .../Automation/Node/HttpRequestNodeTest.php | 190 ++++++++++- .../Automation/Node/WebhookNodeTest.php | 17 + tests/Feature/Automation/Run/FanOutTest.php | 89 +++++ .../Run/RunFamilyAggregationTest.php | 62 ++++ 34 files changed, 1450 insertions(+), 218 deletions(-) create mode 100644 database/migrations/2026_06_11_213339_add_root_run_id_to_automation_runs_table.php create mode 100644 resources/js/components/automations/EditorGuide.vue create mode 100644 resources/js/composables/useExpressionCompletions.ts create mode 100644 tests/Feature/Automation/Run/FanOutTest.php create mode 100644 tests/Feature/Automation/Run/RunFamilyAggregationTest.php diff --git a/app/Actions/Automation/Node/RunFetchRssNode.php b/app/Actions/Automation/Node/RunFetchRssNode.php index c66a0a5e..b01db434 100644 --- a/app/Actions/Automation/Node/RunFetchRssNode.php +++ b/app/Actions/Automation/Node/RunFetchRssNode.php @@ -4,9 +4,9 @@ namespace App\Actions\Automation\Node; +use App\Actions\Automation\Run\AdvanceAutomationRun; use App\DataTransferObjects\Automation\NodeRunResult; use App\Enums\Automation\Run\Status as RunStatus; -use App\Jobs\Automation\ProcessAutomationNode; use App\Models\AutomationNodeState; use App\Models\AutomationRun; use App\Services\Automation\ExpressionResolver; @@ -38,6 +38,7 @@ class RunFetchRssNode public function __construct( private ExpressionResolver $resolver, private SafeHttpFetcher $safeHttp, + private AdvanceAutomationRun $advance, ) {} public function __invoke(AutomationRun $run, array $config): NodeRunResult @@ -76,18 +77,20 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult } $nodeId = (string) $run->current_node_id; - // Dry runs bypass the watermark entirely: they neither load nor advance - // the persisted state, AND they use an epoch watermark so every item in - // the feed is treated as new — otherwise a "now" default would silently - // return zero items on any feed that hasn't published in the last second. - $state = $run->is_dry_run ? null : AutomationNodeState::for($run->automation_id, $nodeId); - $watermark = $run->is_dry_run + // Test runs (dry OR real-data) bypass the watermark entirely: they use an + // epoch watermark so every item is treated as new, process only the first + // item, and never spawn siblings or advance the watermark. This way a + // test ALWAYS shows real data flowing through (instead of "no new items") + // and never floods the feed or poisons the production watermark. + $isPreview = $run->is_manual || $run->is_dry_run; + $state = $isPreview ? null : AutomationNodeState::for($run->automation_id, $nodeId); + $watermark = $isPreview ? CarbonImmutable::createFromTimestamp(0) : $this->parseWatermark(data_get($state->data, 'last_item_date')); [$newItems, $newestSeen] = $this->collectNewItems($xml, $watermark); - if ($state !== null && $newestSeen !== null && ! $run->is_manual) { + if ($state !== null && $newestSeen !== null) { $state->update(['data' => array_merge($state->data ?? [], [ 'last_item_date' => $newestSeen->toIso8601String(), ])]); @@ -99,12 +102,12 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult $first = array_shift($newItems); - if (! $run->is_dry_run) { + if (! $isPreview) { $this->spawnSiblings($run, $nodeId, $newItems); } return NodeRunResult::completed([ - 'fetch' => ['count' => count($newItems) + 1, 'spawned' => $run->is_dry_run ? 0 : count($newItems)], + 'fetch' => ['count' => count($newItems) + 1, 'spawned' => $isPreview ? 0 : count($newItems)], 'fetched' => $first, ]); } @@ -162,35 +165,33 @@ private function spawnSiblings(AutomationRun $parent, string $fetchNodeId, array return; } - $nextNodeId = $this->findNextNodeId($parent, $fetchNodeId); + // Each remaining item gets its own run that fans out across EVERY branch + // wired to the fetch node — matching how item[0] (the current run) fans + // out, so no branch silently drops items 2..N. + $targets = $this->advance->targetsFor($parent->automation, $fetchNodeId, self::ITEM_HANDLE); foreach ($items as $item) { $sibling = AutomationRun::create([ 'automation_id' => $parent->automation_id, + 'root_run_id' => $parent->rootId(), + 'trigger_item_id' => $parent->trigger_item_id, + 'generated_post_id' => $parent->generated_post_id, 'is_manual' => $parent->is_manual, 'is_dry_run' => $parent->is_dry_run, 'status' => RunStatus::Pending, 'context' => array_merge($parent->context ?? [], ['fetched' => $item]), ]); - if ($nextNodeId === null) { + if ($targets === []) { $sibling->update(['status' => RunStatus::Completed, 'finished_at' => now()]); continue; } - ProcessAutomationNode::dispatch($sibling, $nextNodeId); + $this->advance->dispatchBranches($sibling, $targets); } } - private function findNextNodeId(AutomationRun $run, string $fromNodeId): ?string - { - $connection = collect($run->automation->connections ?? []) - ->first(fn ($c) => $c['source'] === $fromNodeId && ($c['source_handle'] ?? self::ITEM_HANDLE) === self::ITEM_HANDLE); - - return $connection['target'] ?? null; - } - private function parseWatermark(?string $stored): CarbonImmutable { if ($stored === null) { diff --git a/app/Actions/Automation/Node/RunHttpRequestNode.php b/app/Actions/Automation/Node/RunHttpRequestNode.php index f5a42ab1..c740ff5e 100644 --- a/app/Actions/Automation/Node/RunHttpRequestNode.php +++ b/app/Actions/Automation/Node/RunHttpRequestNode.php @@ -4,15 +4,16 @@ namespace App\Actions\Automation\Node; +use App\Actions\Automation\Run\AdvanceAutomationRun; use App\DataTransferObjects\Automation\NodeRunResult; use App\Enums\Automation\Run\Status as RunStatus; -use App\Jobs\Automation\ProcessAutomationNode; use App\Models\AutomationNodeState; use App\Models\AutomationRun; use App\Services\Automation\ExpressionResolver; use App\Services\Brand\SafeHttpFetcher; use Carbon\CarbonImmutable; use Illuminate\Http\Client\PendingRequest; +use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Http; use RuntimeException; @@ -22,13 +23,22 @@ * Generalized HTTP node — supersedes the old `fetch_json` polling-only node. * * Two execution modes share one config: - * - **Single request mode** (`items_path` empty): one HTTP call, the parsed - * response is set on `context.fetched` as a single payload. Useful for - * enrichment ("look up user X before generating") or webhook-style fan-out. - * - **Polling mode** (`items_path` set): same as the old fetch — extract an - * items array, filter by watermark (when `item_date_path` is provided), - * process the oldest unseen item in the current run and spawn siblings for - * the remainder. Each item ends up driving its own downstream branch. + * - **Single request mode**: the response is a single object (or `items_path` + * resolves to nothing), so the parsed body is set on `context.fetched` as a + * single payload. Useful for enrichment ("look up user X before generating"). + * - **List mode**: the response is a collection, so each item drives its own + * downstream branch. The collection is found by, in order: + * 1. `items_path` (dot notation, e.g. `data.items`; `*` iterates a top-level + * object map's values), + * 2. an empty `items_path` + a top-level JSON array, + * 3. an empty `items_path` + an NDJSON (one JSON object per line) body. + * The current run takes the first new item; the rest spawn sibling runs. + * + * New items are detected (so a feed isn't reprocessed every poll) by, in order: + * - `item_date_path` — a per-node date watermark (cheapest, bounded), or + * - `item_key_path` — a per-node set of seen keys (covers ids/uuids/urls). + * Either way the FIRST poll records the baseline and emits nothing, so pointing + * the node at an existing feed never floods on day one. * * Auth types: none, bearer, basic, api_key. Credentials are stored encrypted * on the Automation model (see `Automation::booted()`) and decrypted here. @@ -37,18 +47,22 @@ class RunHttpRequestNode { private const ITEM_HANDLE = 'default'; + /** + * Upper bound on the per-node seen-key history. FIFO-evicts the oldest keys + * once exceeded — matching n8n's capped "history size" for its dedup store. + */ + private const MAX_SEEN_KEYS = 500; + public function __construct( private ExpressionResolver $resolver, private SafeHttpFetcher $safeHttp, + private AdvanceAutomationRun $advance, ) {} public function __invoke(AutomationRun $run, array $config): NodeRunResult { $url = (string) data_get($config, 'url', ''); $method = strtoupper((string) data_get($config, 'method', 'GET')); - $itemsPath = data_get($config, 'items_path'); - $itemKeyPath = data_get($config, 'item_key_path'); - $itemDatePath = data_get($config, 'item_date_path'); $nodeId = (string) $run->current_node_id; $context = $run->resolverContext(); @@ -68,15 +82,15 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult } $request = $this->buildRequest($config, $context); - $body = $this->buildJsonBody($method, $config, $context); + $jsonBody = $this->buildJsonBody($method, $config, $context); try { $response = match ($method) { 'GET' => $request->get($resolvedUrl), 'DELETE' => $request->delete($resolvedUrl), - 'POST' => $request->post($resolvedUrl, $body), - 'PUT' => $request->put($resolvedUrl, $body), - 'PATCH' => $request->patch($resolvedUrl, $body), + 'POST' => $request->post($resolvedUrl, $jsonBody), + 'PUT' => $request->put($resolvedUrl, $jsonBody), + 'PATCH' => $request->patch($resolvedUrl, $jsonBody), default => null, }; } catch (Throwable $e) { @@ -94,83 +108,218 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult ]); } - $body = $response->json(); - $useItems = is_string($itemsPath) && $itemsPath !== ''; + $payload = $this->decodeBody($response); + $itemsPath = is_string($raw = data_get($config, 'items_path')) ? trim($raw) : ''; - if (! $useItems) { - // Single-response mode: pass the whole body forward as `fetched`. - return NodeRunResult::completed([ - 'fetch' => ['count' => 1, 'spawned' => 0], - 'fetched' => $body, - ]); + // No path + a top-level array (or NDJSON list) → iterate it. No path + a + // single object/scalar → single-response mode: forward the whole body. + if ($itemsPath === '') { + if (! is_array($payload) || ! array_is_list($payload)) { + return NodeRunResult::completed([ + 'fetch' => ['count' => 1, 'spawned' => 0], + 'fetched' => $payload, + ]); + } + + return $this->processItems($run, $nodeId, $config, $payload); } - $rawItems = data_get($body, $itemsPath, []); - if (! is_array($rawItems)) { + // Explicit path: dot notation, or `*` to iterate a top-level object map. + $resolved = data_get($payload, $itemsPath); + + if (! is_array($resolved)) { return NodeRunResult::failed('Items path did not resolve to an array.'); } - // Dry runs bypass the watermark entirely: they neither load nor advance - // the persisted state, AND they process every item so the user actually - // sees data flow (with a "now" watermark, fresh tests would yield 0 - // items on any feed older than today and look broken). - $useWatermark = is_string($itemDatePath) && $itemDatePath !== '' && ! $run->is_dry_run; - $state = $useWatermark ? AutomationNodeState::for($run->automation_id, $nodeId) : null; - $watermark = $useWatermark - ? $this->parseWatermark(data_get($state->data, 'last_item_date')) - : null; - $newestSeen = null; + return $this->processItems($run, $nodeId, $config, array_values($resolved)); + } - $newItems = []; + /** + * Decodes the response as JSON, falling back to NDJSON (one JSON value per + * line) so streaming/log-style list endpoints work too. + */ + private function decodeBody(Response $response): mixed + { + $json = $response->json(); - foreach ($rawItems as $item) { - if (! is_array($item)) { + if (is_array($json)) { + return $json; + } + + return $this->parseNdjson($response->body()) ?? $json; + } + + /** + * @return array|null The decoded list, or null when the body is + * not newline-delimited JSON. + */ + private function parseNdjson(string $body): ?array + { + $lines = preg_split('/\r\n|\r|\n/', trim($body)) ?: []; + $items = []; + + foreach ($lines as $line) { + $line = trim($line); + if ($line === '') { continue; } - if ($useWatermark) { - $itemDate = $this->parseDate(data_get($item, $itemDatePath)); - if ($itemDate === null) { - continue; - } - if ($newestSeen === null || $itemDate->greaterThan($newestSeen)) { - $newestSeen = $itemDate; - } - if (! $itemDate->greaterThan($watermark)) { - continue; - } + $decoded = json_decode($line, true); + if ($decoded === null && $line !== 'null') { + return null; } - $key = $itemKeyPath ? data_get($item, $itemKeyPath) : null; - $key = ($key === null || $key === '') ? hash('sha256', json_encode($item)) : (string) $key; - - $newItems[] = array_merge($item, ['_key' => $key]); + $items[] = $decoded; } - if ($useWatermark && $newestSeen !== null && $state !== null && ! $run->is_manual) { - $state->update(['data' => array_merge($state->data ?? [], [ - 'last_item_date' => $newestSeen->toIso8601String(), - ])]); + return count($items) > 1 ? $items : null; + } + + /** + * @param array $config + * @param array $items + */ + private function processItems(AutomationRun $run, string $nodeId, array $config, array $items): NodeRunResult + { + $itemKeyPath = is_string($k = data_get($config, 'item_key_path')) ? trim($k) : ''; + $itemDatePath = is_string($d = data_get($config, 'item_date_path')) ? trim($d) : ''; + + // Test runs (dry OR real-data) walk a single item so the user sees data + // flow, without spawning siblings, advancing watermarks or recording keys. + if ($run->is_manual || $run->is_dry_run) { + if ($items === []) { + return NodeRunResult::completed(['fetch' => ['count' => 0]], nextHandle: 'no_items'); + } + + return NodeRunResult::completed([ + 'fetch' => ['count' => count($items), 'spawned' => 0], + 'fetched' => $items[0], + ]); } + // A date watermark is cheapest and bounded, so it wins when both are set; + // the seen-key set is the fallback for feeds without a usable date. + $newItems = match (true) { + $itemDatePath !== '' => $this->filterByDate($run->automation_id, $nodeId, $items, $itemDatePath), + $itemKeyPath !== '' => $this->filterBySeenKeys($run->automation_id, $nodeId, $items, $itemKeyPath), + default => $items, + }; + if ($newItems === []) { return NodeRunResult::completed(['fetch' => ['count' => 0]], nextHandle: 'no_items'); } $first = array_shift($newItems); - - // Dry runs skip sibling spawning so the test stays a single in-memory - // walk through one item — see TestAutomation's dry-run contract. - if (! $run->is_dry_run) { - $this->spawnSiblings($run, $nodeId, $newItems); - } + $this->spawnSiblings($run, $nodeId, $newItems); return NodeRunResult::completed([ - 'fetch' => ['count' => count($newItems) + 1, 'spawned' => $run->is_dry_run ? 0 : count($newItems)], + 'fetch' => ['count' => count($newItems) + 1, 'spawned' => count($newItems)], 'fetched' => $first, ]); } + /** + * Keeps only items newer than the per-node date watermark, then advances the + * watermark to the newest date seen. The first poll records the baseline and + * emits nothing, so an existing feed never floods on day one. + * + * @param array $items + * @return array + */ + private function filterByDate(string $automationId, string $nodeId, array $items, string $datePath): array + { + $state = AutomationNodeState::for($automationId, $nodeId); + $isFirstPoll = ! array_key_exists('last_item_date', (array) $state->data); + $watermark = $this->parseWatermark(data_get($state->data, 'last_item_date')); + $newest = null; + $new = []; + + foreach ($items as $item) { + $date = $this->parseDate(data_get($item, $datePath)); + if ($date === null) { + continue; + } + + if ($newest === null || $date->greaterThan($newest)) { + $newest = $date; + } + + // The first poll only records the baseline (the newest item's date), + // emitting nothing — so an existing feed never floods on day one, even + // if some items are dated slightly ahead of the server clock. + if (! $isFirstPoll && $date->greaterThan($watermark)) { + $new[] = $item; + } + } + + if ($newest !== null) { + $state->update(['data' => array_merge($state->data ?? [], [ + 'last_item_date' => $newest->toIso8601String(), + ])]); + } + + return $new; + } + + /** + * Keeps only items whose key hasn't been seen before, recording the new keys + * in a FIFO-capped per-node set. The first poll records every key but emits + * nothing (baseline), matching the date-watermark semantics so pointing the + * node at an existing feed never floods on day one. + * + * @param array $items + * @return array + */ + private function filterBySeenKeys(string $automationId, string $nodeId, array $items, string $keyPath): array + { + $state = AutomationNodeState::for($automationId, $nodeId); + $isFirstPoll = ! array_key_exists('seen_keys', (array) $state->data); + $hashes = array_values((array) data_get($state->data, 'seen_keys', [])); + $seen = array_flip($hashes); + $new = []; + + foreach ($items as $item) { + $hash = $this->keyHash($item, $keyPath); + if (isset($seen[$hash])) { + continue; + } + + $seen[$hash] = true; + $hashes[] = $hash; + + if (! $isFirstPoll) { + $new[] = $item; + } + } + + if (count($hashes) > self::MAX_SEEN_KEYS) { + $hashes = array_slice($hashes, count($hashes) - self::MAX_SEEN_KEYS); + } + + $state->update(['data' => array_merge((array) $state->data, ['seen_keys' => $hashes])]); + + return $new; + } + + /** + * Stable hash for an item's dedup key. Objects use `item_key_path` (falling + * back to the whole item); scalars use their own value. Hashing keeps the + * stored set compact and avoids persisting raw payloads. + */ + private function keyHash(mixed $item, string $keyPath): string + { + if (is_array($item)) { + $key = $keyPath !== '' ? data_get($item, $keyPath) : null; + // Fall back to the whole item when no usable key is present; serialize + // guards against json_encode returning false on malformed UTF-8. + $key = ($key === null || $key === '') ? (json_encode($item) ?: serialize($item)) : (string) $key; + } else { + $key = (string) $item; + } + + return md5($key); + } + /** * @param array $config * @param array $context @@ -230,14 +379,19 @@ private function buildJsonBody(string $method, array $config, array $context): a return []; } - $rendered = $this->resolver->resolve($template, $context); - $decoded = json_decode($rendered, true); + // Parse the JSON body template first, then resolve placeholders in its + // string leaves so data containing quotes/newlines can't corrupt it. + $decodedTemplate = json_decode($template, true); - return is_array($decoded) ? $decoded : []; + if (! is_array($decodedTemplate)) { + return []; + } + + return $this->resolver->resolveStructured($decodedTemplate, $context); } /** - * @param array> $items + * @param array $items */ private function spawnSiblings(AutomationRun $parent, string $fetchNodeId, array $items): void { @@ -245,35 +399,32 @@ private function spawnSiblings(AutomationRun $parent, string $fetchNodeId, array return; } - $nextNodeId = $this->findNextNodeId($parent, $fetchNodeId); + // Each remaining item gets its own run that fans out across EVERY branch + // wired to this node — matching how item[0] (the current run) fans out. + $targets = $this->advance->targetsFor($parent->automation, $fetchNodeId, self::ITEM_HANDLE); foreach ($items as $item) { $sibling = AutomationRun::create([ 'automation_id' => $parent->automation_id, + 'root_run_id' => $parent->rootId(), + 'trigger_item_id' => $parent->trigger_item_id, + 'generated_post_id' => $parent->generated_post_id, 'is_manual' => $parent->is_manual, 'is_dry_run' => $parent->is_dry_run, 'status' => RunStatus::Pending, 'context' => array_merge($parent->context ?? [], ['fetched' => $item]), ]); - if ($nextNodeId === null) { + if ($targets === []) { $sibling->update(['status' => RunStatus::Completed, 'finished_at' => now()]); continue; } - ProcessAutomationNode::dispatch($sibling, $nextNodeId); + $this->advance->dispatchBranches($sibling, $targets); } } - private function findNextNodeId(AutomationRun $run, string $fromNodeId): ?string - { - $connection = collect($run->automation->connections ?? []) - ->first(fn ($c) => $c['source'] === $fromNodeId && ($c['source_handle'] ?? self::ITEM_HANDLE) === self::ITEM_HANDLE); - - return $connection['target'] ?? null; - } - private function decrypt(string $value): string { if ($value === '') { diff --git a/app/Actions/Automation/Node/RunWebhookNode.php b/app/Actions/Automation/Node/RunWebhookNode.php index 18ee13ff..33dd316e 100644 --- a/app/Actions/Automation/Node/RunWebhookNode.php +++ b/app/Actions/Automation/Node/RunWebhookNode.php @@ -38,11 +38,16 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult $headers[$k] = $this->resolver->resolve((string) $v, $context); } - $payloadJson = $this->resolver->resolve($config['payload_template'] ?? '{}', $context); - $trimmedPayload = trim($payloadJson); + // Parse the template as JSON FIRST, then resolve placeholders in its + // string leaves — so a value containing `"`/`&`/newlines can't corrupt + // the JSON (the final json_encode escapes it). + $template = $config['payload_template'] ?? '{}'; + $trimmedTemplate = trim($template); - if ($trimmedPayload !== '' && $trimmedPayload !== 'null') { - $decoded = json_decode($payloadJson, true); + if ($trimmedTemplate === '' || $trimmedTemplate === 'null') { + $payload = []; + } else { + $decodedTemplate = json_decode($template, true); if (json_last_error() !== JSON_ERROR_NONE) { return NodeRunResult::failed(__('automations.errors.webhook_invalid_payload_json'), [ @@ -50,9 +55,7 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult ]); } - $payload = $decoded ?? []; - } else { - $payload = []; + $payload = $this->resolver->resolveStructured($decodedTemplate ?? [], $context); } if ($run->is_dry_run) { diff --git a/app/Actions/Automation/Run/AdvanceAutomationRun.php b/app/Actions/Automation/Run/AdvanceAutomationRun.php index c6d2802c..cfb5cf7b 100644 --- a/app/Actions/Automation/Run/AdvanceAutomationRun.php +++ b/app/Actions/Automation/Run/AdvanceAutomationRun.php @@ -6,18 +6,16 @@ use App\Enums\Automation\Run\Status; use App\Jobs\Automation\ProcessAutomationNode; +use App\Models\Automation; use App\Models\AutomationRun; class AdvanceAutomationRun { public function __invoke(AutomationRun $run, string $fromNodeId, string $handle = 'default'): void { - $automation = $run->automation; + $targets = $this->targetsFor($run->automation, $fromNodeId, $handle); - $connection = collect($automation->connections ?? []) - ->first(fn ($c) => $c['source'] === $fromNodeId && ($c['source_handle'] ?? 'default') === $handle); - - if ($connection === null) { + if ($targets === []) { $run->update([ 'status' => Status::Completed, 'finished_at' => now(), @@ -32,6 +30,50 @@ public function __invoke(AutomationRun $run, string $fromNodeId, string $handle return; } - ProcessAutomationNode::dispatch($run, $connection['target']); + $this->dispatchBranches($run, $targets); + } + + /** + * Every node id connected to `$fromNodeId` via the given handle. A node can + * fan out to several targets (e.g. a trigger calling RSS and HTTP at once). + * + * @return array + */ + public function targetsFor(Automation $automation, string $fromNodeId, string $handle = 'default'): array + { + return collect($automation->connections ?? []) + ->filter(fn ($c) => ($c['source'] ?? null) === $fromNodeId && ($c['source_handle'] ?? 'default') === $handle) + ->pluck('target') + ->filter() + ->values() + ->all(); + } + + /** + * Continues the run on the first branch and forks a sibling run — sharing the + * accumulated context — for every additional branch, so all targets execute. + * + * @param array $targets + */ + public function dispatchBranches(AutomationRun $run, array $targets): void + { + $first = array_shift($targets); + + foreach ($targets as $target) { + $sibling = AutomationRun::create([ + 'automation_id' => $run->automation_id, + 'root_run_id' => $run->rootId(), + 'trigger_item_id' => $run->trigger_item_id, + 'generated_post_id' => $run->generated_post_id, + 'is_manual' => $run->is_manual, + 'is_dry_run' => $run->is_dry_run, + 'status' => Status::Pending, + 'context' => $run->context, + ]); + + ProcessAutomationNode::dispatch($sibling, $target); + } + + ProcessAutomationNode::dispatch($run, $first); } } diff --git a/app/Actions/Automation/Run/DispatchAutomationRun.php b/app/Actions/Automation/Run/DispatchAutomationRun.php index 2b59a9f0..d7516924 100644 --- a/app/Actions/Automation/Run/DispatchAutomationRun.php +++ b/app/Actions/Automation/Run/DispatchAutomationRun.php @@ -5,16 +5,17 @@ namespace App\Actions\Automation\Run; use App\Enums\Automation\Run\Status; -use App\Jobs\Automation\ProcessAutomationNode; use App\Models\Automation; use App\Models\AutomationRun; use App\Models\AutomationTriggerItem; class DispatchAutomationRun { + public function __construct(private AdvanceAutomationRun $advance) {} + public function __invoke(Automation $automation, AutomationTriggerItem $triggerItem): AutomationRun { - $firstNodeId = $this->findFirstRealNodeId($automation); + $targets = $this->triggerTargets($automation); $run = AutomationRun::create([ 'automation_id' => $automation->id, @@ -23,7 +24,7 @@ public function __invoke(Automation $automation, AutomationTriggerItem $triggerI 'context' => ['trigger' => $triggerItem->payload], ]); - if ($firstNodeId === null) { + if ($targets === []) { $run->update([ 'status' => Status::Failed, 'error' => ['message' => __('automations.errors.no_trigger_connection')], @@ -33,22 +34,22 @@ public function __invoke(Automation $automation, AutomationTriggerItem $triggerI return $run; } - ProcessAutomationNode::dispatch($run, $firstNodeId); + $this->advance->dispatchBranches($run, $targets); return $run; } - private function findFirstRealNodeId(Automation $automation): ?string + /** + * @return array + */ + private function triggerTargets(Automation $automation): array { $triggerNode = collect($automation->nodes ?? [])->firstWhere('type', 'trigger'); if ($triggerNode === null) { - return null; + return []; } - $connection = collect($automation->connections ?? []) - ->firstWhere('source', $triggerNode['id']); - - return $connection['target'] ?? null; + return $this->advance->targetsFor($automation, $triggerNode['id']); } } diff --git a/app/Actions/Automation/Run/TestAutomation.php b/app/Actions/Automation/Run/TestAutomation.php index b6a740c1..69a58d9b 100644 --- a/app/Actions/Automation/Run/TestAutomation.php +++ b/app/Actions/Automation/Run/TestAutomation.php @@ -6,7 +6,6 @@ use App\Enums\Automation\Run\Status; use App\Enums\Automation\Trigger\Type as TriggerType; -use App\Jobs\Automation\ProcessAutomationNode; use App\Models\Automation; use App\Models\AutomationRun; use App\Models\Post; @@ -26,12 +25,16 @@ */ class TestAutomation { + public function __construct(private AdvanceAutomationRun $advance) {} + public function __invoke(Automation $automation, bool $withRealData = false): AutomationRun { $triggerNode = collect($automation->nodes ?? [])->firstWhere('type', 'trigger'); $context = ['trigger' => $this->synthesizePayload($automation, $triggerNode ?? [])]; - $firstNodeId = $this->findFirstRealNodeId($automation, $triggerNode); + $targets = $triggerNode !== null + ? $this->advance->targetsFor($automation, $triggerNode['id']) + : []; $run = AutomationRun::create([ 'automation_id' => $automation->id, @@ -41,7 +44,7 @@ public function __invoke(Automation $automation, bool $withRealData = false): Au 'context' => $context, ]); - if ($firstNodeId === null) { + if ($targets === []) { $run->update([ 'status' => Status::Failed, 'error' => ['message' => __('automations.errors.no_trigger_connection')], @@ -51,7 +54,7 @@ public function __invoke(Automation $automation, bool $withRealData = false): Au return $run; } - ProcessAutomationNode::dispatch($run, $firstNodeId); + $this->advance->dispatchBranches($run, $targets); return $run; } @@ -104,19 +107,4 @@ private function synthesizePostPayload(Automation $automation, string $event): a ], ]); } - - /** - * @param array|null $triggerNode - */ - private function findFirstRealNodeId(Automation $automation, ?array $triggerNode): ?string - { - if ($triggerNode === null) { - return null; - } - - $connection = collect($automation->connections ?? []) - ->firstWhere('source', $triggerNode['id']); - - return $connection['target'] ?? null; - } } diff --git a/app/Actions/Automation/Trigger/DispatchPostTriggerAutomations.php b/app/Actions/Automation/Trigger/DispatchPostTriggerAutomations.php index 60d94286..9c86fb39 100644 --- a/app/Actions/Automation/Trigger/DispatchPostTriggerAutomations.php +++ b/app/Actions/Automation/Trigger/DispatchPostTriggerAutomations.php @@ -4,10 +4,10 @@ namespace App\Actions\Automation\Trigger; +use App\Actions\Automation\Run\AdvanceAutomationRun; use App\Enums\Automation\Run\Status as RunStatus; use App\Enums\Automation\Status as AutomationStatus; use App\Enums\Automation\Trigger\Type as TriggerType; -use App\Jobs\Automation\ProcessAutomationNode; use App\Models\Automation; use App\Models\AutomationRun; use App\Models\Post; @@ -25,6 +25,8 @@ */ class DispatchPostTriggerAutomations { + public function __construct(private AdvanceAutomationRun $advance) {} + public function __invoke(Post $post, TriggerType $triggerType): void { $automations = Automation::query() @@ -64,12 +66,9 @@ private function dispatchRun(Automation $automation, array $triggerNode, Post $p 'context' => $context, ]); - $connection = collect($automation->connections ?? []) - ->firstWhere('source', $triggerNode['id']); + $targets = $this->advance->targetsFor($automation, $triggerNode['id']); - $nextNodeId = $connection['target'] ?? null; - - if ($nextNodeId === null) { + if ($targets === []) { $run->update([ 'status' => RunStatus::Failed, 'error' => ['message' => __('automations.errors.no_trigger_connection')], @@ -79,6 +78,6 @@ private function dispatchRun(Automation $automation, array $triggerNode, Post $p return; } - ProcessAutomationNode::dispatch($run, $nextNodeId); + $this->advance->dispatchBranches($run, $targets); } } diff --git a/app/Events/AutomationRunUpdated.php b/app/Events/AutomationRunUpdated.php index 12212ced..13025db2 100644 --- a/app/Events/AutomationRunUpdated.php +++ b/app/Events/AutomationRunUpdated.php @@ -41,6 +41,7 @@ public function broadcastWith(): array { return [ 'run_id' => $this->run->id, + 'root_run_id' => $this->run->rootId(), 'automation_id' => $this->run->automation_id, 'status' => $this->run->status->value, ]; diff --git a/app/Http/Controllers/App/AutomationController.php b/app/Http/Controllers/App/AutomationController.php index d3bd414b..06ef742b 100644 --- a/app/Http/Controllers/App/AutomationController.php +++ b/app/Http/Controllers/App/AutomationController.php @@ -28,6 +28,7 @@ use App\Http\Resources\AutomationRunResource; use App\Http\Resources\AutomationTriggerItemResource; use App\Models\Automation; +use App\Models\AutomationNodeRun; use App\Models\AutomationRun; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; @@ -158,11 +159,21 @@ public function showRun(Automation $automation, AutomationRun $run): JsonRespons $this->authorize('view', $automation); abort_unless($run->automation_id === $automation->id, 404); - $run->load('nodeRuns'); + // Aggregate the node runs of every branch forked by a fan-out so the test + // panel shows the whole execution, not just the branch the root walked. + $rootId = $run->rootId(); + + $nodeRuns = AutomationNodeRun::query() + ->whereHas('run', fn ($query) => $query + ->where('id', $rootId) + ->orWhere('root_run_id', $rootId)) + ->orderBy('started_at') + ->orderBy('id') + ->get(); return response()->json([ 'run' => AutomationRunResource::make($run)->resolve(), - 'node_runs' => AutomationNodeRunResource::collection($run->nodeRuns)->resolve(), + 'node_runs' => AutomationNodeRunResource::collection($nodeRuns)->resolve(), ]); } } diff --git a/app/Models/AutomationRun.php b/app/Models/AutomationRun.php index 968ac3da..ee51f6c4 100644 --- a/app/Models/AutomationRun.php +++ b/app/Models/AutomationRun.php @@ -38,6 +38,16 @@ public function automation(): BelongsTo return $this->belongsTo(Automation::class); } + /** + * Id of the run that started this execution. Fan-out forks sibling runs that + * all point back at the same root, so callers can treat every branch of one + * test/trigger as a single family. The root run points at itself. + */ + public function rootId(): string + { + return $this->root_run_id ?? $this->id; + } + /** * Context for template (`{{ ... }}`) resolution: the run context plus the * automation's workflow variables, merged in-memory. Variables are NEVER diff --git a/app/Services/Automation/ExpressionResolver.php b/app/Services/Automation/ExpressionResolver.php index a1997a31..492ed53d 100644 --- a/app/Services/Automation/ExpressionResolver.php +++ b/app/Services/Automation/ExpressionResolver.php @@ -14,7 +14,28 @@ public function resolve(string $template, array $context): string '/\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}/', fn ($matches) => $this->resolveVariable($matches[1], $context), $template, - ); + ) ?? $template; + } + + /** + * Resolves `{{ ... }}` placeholders inside an already-decoded JSON structure + * (arrays/strings), resolving only string leaves. The caller json_encodes the + * result, so values are never string-interpolated into raw JSON — quotes, + * `&`, newlines etc. in the data can't corrupt the payload. + * + * @param array $context + */ + public function resolveStructured(mixed $value, array $context): mixed + { + if (is_string($value)) { + return $this->resolve($value, $context); + } + + if (is_array($value)) { + return array_map(fn ($item) => $this->resolveStructured($item, $context), $value); + } + + return $value; } private function resolveVariable(string $path, array $context): string diff --git a/database/migrations/2026_06_11_213339_add_root_run_id_to_automation_runs_table.php b/database/migrations/2026_06_11_213339_add_root_run_id_to_automation_runs_table.php new file mode 100644 index 00000000..4e6701d8 --- /dev/null +++ b/database/migrations/2026_06_11_213339_add_root_run_id_to_automation_runs_table.php @@ -0,0 +1,28 @@ +foreignUuid('root_run_id')->nullable()->after('automation_id') + ->constrained('automation_runs')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('automation_runs', function (Blueprint $table) { + $table->dropConstrainedForeignId('root_run_id'); + }); + } +}; diff --git a/lang/en/automations.php b/lang/en/automations.php index 8e7eef71..1d300167 100644 --- a/lang/en/automations.php +++ b/lang/en/automations.php @@ -14,6 +14,7 @@ 'pause' => 'Pause', 'delete' => 'Delete', 'retry' => 'Retry', + 'guide' => 'Guide', ], 'tabs' => [ @@ -40,6 +41,44 @@ 'add' => 'New variable', ], + 'guide' => [ + 'title' => 'How to use automations', + 'subtitle' => 'A quick reference for building your flow.', + 'flow_title' => 'How it works', + 'flow_text' => 'A trigger (a schedule, or a published/scheduled post) starts the flow. Actions in between fetch data, generate content, or branch on conditions. Output nodes publish posts or call webhooks.', + 'data_title' => 'Passing data between nodes', + 'data_text' => 'Reference data from earlier nodes in any field. Type the name in double braces, for example:', + 'refs' => [ + 'trigger_post' => 'The post that fired a post trigger', + 'fetched_title' => 'Current RSS item / HTTP response', + 'fetched_link' => 'Its link', + 'generated' => 'The AI-generated post', + 'now' => 'Current date & time', + ], + 'vars_title' => 'Variables', + 'vars_text' => 'Define reusable values (API keys, base URLs) in the Variables tab, then reference them by key. Secrets are stored encrypted.', + 'tip_text' => 'Run a Test to see exactly what data each node outputs.', + ], + + 'expr' => [ + 'trigger_event' => 'Trigger event name', + 'trigger_fired_at' => 'When the trigger fired', + 'trigger_post_id' => 'Triggering post ID', + 'trigger_post_content' => 'Triggering post content', + 'trigger_post_status' => 'Triggering post status', + 'trigger_post_scheduled_at' => 'When the post is scheduled', + 'trigger_post_published_at' => 'When the post was published', + 'fetched_title' => 'Fetched item title', + 'fetched_link' => 'Fetched item link', + 'fetched_description' => 'Fetched item description', + 'fetched_pubdate' => 'Fetched item publish date', + 'fetched_http' => 'Fetched HTTP item (append a field)', + 'generated_content' => 'AI-generated post content', + 'generated_post_url' => 'AI-generated post URL', + 'variable' => 'Workflow variable', + 'now' => 'Current date & time', + ], + 'test' => [ 'description' => 'Runs the automation end-to-end using a synthesized trigger payload. Useful for validating each node without waiting for the real schedule or feed.', 'starting' => 'Starting test run…', @@ -121,6 +160,9 @@ 'config' => [ 'select_placeholder' => 'Select…', 'invalid_json' => 'This isn’t valid JSON yet.', + 'expand_editor' => 'Expand editor', + 'minimize_editor' => 'Minimize', + 'editing_in_panel' => 'Editing in the side panel', 'trigger' => [ 'type' => 'Trigger type', @@ -175,6 +217,7 @@ 'social_accounts_empty' => 'No connected social accounts. Connect one first.', 'target_slide_count' => 'Slides to generate', 'prompt_template' => 'Prompt template', + 'prompt_template_hint' => 'Type {{ to insert data from earlier steps.', 'include_image' => 'Include image', 'include_image_hint' => 'Generate an AI image for this post', ], @@ -243,12 +286,14 @@ 'header_name' => 'Header name', 'header_value' => 'Value', 'add_header' => 'Add header', - 'polling_section' => 'Polling (optional)', - 'polling_hint' => 'Leave blank to use the whole response as a single payload. Fill in to extract an array of items and spawn one run per item.', + 'polling_section' => 'List & deduplication (optional)', + 'polling_hint' => 'When the response is a list, each item runs the workflow separately. A single object runs once.', 'items_path' => 'Items path', + 'items_path_hint' => 'Leave blank if the response is already an array. Use a dotted path (e.g. data.items) for a nested array, or * for an object keyed by id.', 'item_key_path' => 'Item key path', - 'item_date_path' => 'Item date path (optional)', - 'item_date_path_hint' => 'JSON path to the item timestamp. When set, only items newer than the previous fetch are forwarded — prevents the first fetch from flooding downstream nodes.', + 'item_key_path_hint' => 'JSON path to a unique id (e.g. id). Items already seen are skipped, so a feed without dates still only forwards new entries.', + 'item_date_path' => 'Item date path', + 'item_date_path_hint' => 'JSON path to the item timestamp (e.g. published_at). Preferred over the key path when available. The first poll records the baseline and forwards nothing, so an existing feed never floods on day one.', ], ], diff --git a/lang/es/automations.php b/lang/es/automations.php index 2b66500a..f7614eab 100644 --- a/lang/es/automations.php +++ b/lang/es/automations.php @@ -14,6 +14,7 @@ 'pause' => 'Pausar', 'delete' => 'Eliminar', 'retry' => 'Reintentar', + 'guide' => 'Guía', ], 'tabs' => [ @@ -40,6 +41,44 @@ 'add' => 'Nueva variable', ], + 'guide' => [ + 'title' => 'Cómo usar automatizaciones', + 'subtitle' => 'Una referencia rápida para construir tu flujo.', + 'flow_title' => 'Cómo funciona', + 'flow_text' => 'Un disparador (una programación, o un post publicado/programado) inicia el flujo. Las acciones intermedias obtienen datos, generan contenido o se ramifican por condiciones. Los nodos de salida publican posts o llaman webhooks.', + 'data_title' => 'Pasar datos entre nodos', + 'data_text' => 'Referencia datos de nodos anteriores en cualquier campo. Escribe el nombre entre llaves dobles, por ejemplo:', + 'refs' => [ + 'trigger_post' => 'El post que disparó un disparador de post', + 'fetched_title' => 'Elemento actual del RSS / respuesta HTTP', + 'fetched_link' => 'Su enlace', + 'generated' => 'El post generado por IA', + 'now' => 'Fecha y hora actuales', + ], + 'vars_title' => 'Variables', + 'vars_text' => 'Define valores reutilizables (API keys, URLs base) en la pestaña Variables y referéncialos por clave. Los secretos se almacenan cifrados.', + 'tip_text' => 'Ejecuta una Prueba para ver exactamente qué produce cada nodo.', + ], + + 'expr' => [ + 'trigger_event' => 'Nombre del evento del disparador', + 'trigger_fired_at' => 'Cuándo se disparó', + 'trigger_post_id' => 'ID del post que disparó', + 'trigger_post_content' => 'Contenido del post que disparó', + 'trigger_post_status' => 'Estado del post que disparó', + 'trigger_post_scheduled_at' => 'Cuándo está programado el post', + 'trigger_post_published_at' => 'Cuándo se publicó el post', + 'fetched_title' => 'Título del ítem obtenido', + 'fetched_link' => 'Enlace del ítem obtenido', + 'fetched_description' => 'Descripción del ítem obtenido', + 'fetched_pubdate' => 'Fecha de publicación del ítem obtenido', + 'fetched_http' => 'Ítem HTTP obtenido (añade un campo)', + 'generated_content' => 'Contenido del post generado por IA', + 'generated_post_url' => 'URL del post generado por IA', + 'variable' => 'Variable del flujo', + 'now' => 'Fecha y hora actuales', + ], + 'test' => [ 'description' => 'Ejecuta la automatización de punta a punta usando un payload de disparo sintético. Útil para validar cada nodo sin esperar el cronograma o el feed real.', 'starting' => 'Iniciando ejecución de prueba…', @@ -121,6 +160,9 @@ 'config' => [ 'select_placeholder' => 'Selecciona…', 'invalid_json' => 'Esto aún no es un JSON válido.', + 'expand_editor' => 'Expandir editor', + 'minimize_editor' => 'Minimizar', + 'editing_in_panel' => 'Editando en el panel lateral', 'trigger' => [ 'type' => 'Tipo de disparador', @@ -175,6 +217,7 @@ 'social_accounts_empty' => 'Sin cuentas sociales conectadas. Conecta una primero.', 'target_slide_count' => 'Diapositivas a generar', 'prompt_template' => 'Plantilla de prompt', + 'prompt_template_hint' => 'Escribe {{ para insertar datos de pasos anteriores.', 'include_image' => 'Incluir imagen', 'include_image_hint' => 'Generar una imagen con IA para esta publicación', ], @@ -243,12 +286,14 @@ 'header_name' => 'Nombre del header', 'header_value' => 'Valor', 'add_header' => 'Agregar header', - 'polling_section' => 'Polling (opcional)', - 'polling_hint' => 'Deja vacío para usar la respuesta completa como un solo payload. Rellena para extraer un array de ítems y disparar un run por ítem.', + 'polling_section' => 'Lista y deduplicación (opcional)', + 'polling_hint' => 'Cuando la respuesta es una lista, cada ítem ejecuta el flujo por separado. Un objeto único se ejecuta una vez.', 'items_path' => 'Ruta de ítems', + 'items_path_hint' => 'Deja vacío si la respuesta ya es un array. Usa una ruta con puntos (ej: data.items) para un array anidado, o * para un objeto con claves por id.', 'item_key_path' => 'Ruta de clave del ítem', - 'item_date_path' => 'Ruta de fecha del ítem (opcional)', - 'item_date_path_hint' => 'Ruta JSON al timestamp del ítem. Cuando se define, solo los ítems más nuevos que la última obtención se reenvían — evita que la primera obtención inunde los siguientes nodos.', + 'item_key_path_hint' => 'Ruta JSON a un id único (ej: id). Los ítems ya vistos se omiten, así un feed sin fechas aún reenvía solo los nuevos.', + 'item_date_path' => 'Ruta de fecha del ítem', + 'item_date_path_hint' => 'Ruta JSON al timestamp del ítem (ej: published_at). Preferido sobre la ruta de clave cuando existe. La primera obtención registra el punto de partida y no reenvía nada, así un feed existente nunca inunda el primer día.', ], ], diff --git a/lang/pt-BR/automations.php b/lang/pt-BR/automations.php index 95ff93e7..2d6f9ec4 100644 --- a/lang/pt-BR/automations.php +++ b/lang/pt-BR/automations.php @@ -14,6 +14,7 @@ 'pause' => 'Pausar', 'delete' => 'Excluir', 'retry' => 'Tentar novamente', + 'guide' => 'Guia', ], 'tabs' => [ @@ -40,6 +41,44 @@ 'add' => 'Nova variável', ], + 'guide' => [ + 'title' => 'Como usar automações', + 'subtitle' => 'Uma referência rápida pra montar seu fluxo.', + 'flow_title' => 'Como funciona', + 'flow_text' => 'Um trigger (um agendamento, ou um post publicado/agendado) inicia o fluxo. As ações no meio buscam dados, geram conteúdo ou ramificam por condições. Os nós de saída publicam posts ou chamam webhooks.', + 'data_title' => 'Passando dados entre nós', + 'data_text' => 'Referencie dados de nós anteriores em qualquer campo. Escreva o nome entre chaves duplas, por exemplo:', + 'refs' => [ + 'trigger_post' => 'O post que disparou um trigger de post', + 'fetched_title' => 'Item atual do RSS / resposta HTTP', + 'fetched_link' => 'O link dele', + 'generated' => 'O post gerado pela IA', + 'now' => 'Data e hora atuais', + ], + 'vars_title' => 'Variáveis', + 'vars_text' => 'Defina valores reutilizáveis (API keys, base URLs) na aba Variáveis e referencie pela chave. Segredos são armazenados encriptados.', + 'tip_text' => 'Rode um Teste pra ver exatamente o que cada nó produz.', + ], + + 'expr' => [ + 'trigger_event' => 'Nome do evento do gatilho', + 'trigger_fired_at' => 'Quando o gatilho disparou', + 'trigger_post_id' => 'ID do post que disparou', + 'trigger_post_content' => 'Conteúdo do post que disparou', + 'trigger_post_status' => 'Status do post que disparou', + 'trigger_post_scheduled_at' => 'Quando o post está agendado', + 'trigger_post_published_at' => 'Quando o post foi publicado', + 'fetched_title' => 'Título do item buscado', + 'fetched_link' => 'Link do item buscado', + 'fetched_description' => 'Descrição do item buscado', + 'fetched_pubdate' => 'Data de publicação do item buscado', + 'fetched_http' => 'Item HTTP buscado (adicione um campo)', + 'generated_content' => 'Conteúdo do post gerado por IA', + 'generated_post_url' => 'URL do post gerado por IA', + 'variable' => 'Variável do fluxo', + 'now' => 'Data e hora atuais', + ], + 'test' => [ 'description' => 'Executa a automação ponta a ponta usando um payload de gatilho sintético. Útil pra validar cada nó sem esperar o agendamento ou o feed real.', 'starting' => 'Iniciando execução de teste…', @@ -121,6 +160,9 @@ 'config' => [ 'select_placeholder' => 'Selecione…', 'invalid_json' => 'Isto ainda não é um JSON válido.', + 'expand_editor' => 'Expandir editor', + 'minimize_editor' => 'Minimizar', + 'editing_in_panel' => 'Editando no painel ao lado', 'trigger' => [ 'type' => 'Tipo de trigger', @@ -175,6 +217,7 @@ 'social_accounts_empty' => 'Nenhuma conta social conectada. Conecte uma primeiro.', 'target_slide_count' => 'Slides a gerar', 'prompt_template' => 'Template do prompt', + 'prompt_template_hint' => 'Digite {{ para inserir dados das etapas anteriores.', 'include_image' => 'Incluir imagem', 'include_image_hint' => 'Gerar uma imagem com IA para esta publicação', ], @@ -243,12 +286,14 @@ 'header_name' => 'Nome do header', 'header_value' => 'Valor', 'add_header' => 'Adicionar header', - 'polling_section' => 'Polling (opcional)', - 'polling_hint' => 'Deixe vazio para usar a resposta inteira como payload único. Preencha para extrair um array de itens e disparar um run por item.', + 'polling_section' => 'Lista e deduplicação (opcional)', + 'polling_hint' => 'Quando a resposta é uma lista, cada item roda o fluxo separadamente. Um objeto único roda uma vez.', 'items_path' => 'Caminho dos itens', + 'items_path_hint' => 'Deixe vazio se a resposta já é um array. Use um caminho com ponto (ex: data.items) para um array aninhado, ou * para um objeto com chaves por id.', 'item_key_path' => 'Caminho da chave do item', - 'item_date_path' => 'Caminho da data do item (opcional)', - 'item_date_path_hint' => 'Caminho JSON pro timestamp do item. Quando definido, só items mais novos que a última busca são encaminhados — evita que a primeira busca inunde os próximos nós.', + 'item_key_path_hint' => 'Caminho JSON pra um id único (ex: id). Itens já vistos são ignorados, então um feed sem datas ainda encaminha só os novos.', + 'item_date_path' => 'Caminho da data do item', + 'item_date_path_hint' => 'Caminho JSON pro timestamp do item (ex: published_at). Preferido sobre o caminho da chave quando existe. A primeira busca registra o ponto de partida e não encaminha nada, então um feed existente nunca inunda no primeiro dia.', ], ], diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index dca75aee..6c7ba6ee 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -77,6 +77,7 @@ const mainNavItems = computed(() => [ title: trans('sidebar.automations'), href: automations.url(), icon: IconBolt, + badge: 'Beta', }, ]); diff --git a/resources/js/components/CodeEditor.vue b/resources/js/components/CodeEditor.vue index 7aaa605d..486c1824 100644 --- a/resources/js/components/CodeEditor.vue +++ b/resources/js/components/CodeEditor.vue @@ -1,26 +1,42 @@ diff --git a/resources/js/components/NavMain.vue b/resources/js/components/NavMain.vue index cb2e3724..f0cd0acd 100644 --- a/resources/js/components/NavMain.vue +++ b/resources/js/components/NavMain.vue @@ -1,6 +1,7 @@ + + diff --git a/resources/js/components/automations/TestRunPanel.vue b/resources/js/components/automations/TestRunPanel.vue index c25d0df7..1488cc68 100644 --- a/resources/js/components/automations/TestRunPanel.vue +++ b/resources/js/components/automations/TestRunPanel.vue @@ -58,12 +58,14 @@ const fetchRun = async (runId: string): Promise => { // Subscribe once to the automation's private channel. The backend broadcasts // a tiny `{ run_id, status }` payload on every run/node update; we refetch the // full state only when the event is for our active run. Zero polling. -useAutomationEcho<{ run_id: string; status: string }>( +useAutomationEcho<{ run_id: string; root_run_id: string; status: string }>( props.automationId, '.automation.run.updated', (payload) => { - if (payload.run_id === activeRunId.value) { - fetchRun(payload.run_id); + // Refetch when any branch of the active test family advances — a fan-out + // forks sibling runs whose root_run_id points back at the run we started. + if (payload.root_run_id === activeRunId.value) { + fetchRun(activeRunId.value); } }, ); diff --git a/resources/js/components/automations/config/GenerateNodeConfig.vue b/resources/js/components/automations/config/GenerateNodeConfig.vue index ec0481a5..7f7abe95 100644 --- a/resources/js/components/automations/config/GenerateNodeConfig.vue +++ b/resources/js/components/automations/config/GenerateNodeConfig.vue @@ -3,11 +3,11 @@ import { usePage } from '@inertiajs/vue3'; import { computed, ref, watch } from 'vue'; import ChannelConfigurator from '@/components/ChannelConfigurator.vue'; +import CodeEditor from '@/components/CodeEditor.vue'; import InputError from '@/components/InputError.vue'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; -import { Textarea } from '@/components/ui/textarea'; import { getMediaRulesForContentType } from '@/composables/useMediaRules'; import { getMediaIncompatibilityReason, getPlatformMetaIssue } from '@/composables/usePostCompliance'; import type { PinterestBoard } from '@/types'; @@ -310,7 +310,16 @@ const channels = computed(() =>
-