Add expression autocomplete, side-panel editor, and richer HTTP fetch

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).
This commit is contained in:
Paulo Castellano 2026-06-12 11:31:58 -03:00
parent 9a692b4608
commit 448ae73389
34 changed files with 1450 additions and 218 deletions

View file

@ -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) {

View file

@ -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<int, mixed>|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<string, mixed> $config
* @param array<int, mixed> $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<int, mixed> $items
* @return array<int, mixed>
*/
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<int, mixed> $items
* @return array<int, mixed>
*/
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<string, mixed> $config
* @param array<string, mixed> $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<int, array<string, mixed>> $items
* @param array<int, mixed> $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 === '') {

View file

@ -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) {

View file

@ -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<int, string>
*/
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<int, string> $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);
}
}

View file

@ -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<int, string>
*/
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']);
}
}

View file

@ -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<string, mixed>|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;
}
}

View file

@ -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);
}
}

View file

@ -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,
];

View file

@ -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(),
]);
}
}

View file

@ -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

View file

@ -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<string, mixed> $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

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('automation_runs', function (Blueprint $table) {
// Links every run forked by a fan-out back to the run that started the
// execution, so the editor test panel can aggregate all branches of a
// single test under one root. Null on the root run itself.
$table->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');
});
}
};

View file

@ -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.',
],
],

View file

@ -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.',
],
],

View file

@ -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.',
],
],

View file

@ -77,6 +77,7 @@ const mainNavItems = computed<NavItem[]>(() => [
title: trans('sidebar.automations'),
href: automations.url(),
icon: IconBolt,
badge: 'Beta',
},
]);

View file

@ -1,26 +1,42 @@
<script setup lang="ts">
import { autocompletion } from '@codemirror/autocomplete';
import { indentWithTab } from '@codemirror/commands';
import { json } from '@codemirror/lang-json';
import { EditorState } from '@codemirror/state';
import { type Extension, EditorState } from '@codemirror/state';
import { EditorView, keymap, placeholder as placeholderExt } from '@codemirror/view';
import { IconCopy } from '@tabler/icons-vue';
import { IconArrowsMaximize, IconArrowsMinimize, IconCopy } from '@tabler/icons-vue';
import { basicSetup } from 'codemirror';
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { computed, inject, onBeforeUnmount, onMounted, type Ref, ref, watch } from 'vue';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
type ExpressionSuggestion,
expressionCompletionSource,
} from '@/composables/useExpressionCompletions';
import debounce from '@/debounce';
import { copyToClipboard } from '@/lib/utils';
const props = withDefaults(
defineProps<{
modelValue: string;
language?: 'json';
language?: 'json' | 'text';
readOnly?: boolean;
placeholder?: string;
expandable?: boolean;
label?: string;
}>(),
{
language: 'json',
readOnly: false,
placeholder: '',
expandable: false,
label: '',
},
);
@ -28,6 +44,34 @@ const emit = defineEmits<{
'update:modelValue': [value: string];
}>();
// Provided by the automation editor (Form.vue) for the currently selected node.
// Empty elsewhere, so the editor degrades to a plain field with no `{{ ... }}`
// suggestions.
const expressionCompletions = inject<Ref<ExpressionSuggestion[]>>(
'automationExpressionCompletions',
ref([]),
);
// Shared with Form.vue: toggling `.active` slides out the side panel (and shifts
// the sidebar to make room). Null outside the automation editor, so `expandable`
// only takes effect where Form provides the panel target.
const expandedPanel = inject<{ active: boolean } | null>('automationExpandedEditor', null);
const isExpanded = ref(false);
const canExpand = computed(() => props.expandable && expandedPanel !== null);
watch(isExpanded, (open) => {
if (expandedPanel) {
expandedPanel.active = open;
}
});
const onKeydown = (event: KeyboardEvent): void => {
if (event.key === 'Escape' && isExpanded.value) {
isExpanded.value = false;
}
};
const editorContainer = ref<HTMLElement>();
let view: EditorView | null = null;
@ -35,8 +79,10 @@ const debouncedEmit = debounce((value: string) => {
emit('update:modelValue', value);
}, 250);
const languageExtension = () => {
const languageExtension = (): Extension => {
switch (props.language) {
case 'text':
return [];
case 'json':
default:
return json();
@ -106,6 +152,11 @@ onMounted(() => {
keymap.of([indentWithTab]),
languageExtension(),
EditorView.lineWrapping,
autocompletion({
override: [expressionCompletionSource(() => expressionCompletions.value)],
activateOnTyping: true,
icons: false,
}),
lightTheme,
updateListener,
];
@ -126,6 +177,10 @@ onMounted(() => {
}),
parent: editorContainer.value,
});
if (props.expandable) {
window.addEventListener('keydown', onKeydown);
}
});
watch(
@ -153,22 +208,89 @@ onBeforeUnmount(() => {
debouncedEmit.cancel();
view?.destroy();
view = null;
window.removeEventListener('keydown', onKeydown);
// Switching nodes unmounts the editor; make sure the side panel collapses
// instead of lingering empty.
if (isExpanded.value && expandedPanel) {
expandedPanel.active = false;
}
});
</script>
<template>
<div class="relative h-full w-full">
<div ref="editorContainer" class="code-editor h-full w-full" />
<div class="group relative h-full w-full">
<div v-show="!isExpanded" ref="editorContainer" class="code-editor h-full w-full" />
<!-- While the side panel is open, the inline field collapses to a hint
(clicking it closes the panel) instead of duplicating the editor. -->
<button
v-if="modelValue"
v-if="isExpanded"
type="button"
class="absolute right-2 top-2 z-10 inline-flex size-7 items-center justify-center rounded-md border-2 border-foreground bg-card shadow-[1px_1px_0_var(--foreground)] transition hover:-translate-x-px hover:-translate-y-px hover:shadow-[2px_2px_0_var(--foreground)] active:translate-x-0 active:translate-y-0 active:shadow-[0_0_0_var(--foreground)]"
:title="$t('common.actions.copy')"
:aria-label="$t('common.actions.copy')"
@click="copyToClipboard(modelValue)"
class="flex h-full w-full flex-col items-center justify-center gap-1.5 rounded-md border-2 border-dashed border-foreground/25 bg-card/40 px-3 text-center text-xs font-medium text-foreground/60 transition hover:border-foreground/40 hover:text-foreground/80"
@click="isExpanded = false"
>
<IconCopy class="size-3.5" stroke-width="2.5" />
<IconArrowsMaximize class="size-4" stroke-width="2.5" />
{{ $t('automations.config.editing_in_panel') }}
</button>
<TooltipProvider :delay-duration="200">
<div
v-if="!isExpanded && (canExpand || modelValue)"
class="absolute right-2 top-2 z-10 flex gap-0.5 rounded-lg border-2 border-foreground bg-card p-0.5 opacity-0 shadow-[1px_1px_0_var(--foreground)] transition-opacity duration-150 group-hover:opacity-100 focus-within:opacity-100"
>
<Tooltip v-if="canExpand">
<TooltipTrigger as-child>
<button
type="button"
class="inline-flex size-6 items-center justify-center rounded-md text-foreground/70 transition-colors hover:bg-foreground/10 hover:text-foreground"
:aria-label="$t('automations.config.expand_editor')"
@click="isExpanded = true"
>
<IconArrowsMaximize class="size-3.5" stroke-width="2.5" />
</button>
</TooltipTrigger>
<TooltipContent>{{ $t('automations.config.expand_editor') }}</TooltipContent>
</Tooltip>
<Tooltip v-if="modelValue">
<TooltipTrigger as-child>
<button
type="button"
class="inline-flex size-6 items-center justify-center rounded-md text-foreground/70 transition-colors hover:bg-foreground/10 hover:text-foreground"
:aria-label="$t('common.actions.copy')"
@click="copyToClipboard(modelValue)"
>
<IconCopy class="size-3.5" stroke-width="2.5" />
</button>
</TooltipTrigger>
<TooltipContent>{{ $t('common.actions.copy') }}</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
<Teleport v-if="canExpand && isExpanded" to="#automation-expanded-editor">
<div class="flex shrink-0 items-center justify-between gap-2 border-b-2 border-foreground/10 px-3 py-3">
<span class="truncate text-sm font-bold">{{ label || $t('automations.config.expand_editor') }}</span>
<TooltipProvider :delay-duration="200">
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon-sm" :aria-label="$t('automations.config.minimize_editor')" @click="isExpanded = false">
<IconArrowsMinimize class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('automations.config.minimize_editor') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div class="min-h-0 flex-1 p-4">
<CodeEditor
:model-value="modelValue"
:language="language"
:placeholder="placeholder"
:read-only="readOnly"
@update:model-value="emit('update:modelValue', $event)"
/>
</div>
</Teleport>
</div>
</template>

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { Badge } from '@/components/ui/badge';
import {
SidebarGroup,
SidebarGroupLabel,
@ -36,6 +37,13 @@ const { urlIsActive } = useActiveUrl();
<span>{{ item.title }}</span>
</Link>
</SidebarMenuButton>
<Badge
v-if="item.badge"
variant="warning"
class="pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 px-1.5 group-data-[collapsible=icon]:hidden"
>
{{ item.badge }}
</Badge>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>

View file

@ -0,0 +1,72 @@
<script setup lang="ts">
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
const open = defineModel<boolean>('open', { default: false });
// The `{{ ... }}` snippets stay literal (they're code); only the short
// descriptions are translated.
const dataRefs = [
{ code: '{{ trigger.post.content }}', descKey: 'automations.guide.refs.trigger_post' },
{ code: '{{ fetched.title }}', descKey: 'automations.guide.refs.fetched_title' },
{ code: '{{ fetched.link }}', descKey: 'automations.guide.refs.fetched_link' },
{ code: '{{ generated.content }}', descKey: 'automations.guide.refs.generated' },
{ code: '{{ now }}', descKey: 'automations.guide.refs.now' },
];
const variableExample = '{{ variables.API_KEY }}';
</script>
<template>
<Sheet v-model:open="open">
<SheetContent side="right" class="w-full overflow-y-auto sm:max-w-md">
<SheetHeader>
<SheetTitle>{{ $t('automations.guide.title') }}</SheetTitle>
<SheetDescription>{{ $t('automations.guide.subtitle') }}</SheetDescription>
</SheetHeader>
<div class="space-y-6 px-4 pb-8">
<section class="space-y-1.5">
<h3 class="text-[11px] font-black uppercase tracking-widest text-foreground/50">
{{ $t('automations.guide.flow_title') }}
</h3>
<p class="text-sm text-foreground/70">{{ $t('automations.guide.flow_text') }}</p>
</section>
<section class="space-y-2">
<h3 class="text-[11px] font-black uppercase tracking-widest text-foreground/50">
{{ $t('automations.guide.data_title') }}
</h3>
<p class="text-sm text-foreground/70">{{ $t('automations.guide.data_text') }}</p>
<div class="space-y-1.5">
<div
v-for="ref in dataRefs"
:key="ref.code"
class="flex flex-col gap-0.5 rounded-lg border-2 border-foreground/15 bg-card/50 p-2.5"
>
<code class="font-mono text-xs font-bold text-foreground">{{ ref.code }}</code>
<span class="text-xs text-foreground/55">{{ $t(ref.descKey) }}</span>
</div>
</div>
</section>
<section class="space-y-2">
<h3 class="text-[11px] font-black uppercase tracking-widest text-foreground/50">
{{ $t('automations.guide.vars_title') }}
</h3>
<p class="text-sm text-foreground/70">{{ $t('automations.guide.vars_text') }}</p>
<code class="inline-block rounded-lg border-2 border-foreground/15 bg-card/50 px-2.5 py-1.5 font-mono text-xs font-bold text-foreground">{{ variableExample }}</code>
</section>
<section class="rounded-xl border-2 border-foreground bg-amber-50 p-3 shadow-[3px_3px_0_var(--foreground)]">
<p class="text-sm font-medium text-foreground">💡 {{ $t('automations.guide.tip_text') }}</p>
</section>
</div>
</SheetContent>
</Sheet>
</template>

View file

@ -58,12 +58,14 @@ const fetchRun = async (runId: string): Promise<void> => {
// 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);
}
},
);

View file

@ -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<Channel[]>(() =>
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.generate.prompt_template') }}</label>
<Textarea v-model="local.prompt_template" :rows="6" placeholder="Write a social media post about {{ trigger.title }}…" />
<div class="h-40">
<CodeEditor
v-model="local.prompt_template"
language="text"
expandable
:label="$t('automations.config.generate.prompt_template')"
placeholder="Write a social media post about {{ trigger.title }}…"
/>
</div>
<p class="mt-1 text-xs text-foreground/50">{{ $t('automations.config.generate.prompt_template_hint') }}</p>
<InputError :message="errors?.prompt_template" class="mt-1" />
</div>
</div>

View file

@ -92,7 +92,6 @@ const removeHeader = (index: number): void => {
watch(local, (val) => emit('update', val), { deep: true });
const supportsBody = computed(() => ['POST', 'PUT', 'PATCH'].includes(local.value.method));
const isPollingMode = computed(() => local.value.items_path.trim() !== '');
const isBodyJsonInvalid = computed(() => {
const value = local.value.body_template.trim();
@ -183,7 +182,13 @@ const isBodyJsonInvalid = computed(() => {
<div v-if="supportsBody">
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.http_request.body_template') }}</label>
<div class="h-36">
<CodeEditor v-model="local.body_template" language="json" placeholder='{"id": "{{ trigger.post.id }}"}' />
<CodeEditor
v-model="local.body_template"
language="json"
expandable
:label="$t('automations.config.http_request.body_template')"
placeholder='{"id": "{{ trigger.post.id }}"}'
/>
</div>
<p v-if="isBodyJsonInvalid" class="mt-1 text-xs text-amber-600 dark:text-amber-500">
{{ $t('automations.config.invalid_json') }}
@ -234,21 +239,21 @@ const isBodyJsonInvalid = computed(() => {
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.http_request.items_path') }}</label>
<Input v-model="local.items_path" placeholder="data.items" />
<p class="mt-1 text-xs text-foreground/50">{{ $t('automations.config.http_request.items_path_hint') }}</p>
<InputError :message="errors?.items_path" class="mt-1" />
</div>
<template v-if="isPollingMode">
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.http_request.item_key_path') }}</label>
<Input v-model="local.item_key_path" placeholder="id" />
<InputError :message="errors?.item_key_path" class="mt-1" />
</div>
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.http_request.item_date_path') }}</label>
<Input v-model="local.item_date_path" placeholder="published_at" />
<InputError :message="errors?.item_date_path" class="mt-1" />
<p class="mt-1 text-xs text-foreground/50">{{ $t('automations.config.http_request.item_date_path_hint') }}</p>
</div>
</template>
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.http_request.item_date_path') }}</label>
<Input v-model="local.item_date_path" placeholder="published_at" />
<p class="mt-1 text-xs text-foreground/50">{{ $t('automations.config.http_request.item_date_path_hint') }}</p>
<InputError :message="errors?.item_date_path" class="mt-1" />
</div>
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.http_request.item_key_path') }}</label>
<Input v-model="local.item_key_path" placeholder="id" />
<p class="mt-1 text-xs text-foreground/50">{{ $t('automations.config.http_request.item_key_path_hint') }}</p>
<InputError :message="errors?.item_key_path" class="mt-1" />
</div>
</div>
</div>
</div>

View file

@ -16,9 +16,9 @@ import {
normalizeScheduleData,
timezoneAbbr as getTimezoneAbbr,
userTimezone as getUserTimezone,
type ScheduleData,
} from '@/components/automations/schedule-summary';
import { ScheduleField } from '@/types/automation/schedule-field';
import type { ScheduleData } from '@/types/automation/schedule-data';
import { TriggerType, type TriggerTypeValue } from '@/types/automation/trigger-type';
const props = defineProps<{

View file

@ -76,7 +76,13 @@ const isPayloadJsonInvalid = computed(() => {
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.webhook.payload_template') }}</label>
<div class="h-40">
<CodeEditor v-model="local.payload_template" language="json" placeholder='{"content": "{{ post.content }}"}' />
<CodeEditor
v-model="local.payload_template"
language="json"
expandable
:label="$t('automations.config.webhook.payload_template')"
placeholder='{"content": "{{ post.content }}"}'
/>
</div>
<p v-if="isPayloadJsonInvalid" class="mt-1 text-xs text-amber-600 dark:text-amber-500">
{{ $t('automations.config.invalid_json') }}

View file

@ -128,7 +128,7 @@ export const humanSchedule = (data: ScheduleData): string => {
}
case ScheduleField.Hours: {
const n = num(data, 'schedule_hours_interval', 1, 1, 23);
return transChoice(summaryKey('every_n_hours'), n, { minute });
return transChoice(summaryKey('every_n_hours'), n, { minute: pad2(minute) });
}
case ScheduleField.Days: {
const n = num(data, 'schedule_days_interval', 1, 1, 31);

View file

@ -0,0 +1,168 @@
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import { trans } from 'laravel-vue-i18n';
import type { AutomationVariable } from '@/types/automation/automation';
export interface ExpressionSuggestion {
label: string;
info: string;
}
interface GraphNode {
id: string;
type?: string;
data?: Record<string, unknown>;
}
interface GraphEdge {
source: string;
target: string;
}
const suggestion = (label: string, key: string): ExpressionSuggestion => ({
label,
info: trans(`automations.expr.${key}`),
});
/**
* Expressions a node makes available to everything downstream — mirrors the
* `output` each node action merges into the run context on the backend.
*/
const providedBy = (node: GraphNode): ExpressionSuggestion[] => {
switch (node.type) {
case 'trigger': {
const triggerType = node.data?.trigger_type;
if (triggerType === 'post_published' || triggerType === 'post_scheduled') {
return [
suggestion('trigger.post.id', 'trigger_post_id'),
suggestion('trigger.post.content', 'trigger_post_content'),
suggestion('trigger.post.status', 'trigger_post_status'),
suggestion('trigger.post.scheduled_at', 'trigger_post_scheduled_at'),
suggestion('trigger.post.published_at', 'trigger_post_published_at'),
];
}
return [suggestion('trigger.event', 'trigger_event'), suggestion('trigger.fired_at', 'trigger_fired_at')];
}
case 'fetch_rss':
return [
suggestion('fetched.title', 'fetched_title'),
suggestion('fetched.link', 'fetched_link'),
suggestion('fetched.description', 'fetched_description'),
suggestion('fetched.pubDate', 'fetched_pubdate'),
];
case 'http_request':
return [suggestion('fetched', 'fetched_http')];
case 'generate':
return [
suggestion('generated.content', 'generated_content'),
suggestion('generated.post_url', 'generated_post_url'),
];
default:
return [];
}
};
/**
* Every node upstream of `nodeId`, following connections backwards. These are
* the only nodes whose output is in scope when the given node runs.
*/
const ancestorNodes = (nodeId: string, nodes: GraphNode[], edges: GraphEdge[]): GraphNode[] => {
const byId = new Map(nodes.map((node) => [node.id, node]));
const incoming = new Map<string, string[]>();
edges.forEach((edge) => {
const sources = incoming.get(edge.target) ?? [];
sources.push(edge.source);
incoming.set(edge.target, sources);
});
const seen = new Set<string>();
const stack = [nodeId];
const result: GraphNode[] = [];
while (stack.length > 0) {
const current = stack.pop()!;
for (const source of incoming.get(current) ?? []) {
if (seen.has(source)) {
continue;
}
seen.add(source);
const node = byId.get(source);
if (node) {
result.push(node);
stack.push(source);
}
}
}
return result;
};
/**
* The `{{ ... }}` expressions available inside `nodeId`'s config: everything its
* upstream nodes provide, plus the workflow variables and `now`.
*/
export const buildExpressionCatalog = (
nodeId: string | null,
nodes: GraphNode[],
edges: GraphEdge[],
variables: AutomationVariable[],
): ExpressionSuggestion[] => {
const suggestions: ExpressionSuggestion[] = [];
if (nodeId) {
ancestorNodes(nodeId, nodes, edges).forEach((node) => {
suggestions.push(...providedBy(node));
});
}
variables
.filter((variable) => variable.key?.trim())
.forEach((variable) => {
suggestions.push({ label: `variables.${variable.key}`, info: trans('automations.expr.variable') });
});
suggestions.push(suggestion('now', 'now'));
const seen = new Set<string>();
return suggestions.filter((item) => (seen.has(item.label) ? false : seen.add(item.label)));
};
/**
* CodeMirror completion source that only fires inside a `{{ ... }}` block —
* matching how n8n scopes completions to its `Resolvable` syntax node. The typed
* path is replaced with the chosen expression, closing the braces if needed.
*/
export const expressionCompletionSource =
(getSuggestions: () => ExpressionSuggestion[]) =>
(context: CompletionContext): CompletionResult | null => {
const before = context.matchBefore(/\{\{\s*[\w.]*$/);
if (!before) {
return null;
}
const suggestions = getSuggestions();
if (suggestions.length === 0) {
return null;
}
// The path token starts after `{{` and any following whitespace.
const openMarker = before.text.match(/^\{\{\s*/)?.[0] ?? '{{';
const from = before.from + openMarker.length;
const options: Completion[] = suggestions.map((item) => ({
label: item.label,
info: item.info,
type: 'variable',
apply: (view, _completion, applyFrom, applyTo) => {
const trailing = view.state.sliceDoc(applyTo, applyTo + 3);
const hasClose = /^\s*\}\}/.test(trailing);
const insert = hasClose ? item.label : `${item.label} }}`;
view.dispatch({
changes: { from: applyFrom, to: applyTo, insert },
selection: { anchor: applyFrom + insert.length },
});
},
}));
return { from, options, filter: true };
};

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Head, Link, router } from '@inertiajs/vue3';
import { IconBolt } from '@tabler/icons-vue';
import { IconBolt, IconHelp } from '@tabler/icons-vue';
import { Background } from '@vue-flow/background';
import { Controls } from '@vue-flow/controls';
import {
@ -14,7 +14,7 @@ import {
type XYPosition,
} from '@vue-flow/core';
import { trans } from 'laravel-vue-i18n';
import { computed, markRaw, ref, watch } from 'vue';
import { computed, markRaw, provide, reactive, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import '@vue-flow/core/dist/style.css';
@ -23,26 +23,26 @@ import '@vue-flow/controls/dist/style.css';
import AutomationConnectionLine from '@/components/automations/AutomationConnectionLine.vue';
import EndNode from '@/components/automations/nodes/EndNode.vue';
import HttpRequestNode from '@/components/automations/nodes/HttpRequestNode.vue';
import FetchRssNode from '@/components/automations/nodes/FetchRssNode.vue';
import GenerateNode from '@/components/automations/nodes/GenerateNode.vue';
import PublishNode from '@/components/automations/nodes/PublishNode.vue';
import TriggerNode from '@/components/automations/nodes/TriggerNode.vue';
import WebhookNode from '@/components/automations/nodes/WebhookNode.vue';
import ConditionNodeConfig from '@/components/automations/config/ConditionNodeConfig.vue';
import DelayNodeConfig from '@/components/automations/config/DelayNodeConfig.vue';
import EndNodeConfig from '@/components/automations/config/EndNodeConfig.vue';
import HttpRequestNodeConfig from '@/components/automations/config/HttpRequestNodeConfig.vue';
import FetchRssNodeConfig from '@/components/automations/config/FetchRssNodeConfig.vue';
import GenerateNodeConfig from '@/components/automations/config/GenerateNodeConfig.vue';
import HttpRequestNodeConfig from '@/components/automations/config/HttpRequestNodeConfig.vue';
import PublishNodeConfig from '@/components/automations/config/PublishNodeConfig.vue';
import TriggerNodeConfig from '@/components/automations/config/TriggerNodeConfig.vue';
import WebhookNodeConfig from '@/components/automations/config/WebhookNodeConfig.vue';
import EditorGuide from '@/components/automations/EditorGuide.vue';
import EditorSidebar from '@/components/automations/EditorSidebar.vue';
import ConditionNode from '@/components/automations/nodes/ConditionNode.vue';
import DelayNode from '@/components/automations/nodes/DelayNode.vue';
import EndNode from '@/components/automations/nodes/EndNode.vue';
import FetchRssNode from '@/components/automations/nodes/FetchRssNode.vue';
import GenerateNode from '@/components/automations/nodes/GenerateNode.vue';
import HttpRequestNode from '@/components/automations/nodes/HttpRequestNode.vue';
import PublishNode from '@/components/automations/nodes/PublishNode.vue';
import TriggerNode from '@/components/automations/nodes/TriggerNode.vue';
import WebhookNode from '@/components/automations/nodes/WebhookNode.vue';
import { Button } from '@/components/ui/button';
import { AddEdgeCommand } from '@/composables/history/commands/AddEdgeCommand';
import { AddNodeCommand } from '@/composables/history/commands/AddNodeCommand';
@ -53,6 +53,7 @@ import { UpdateNodeDataCommand } from '@/composables/history/commands/UpdateNode
import { useHistory } from '@/composables/history/useHistory';
import { usePageErrors } from '@/composables/usePageErrors';
import { useShortcut } from '@/composables/useShortcut';
import { buildExpressionCatalog } from '@/composables/useExpressionCompletions';
import AppLayout from '@/layouts/AppLayout.vue';
import {
show as showAutomation,
@ -102,6 +103,7 @@ const hydrateEdges = (list: RawConnection[]): Edge[] =>
const nodes = ref<Node[]>(props.automation.nodes ?? []);
const edges = ref<Edge[]>(hydrateEdges(props.automation.connections ?? []));
const selectedNodeId = ref<string | null>(null);
const selectedEdgeId = ref<string | null>(null);
const name = ref(props.automation.name);
const variables = ref<AutomationVariable[]>(props.automation.variables ?? []);
@ -126,6 +128,19 @@ watch(() => props.automation.variables, (newVariables) => {
});
const selectedNode = computed(() => nodes.value.find((n) => n.id === selectedNodeId.value));
// `{{ ... }}` suggestions available to the selected node's config editors —
// everything its upstream nodes provide, plus workflow variables and `now`.
// Injected by every CodeEditor inside this editor (see CodeEditor.vue).
provide(
'automationExpressionCompletions',
computed(() => buildExpressionCatalog(selectedNodeId.value, nodes.value, edges.value, variables.value)),
);
// Toggled by an expandable CodeEditor (see CodeEditor.vue) to slide out the
// side-by-side editing panel, pushing the sidebar left to make room.
const expandedEditor = reactive({ active: false });
provide('automationExpandedEditor', expandedEditor);
const selectedConfigComponent = computed(() =>
selectedNode.value ? configByType[selectedNode.value.type as string] : null,
);
@ -152,6 +167,8 @@ const history = useHistory();
const {
onNodeClick,
onEdgeClick,
onPaneClick,
onConnect,
onNodeDragStart,
onNodeDragStop,
@ -161,6 +178,18 @@ const {
onNodeClick(({ node }) => {
selectedNodeId.value = node.id;
selectedEdgeId.value = null;
});
// Selecting a connection closes the node config and arms it for deletion via
// Backspace/Delete — without removing the nodes it links.
onEdgeClick(({ edge }) => {
selectedEdgeId.value = edge.id;
selectedNodeId.value = null;
});
onPaneClick(() => {
selectedEdgeId.value = null;
});
onConnect((connection: Connection) => {
@ -303,8 +332,19 @@ const deleteSelectedNode = () => {
nodes.value = nodes.value.filter((n) => n.id !== node.id);
};
const deleteSelectedEdge = () => {
if (!selectedEdgeId.value) return;
const edge = edges.value.find((e) => e.id === selectedEdgeId.value);
if (!edge) return;
history.push(new RemoveEdgeCommand(edge, edges));
edges.value = edges.value.filter((e) => e.id !== edge.id);
selectedEdgeId.value = null;
};
const isSaving = ref(false);
const activeTab = ref('build');
const isGuideOpen = ref(false);
const sanitizeNodes = (list: Node[]) =>
list.map((n) => ({
@ -376,9 +416,11 @@ useShortcut('mod+shift+z', () => {
});
useShortcut('backspace', () => {
if (selectedNode.value) deleteSelectedNode();
else if (selectedEdgeId.value) deleteSelectedEdge();
}, { ignoreOnInput: true });
useShortcut('delete', () => {
if (selectedNode.value) deleteSelectedNode();
else if (selectedEdgeId.value) deleteSelectedEdge();
}, { ignoreOnInput: true });
const defaultEdgeOptions = {
@ -416,6 +458,10 @@ const defaultEdgeOptions = {
class="w-72 rounded-md border-2 border-transparent bg-transparent px-3 py-1 text-center text-sm font-semibold text-foreground transition-colors hover:border-foreground/15 focus:border-foreground focus:bg-background focus:outline-none"
/>
<div class="flex items-center justify-end gap-2">
<Button variant="outline" size="sm" @click="isGuideOpen = true">
<IconHelp class="size-4" />
{{ $t('automations.actions.guide') }}
</Button>
<Button size="sm" @click="save" :disabled="isSaving">{{ $t('automations.actions.save') }}</Button>
</div>
</header>
@ -475,14 +521,24 @@ const defaultEdgeOptions = {
<template v-if="selectedNode" #config>
<component
:is="selectedConfigComponent"
:key="selectedNode.id"
:data="selectedNode.data"
:errors="selectedNodeErrors"
@update="updateSelectedConfig"
/>
</template>
</EditorSidebar>
<aside v-show="expandedEditor.active" class="flex w-[30rem] shrink-0 flex-col py-3 pr-3">
<div
id="automation-expanded-editor"
class="flex h-full min-h-0 flex-col overflow-hidden rounded-2xl border-2 border-foreground bg-card shadow-[3px_3px_0_var(--foreground)]"
/>
</aside>
</div>
</div>
<EditorGuide v-model:open="isGuideOpen" />
</AppLayout>
</template>

View file

@ -110,7 +110,7 @@ const handleCreate = () => {
{{ statusConfig(automation.status).label }}
</Badge>
</TableCell>
<TableCell>{{ formatDate(automation.created_at) }}</TableCell>
<TableCell>{{ automation.created_at ? formatDate(automation.created_at) : '' }}</TableCell>
</TableRow>
</TableBody>
</Table>

View file

@ -58,6 +58,7 @@ export interface NavItem {
activePattern?: string;
exact?: boolean;
excludeActive?: string[];
badge?: string;
}
export interface SharedData {

View file

@ -149,6 +149,40 @@
);
});
it('fans every spawned item out across all branches wired to the fetch node', function () {
Carbon::setTestNow('2026-01-15 10:00:00');
Http::fake(['1.1.1.1/*' => Http::response(FETCH_RSS_THREE_NEW, 200)]);
$automation = Automation::factory()->active()->create([
'nodes' => [
['id' => 'trigger_1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']],
['id' => 'fetch_1', 'type' => 'fetch_rss', 'position' => ['x' => 200, 'y' => 0], 'data' => ['feed_url' => 'https://1.1.1.1/feed']],
['id' => 'generate_1', 'type' => 'generate', 'position' => ['x' => 400, 'y' => 0], 'data' => []],
['id' => 'webhook_1', 'type' => 'webhook', 'position' => ['x' => 400, 'y' => 200], 'data' => []],
],
'connections' => [
['id' => 'e1', 'source' => 'trigger_1', 'target' => 'fetch_1'],
['id' => 'e2', 'source' => 'fetch_1', 'source_handle' => 'default', 'target' => 'generate_1'],
['id' => 'e3', 'source' => 'fetch_1', 'source_handle' => 'default', 'target' => 'webhook_1'],
],
]);
AutomationNodeState::create([
'automation_id' => $automation->id,
'node_id' => 'fetch_1',
'data' => ['last_item_date' => '2025-01-01T00:00:00+00:00'],
]);
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'fetch_1']);
app(RunFetchRssNode::class)($run, ['feed_url' => 'https://1.1.1.1/feed']);
// 2 spawned items × 2 branches = 4 dispatches; both branches reached.
Bus::assertDispatchedTimes(ProcessAutomationNode::class, 4);
Bus::assertDispatched(ProcessAutomationNode::class, fn ($job) => $job->nodeId === 'generate_1');
Bus::assertDispatched(ProcessAutomationNode::class, fn ($job) => $job->nodeId === 'webhook_1');
});
it('does not persist the production watermark on a manual real-data test', function () {
Carbon::setTestNow('2026-01-15 10:00:00');
Http::fake(['1.1.1.1/*' => Http::response(FETCH_RSS_MIXED, 200)]);
@ -165,10 +199,11 @@
],
]);
// Watermark in the FUTURE: a production run would see zero new items here.
AutomationNodeState::create([
'automation_id' => $automation->id,
'node_id' => 'fetch_1',
'data' => ['last_item_date' => '2025-01-01T00:00:00+00:00'],
'data' => ['last_item_date' => '2030-01-01T00:00:00+00:00'],
]);
$run = AutomationRun::factory()->for($automation)->create([
@ -179,14 +214,14 @@
$result = app(RunFetchRssNode::class)($run, ['feed_url' => 'https://1.1.1.1/feed']);
// Reads the production watermark, so it finds the newer items.
// A manual test IGNORES the watermark, so it always surfaces real data even
// when production would have already consumed every item.
expect($result->status)->toBe(NodeRunStatus::Completed);
expect($result->output['fetch']['count'])->toBeGreaterThan(0);
// The persisted watermark stays put — a second identical manual test would
// surface the same items.
// ...and never advances the persisted watermark.
$state = AutomationNodeState::for($automation->id, 'fetch_1');
expect($state->data['last_item_date'])->toBe('2025-01-01T00:00:00+00:00');
expect($state->data['last_item_date'])->toBe('2030-01-01T00:00:00+00:00');
});
it('advances the production watermark on a non-manual real-data run', function () {

View file

@ -89,16 +89,47 @@
'method' => 'GET',
'auth_type' => 'none',
'items_path' => 'items',
'item_key_path' => 'id',
'item_date_path' => 'published_at',
]);
expect($result->output['fetched']['id'])->toBe('a1');
expect($result->output['fetch']['count'])->toBe(3);
expect($result->output['fetch']['spawned'])->toBe(2);
// Parent run + one sibling per remaining item.
expect(AutomationRun::where('automation_id', $automation->id)->count())->toBe(3);
Bus::assertDispatchedTimes(ProcessAutomationNode::class, 2);
});
it('records the baseline on the first date-dedup poll and emits nothing', function () {
Carbon::setTestNow('2026-01-15 10:00:00');
Http::fake([
'1.1.1.1/*' => Http::response([
'items' => [
['id' => 'a1', 'published_at' => '2026-01-05T00:00:00Z'],
// Dated ahead of the server clock — must NOT leak on the first poll.
['id' => 'a2', 'published_at' => '2026-02-01T00:00:00Z'],
],
], 200),
]);
$automation = httpAutomation();
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'http_1']);
$result = app(RunHttpRequestNode::class)($run, [
'url' => 'https://1.1.1.1/items',
'method' => 'GET',
'auth_type' => 'none',
'items_path' => 'items',
'item_date_path' => 'published_at',
]);
expect($result->output['fetch']['count'])->toBe(0);
Bus::assertNotDispatched(ProcessAutomationNode::class);
$state = AutomationNodeState::for($automation->id, 'http_1');
expect($state->data['last_item_date'])->toStartWith('2026-02-01');
});
it('sends bearer token header decrypting it on the fly', function () {
Http::fake(['1.1.1.1/*' => Http::response(['ok' => true], 200)]);
@ -191,3 +222,160 @@
expect($result->status)->toBe(NodeRunStatus::Failed);
});
it('iterates a top-level array with no items_path', function () {
Http::fake([
'1.1.1.1/*' => Http::response([
['id' => 1, 'title' => 'a'],
['id' => 2, 'title' => 'b'],
['id' => 3, 'title' => 'c'],
], 200),
]);
$automation = httpAutomation();
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'http_1']);
$result = app(RunHttpRequestNode::class)($run, [
'url' => 'https://1.1.1.1/posts',
'method' => 'GET',
'auth_type' => 'none',
]);
expect($result->output['fetched'])->toBe(['id' => 1, 'title' => 'a']);
expect($result->output['fetch']['count'])->toBe(3);
expect($result->output['fetch']['spawned'])->toBe(2);
Bus::assertDispatchedTimes(ProcessAutomationNode::class, 2);
});
it('iterates a top-level object map via the wildcard items_path', function () {
Http::fake([
'1.1.1.1/*' => Http::response([
'16092' => ['id' => 16092, 'title' => 'a'],
'14920' => ['id' => 14920, 'title' => 'b'],
], 200),
]);
$automation = httpAutomation();
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'http_1']);
$result = app(RunHttpRequestNode::class)($run, [
'url' => 'https://1.1.1.1/posts',
'method' => 'GET',
'auth_type' => 'none',
'items_path' => '*',
]);
expect($result->output['fetched'])->toBe(['id' => 16092, 'title' => 'a']);
expect($result->output['fetch']['spawned'])->toBe(1);
});
it('iterates a top-level array of primitives, passing each scalar as fetched', function () {
Http::fake([
'1.1.1.1/*' => Http::response(['https://x/1', 'https://x/2', 'https://x/3'], 200),
]);
$automation = httpAutomation();
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'http_1']);
$result = app(RunHttpRequestNode::class)($run, [
'url' => 'https://1.1.1.1/urls',
'method' => 'GET',
'auth_type' => 'none',
]);
expect($result->output['fetched'])->toBe('https://x/1');
expect($result->output['fetch']['spawned'])->toBe(2);
});
it('parses an NDJSON body into items', function () {
Http::fake([
'1.1.1.1/*' => Http::response("{\"id\":1}\n{\"id\":2}\n{\"id\":3}", 200),
]);
$automation = httpAutomation();
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'http_1']);
$result = app(RunHttpRequestNode::class)($run, [
'url' => 'https://1.1.1.1/stream',
'method' => 'GET',
'auth_type' => 'none',
]);
expect($result->output['fetched'])->toBe(['id' => 1]);
expect($result->output['fetch']['count'])->toBe(3);
});
it('records the baseline on the first key-dedup poll and emits nothing', function () {
Http::fake([
'1.1.1.1/*' => Http::response([
['id' => 'p1'],
['id' => 'p2'],
['id' => 'p3'],
], 200),
]);
$automation = httpAutomation();
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'http_1']);
$result = app(RunHttpRequestNode::class)($run, [
'url' => 'https://1.1.1.1/posts',
'method' => 'GET',
'auth_type' => 'none',
'item_key_path' => 'id',
]);
expect($result->output['fetch']['count'])->toBe(0);
Bus::assertNotDispatched(ProcessAutomationNode::class);
$state = AutomationNodeState::for($automation->id, 'http_1');
expect($state->data['seen_keys'])->toHaveCount(3);
});
it('emits only unseen items on a later key-dedup poll', function () {
$automation = httpAutomation();
AutomationNodeState::create([
'automation_id' => $automation->id,
'node_id' => 'http_1',
'data' => ['seen_keys' => [md5('p1'), md5('p2')]],
]);
Http::fake([
'1.1.1.1/*' => Http::response([
['id' => 'p1'],
['id' => 'p2'],
['id' => 'p3'],
], 200),
]);
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'http_1']);
$result = app(RunHttpRequestNode::class)($run, [
'url' => 'https://1.1.1.1/posts',
'method' => 'GET',
'auth_type' => 'none',
'item_key_path' => 'id',
]);
expect($result->output['fetched'])->toBe(['id' => 'p3']);
expect($result->output['fetch']['count'])->toBe(1);
expect($result->output['fetch']['spawned'])->toBe(0);
$state = AutomationNodeState::for($automation->id, 'http_1');
expect($state->data['seen_keys'])->toHaveCount(3);
});
function httpAutomation(): Automation
{
return Automation::factory()->active()->create([
'nodes' => [
['id' => 'trigger_1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']],
['id' => 'http_1', 'type' => 'http_request', 'position' => ['x' => 200, 'y' => 0], 'data' => []],
['id' => 'end_1', 'type' => 'end', 'position' => ['x' => 400, 'y' => 0], 'data' => []],
],
'connections' => [
['id' => 'e1', 'source' => 'trigger_1', 'target' => 'http_1'],
['id' => 'e2', 'source' => 'http_1', 'target' => 'end_1'],
],
]);
}

View file

@ -44,6 +44,23 @@
Http::assertSent(fn ($request) => $request->hasHeader('User-Agent', config('trypost.user_agent')));
});
it('escapes special characters in templated payload values so the JSON stays valid', function () {
Http::fake(['1.1.1.1/*' => Http::response(['ok' => true], 200)]);
$run = AutomationRun::factory()->create([
'context' => ['fetched' => ['title' => 'He said "hi" & bye']],
]);
$result = app(RunWebhookNode::class)($run, [
'url' => 'https://1.1.1.1/hook',
'method' => 'POST',
'payload_template' => '{"title":"{{ fetched.title }}"}',
]);
expect($result->status)->toBe(Status::Completed);
Http::assertSent(fn ($request) => $request['title'] === 'He said "hi" & bye');
});
it('fails on 5xx response', function () {
Http::fake(['1.1.1.1/*' => Http::response('err', 500)]);

View file

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
use App\Actions\Automation\Run\AdvanceAutomationRun;
use App\Jobs\Automation\ProcessAutomationNode;
use App\Models\Automation;
use App\Models\AutomationRun;
use App\Models\Post;
use Illuminate\Support\Facades\Bus;
it('fans out to all targets — continues the run on the first branch and forks a sibling for each other', function () {
Bus::fake();
$automation = Automation::factory()->create([
'nodes' => [
['id' => 't', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']],
['id' => 'a', 'type' => 'end', 'position' => ['x' => 1, 'y' => 0], 'data' => []],
['id' => 'b', 'type' => 'end', 'position' => ['x' => 1, 'y' => 1], 'data' => []],
['id' => 'c', 'type' => 'end', 'position' => ['x' => 1, 'y' => 2], 'data' => []],
],
'connections' => [
['id' => 'e1', 'source' => 't', 'target' => 'a'],
['id' => 'e2', 'source' => 't', 'target' => 'b'],
['id' => 'e3', 'source' => 't', 'target' => 'c'],
],
]);
$run = AutomationRun::factory()->for($automation)->create(['context' => ['shared' => 'value']]);
app(AdvanceAutomationRun::class)($run, 't');
// One job per branch (the original run + two forked siblings).
Bus::assertDispatchedTimes(ProcessAutomationNode::class, 3);
// Two sibling runs created (original + 2), each sharing the parent context.
$runs = AutomationRun::where('automation_id', $automation->id)->get();
expect($runs)->toHaveCount(3);
$runs->each(fn ($r) => expect($r->context)->toBe(['shared' => 'value']));
});
it('forks siblings that inherit the generated post, so a branch after Generate can publish', function () {
Bus::fake();
$post = Post::factory()->create();
$automation = Automation::factory()->create([
'nodes' => [
['id' => 'g', 'type' => 'generate', 'position' => ['x' => 0, 'y' => 0], 'data' => []],
['id' => 'p', 'type' => 'publish', 'position' => ['x' => 1, 'y' => 0], 'data' => ['mode' => 'now']],
['id' => 'w', 'type' => 'webhook', 'position' => ['x' => 1, 'y' => 1], 'data' => []],
],
'connections' => [
['id' => 'e1', 'source' => 'g', 'target' => 'p'],
['id' => 'e2', 'source' => 'g', 'target' => 'w'],
],
]);
$run = AutomationRun::factory()->for($automation)->create([
'current_node_id' => 'g',
'generated_post_id' => $post->id,
]);
app(AdvanceAutomationRun::class)($run, 'g');
$sibling = AutomationRun::where('automation_id', $automation->id)
->whereKeyNot($run->id)
->sole();
expect($sibling->generated_post_id)->toBe($post->id);
});
it('completes the run when a handle has no connected target', function () {
Bus::fake();
$automation = Automation::factory()->create([
'nodes' => [
['id' => 't', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']],
],
'connections' => [],
]);
$run = AutomationRun::factory()->for($automation)->create();
app(AdvanceAutomationRun::class)($run, 't');
expect($run->fresh()->status->value)->toBe('completed');
Bus::assertNotDispatched(ProcessAutomationNode::class);
});

View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
use App\Enums\Automation\Node\Type as NodeType;
use App\Enums\UserWorkspace\Role;
use App\Models\Automation;
use App\Models\AutomationNodeRun;
use App\Models\AutomationRun;
use App\Models\User;
use App\Models\Workspace;
beforeEach(function () {
$this->workspace = Workspace::factory()->create();
$this->user = User::factory()->create(['current_workspace_id' => $this->workspace->id]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Admin->value]);
$this->user->refresh();
});
it('aggregates node runs from every fan-out branch under the root run', function () {
$automation = Automation::factory()->for($this->workspace)->create();
$root = AutomationRun::factory()->for($automation)->create();
$sibling = AutomationRun::factory()->for($automation)->create(['root_run_id' => $root->id]);
AutomationNodeRun::factory()->create([
'run_id' => $root->id,
'node_id' => 'fetch_rss_1',
'node_type' => NodeType::FetchRss,
]);
AutomationNodeRun::factory()->create([
'run_id' => $sibling->id,
'node_id' => 'http_1',
'node_type' => NodeType::HttpRequest,
]);
$response = $this->actingAs($this->user)
->getJson(route('app.automations.runs.show', [$automation, $root]));
$response->assertOk();
$nodeIds = collect($response->json('node_runs'))->pluck('node_id');
expect($nodeIds)->toHaveCount(2)
->and($nodeIds)->toContain('fetch_rss_1')
->and($nodeIds)->toContain('http_1');
});
it('returns the same aggregated family when queried from a sibling run', function () {
$automation = Automation::factory()->for($this->workspace)->create();
$root = AutomationRun::factory()->for($automation)->create();
$sibling = AutomationRun::factory()->for($automation)->create(['root_run_id' => $root->id]);
AutomationNodeRun::factory()->create(['run_id' => $root->id, 'node_id' => 'a']);
AutomationNodeRun::factory()->create(['run_id' => $sibling->id, 'node_id' => 'b']);
$response = $this->actingAs($this->user)
->getJson(route('app.automations.runs.show', [$automation, $sibling]));
$response->assertOk();
expect($response->json('node_runs'))->toHaveCount(2);
});