Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/components/Core.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,7 @@ The expression has access to:
- **$**: The run context data
- **root()**: Access to the root event data
- **previous()**: Access to previous node outputs (optionally with depth parameter)
- **run()**: Access to the current run's metadata — `run().id`, `run().url` (a direct link to the run in the SuperPlane UI), and `run().started_at` (a timestamp)

### Examples

Expand Down Expand Up @@ -656,6 +657,7 @@ The expression has access to:
- **$**: The run context data
- **root()**: Access to the root event data
- **previous()**: Access to previous node outputs (optionally with depth parameter)
- **run()**: Access to the current run's metadata — `run().id`, `run().url` (a direct link to the run in the SuperPlane UI), and `run().started_at` (a timestamp)

### Examples

Expand Down
2 changes: 1 addition & 1 deletion docs/components/Slack.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ The Send Text Message component sends a text message to a Slack channel.
### Configuration

- **Channel**: Select the Slack channel to send the message to
- **Text**: The message text to send (supports expressions and Slack markdown formatting)
- **Text**: The message text to send (supports expressions and Slack markdown formatting). For example, link to the current run with `Deploy started: <{{ run().url }}|view run>`.

### Output

Expand Down
1 change: 1 addition & 0 deletions pkg/components/filter/filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ The expression has access to:
- **$**: The run context data
- **root()**: Access to the root event data
- **previous()**: Access to previous node outputs (optionally with depth parameter)
- **run()**: Access to the current run's metadata — ` + "`run().id`" + `, ` + "`run().url`" + ` (a direct link to the run in the SuperPlane UI), and ` + "`run().started_at`" + ` (a timestamp)

## Examples

Expand Down
1 change: 1 addition & 0 deletions pkg/components/if/if.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ The expression has access to:
- **$**: The run context data
- **root()**: Access to the root event data
- **previous()**: Access to previous node outputs (optionally with depth parameter)
- **run()**: Access to the current run's metadata — ` + "`run().id`" + `, ` + "`run().url`" + ` (a direct link to the run in the SuperPlane UI), and ` + "`run().started_at`" + ` (a timestamp)

## Examples

Expand Down
1 change: 1 addition & 0 deletions pkg/components/memorywrite/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ var (
"config": {},
"root": {},
"previous": {},
"run": {},
"ctx": {},
}

Expand Down
2 changes: 1 addition & 1 deletion pkg/components/memorywrite/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ func TestListMode_ValidateRequiresSource(t *testing.T) {
}

func TestListMode_ValidateRejectsReservedAndInvalidNames(t *testing.T) {
cases := []string{"$", "memory", "1bad", "with space", ""}
cases := []string{"$", "memory", "config", "root", "previous", "run", "ctx", "1bad", "with space", ""}
for _, name := range cases {
t.Run(fmt.Sprintf("rejects %q", name), func(t *testing.T) {
err := ListMode{IterateList: true, ListSource: "list", ItemVariable: name}.Validate()
Expand Down
5 changes: 5 additions & 0 deletions pkg/configuration/expressionvalidation/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ func checkTopLevelCall(name string, args []ast.Node) error {
return fmt.Errorf("previous() depth must be an integer literal")
}
}
case "run":
if len(args) != 0 {
return fmt.Errorf("run() takes no arguments, got %d", len(args))
}
}
return nil
}
Expand Down Expand Up @@ -174,6 +178,7 @@ func compileWithStubEnv(body string, knownNodeNames map[string]struct{}, extraEn
exprruntime.DateFunctionOption(),
expr.Function("root", func(params ...any) (any, error) { return nil, nil }),
expr.Function("previous", func(params ...any) (any, error) { return nil, nil }),
expr.Function("run", func(params ...any) (any, error) { return nil, nil }),
}

if _, err := expr.Compile(body, opts...); err != nil {
Expand Down
5 changes: 5 additions & 0 deletions pkg/configuration/expressionvalidation/validator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ func TestValidateExpression_Valid(t *testing.T) {
{name: "root call", raw: `root().data.ref`},
{name: "previous no args", raw: `previous()`},
{name: "previous with int", raw: `previous(2)`},
{name: "run call", raw: `run().url`},
{name: "run id", raw: `run().id`},
{name: "run started_at", raw: `run().started_at`},
{name: "memory find", raw: `memory.find('users', {id: 1})`},
{name: "memory findFirst", raw: `memory.findFirst('users', {id: 1})`},
{name: "string builtins chain", raw: `lower(trim($['Build'].name))`, knownNames: []string{"Build"}},
Expand Down Expand Up @@ -83,6 +86,8 @@ func TestValidateExpression_BadArity(t *testing.T) {
{name: "previous too many", raw: `previous(1, 2)`, wantErr: "previous() accepts zero or one argument"},
{name: "previous string literal", raw: `previous('a')`, wantErr: "previous() depth must be an integer literal"},
{name: "previous float literal", raw: `previous(1.5)`, wantErr: "previous() depth must be an integer literal"},
{name: "run with int", raw: `run(1)`, wantErr: "run() takes no arguments"},
{name: "run with string", raw: `run('x')`, wantErr: "run() takes no arguments"},
{name: "memory.find missing matches", raw: `memory.find('ns')`, wantErr: "memory.find() requires a namespace and matches"},
{name: "memory.find no args", raw: `memory.find()`, wantErr: "memory.find() requires a namespace and matches"},
{name: "memory.findFirst no args", raw: `memory.findFirst()`, wantErr: "memory.findFirst() requires a namespace and matches"},
Expand Down
2 changes: 1 addition & 1 deletion pkg/integrations/slack/send_text_message.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ func (c *SendTextMessage) Documentation() string {
## Configuration

- **Channel**: Select the Slack channel to send the message to
- **Text**: The message text to send (supports expressions and Slack markdown formatting)
- **Text**: The message text to send (supports expressions and Slack markdown formatting). For example, link to the current run with ` + "`Deploy started: <{{ run().url }}|view run>`" + `.

## Output

Expand Down
70 changes: 70 additions & 0 deletions pkg/workers/contexts/node_configuration_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package contexts
import (
"encoding/json"
"fmt"
"os"
"regexp"
"sort"
"strconv"
Expand Down Expand Up @@ -317,6 +318,13 @@ func (b *NodeConfigurationBuilder) ResolveExpressionWithExtraVariables(expressio

return b.resolvePreviousPayload(depth)
}),
expr.Function("run", func(params ...any) (any, error) {
if len(params) != 0 {
return nil, fmt.Errorf("run() takes no arguments")
}

return b.resolveRunPayload()
}),
}

vm, err := expr.Compile(expression, exprOptions...)
Expand Down Expand Up @@ -723,6 +731,67 @@ func (b *NodeConfigurationBuilder) resolveRootPayload() (any, error) {
return payload, nil
}

// resolveRunPayload exposes the current run to expressions via run().
// It returns id, url, and started_at (a time.Time) for the run that the
// current node belongs to, resolved from the builder's root event.
func (b *NodeConfigurationBuilder) resolveRunPayload() (any, error) {
if b.rootEventID == nil {
return nil, fmt.Errorf("run() is not available in this context: no run found")
}

run, err := models.FindCanvasRunByRootEventInTransaction(b.tx, *b.rootEventID)
if err != nil {
return nil, fmt.Errorf("run() could not resolve the current run: %w", err)
}

payload := map[string]any{
"id": run.ID.String(),
}

if run.CreatedAt != nil {
payload["started_at"] = *run.CreatedAt
}

url, err := b.buildRunURL(run)
if err != nil {
return nil, err
}
payload["url"] = url

return payload, nil
}

func (b *NodeConfigurationBuilder) buildRunURL(run *models.CanvasRun) (string, error) {
canvas, err := models.FindCanvasWithoutOrgScopeInTransaction(b.tx, b.workflowID)
if err != nil {
return "", fmt.Errorf("run() could not resolve the organization for the run: %w", err)
}

return fmt.Sprintf(
"%s/%s/apps/%s?view=runs&run=%s",
runBaseURL(),
canvas.OrganizationID.String(),
b.workflowID.String(),
run.ID.String(),
), nil
}

// runBaseURL mirrors the server's base URL resolution so run().url points at
// the SuperPlane UI both in production (BASE_URL) and local development.
func runBaseURL() string {
baseURL := os.Getenv("BASE_URL")
if baseURL != "" {
return baseURL
}

port := os.Getenv("PORT")
if port == "" {
port = "8000"
}

return fmt.Sprintf("http://localhost:%s", port)
}

func populateFromInputOrRoot(messageChain map[string]any, inputMap map[string]any, rootEvent *models.CanvasEvent, refToNodeID map[string]string) map[string]string {
chainRefs := make(map[string]string, len(refToNodeID))
for nodeRef, nodeID := range refToNodeID {
Expand Down Expand Up @@ -837,6 +906,7 @@ var reservedExpressionIdentifiers = map[string]struct{}{
"config": {},
"root": {},
"previous": {},
"run": {},
"ctx": {},
}

Expand Down
16 changes: 16 additions & 0 deletions pkg/workers/contexts/node_configuration_builder_expr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,22 @@ func TestNodeConfigurationBuilder_ResolveExpressionWithExtraVariables_RejectsRes
require.Contains(t, err.Error(), "reserved")
}

func TestNodeConfigurationBuilder_ResolveExpression_RunWithoutRunContext(t *testing.T) {
b := NewNodeConfigurationBuilder(nil, uuid.Nil).WithInput(map[string]any{})

_, err := b.ResolveExpression(`run()`)
require.Error(t, err)
require.Contains(t, err.Error(), "no run found")
}

func TestNodeConfigurationBuilder_ResolveExpression_RunRejectsArguments(t *testing.T) {
b := NewNodeConfigurationBuilder(nil, uuid.Nil).WithInput(map[string]any{})

_, err := b.ResolveExpression(`run(1)`)
require.Error(t, err)
require.Contains(t, err.Error(), "run() takes no arguments")
}

func TestNodeConfigurationBuilder_ResolveExpression_UsesConfiguredExpressionVariables(t *testing.T) {
b := NewNodeConfigurationBuilder(nil, uuid.Nil).
WithInput(map[string]any{}).
Expand Down
72 changes: 72 additions & 0 deletions pkg/workers/contexts/node_configuration_builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package contexts

import (
"encoding/json"
"fmt"
"testing"
"time"

Expand Down Expand Up @@ -79,6 +80,77 @@ func Test_NodeConfigurationBuilder_WorkflowLevelNode_Root(t *testing.T) {
assert.Equal(t, "42", result["count"])
}

func Test_NodeConfigurationBuilder_RunFunction(t *testing.T) {
r := support.Setup(t)
defer r.Close()

triggerNode := "trigger-1"
componentNode := "component-1"
canvas, _ := support.CreateCanvas(
t,
r.Organization.ID,
r.User,
[]models.CanvasNode{
{
NodeID: triggerNode,
Name: triggerNode,
Type: models.NodeTypeTrigger,
Ref: datatypes.NewJSONType(models.NodeRef{Trigger: &models.TriggerRef{Name: "start"}}),
},
{
NodeID: componentNode,
Name: componentNode,
Type: models.NodeTypeComponent,
Ref: datatypes.NewJSONType(models.NodeRef{Component: &models.ComponentRef{Name: "noop"}}),
},
},
[]models.Edge{
{SourceID: triggerNode, TargetID: componentNode, Channel: "default"},
},
)

rootEvent := support.EmitCanvasEventForNodeWithData(t, canvas.ID, triggerNode, "default", nil, map[string]any{"user": "john"})

//
// Associate the root event with a run so run() can resolve it.
//
run, err := models.FindOrCreateCanvasRunForRootEventInTransaction(database.Conn(), rootEvent)
require.NoError(t, err)

builder := NewNodeConfigurationBuilder(database.Conn(), canvas.ID).
WithRootEvent(&rootEvent.ID).
WithInput(map[string]any{triggerNode: map[string]any{"user": "john"}})

t.Run("returns id, url, and started_at", func(t *testing.T) {
result, err := builder.ResolveExpression(`run()`)
require.NoError(t, err)

payload, ok := result.(map[string]any)
require.True(t, ok)

assert.Equal(t, run.ID.String(), payload["id"])

expectedURLSuffix := fmt.Sprintf("/%s/apps/%s?view=runs&run=%s", canvas.OrganizationID.String(), canvas.ID.String(), run.ID.String())
assert.Contains(t, payload["url"], expectedURLSuffix)

startedAt, ok := payload["started_at"].(time.Time)
require.True(t, ok)
require.NotNil(t, run.CreatedAt)
assert.WithinDuration(t, *run.CreatedAt, startedAt, time.Second)
})

t.Run("fields are usable in templates", func(t *testing.T) {
result, err := builder.Build(map[string]any{
"runID": "{{ run().id }}",
"runURL": "{{ run().url }}",
})
require.NoError(t, err)
assert.Equal(t, run.ID.String(), result["runID"])
assert.Contains(t, result["runURL"], run.ID.String())
assert.Contains(t, result["runURL"], "view=runs")
})
}

func Test_NodeConfigurationBuilder_JSONNumberTemplateUsesOriginalToken(t *testing.T) {
builder := NewNodeConfigurationBuilder(nil, uuid.New()).
WithInput(map[string]any{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,11 @@ export const AutoCompleteInput = forwardRef<HTMLTextAreaElement, AutoCompleteInp
return `__previousByDepth["${depth}"]${expr.slice(previousMatch[0].length)}`;
}

const runMatch = expr.match(/^run\(\)/);
if (runMatch) {
return `__run${expr.slice(runMatch[0].length)}`;
}

return expr;
};

Expand Down
14 changes: 14 additions & 0 deletions web_src/src/components/AutoCompleteInput/core.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,20 @@ describe("getSuggestions", () => {
expect(labels).toContain("image");
});

it("suggests run() metadata fields after dot", () => {
const suggestions = getSuggestions("run().", "run().".length, {
__run: {
id: "abc",
url: "https://app.superplane.com/org/apps/canvas?view=runs&run=abc",
started_at: "2026-01-01T00:00:00Z",
},
});
const labels = suggestions.map((item) => item.label);
expect(labels).toContain("id");
expect(labels).toContain("url");
expect(labels).toContain("started_at");
});

it("suggests previous(n) payload fields after dot", () => {
const suggestions = getSuggestions("previous(2).", "previous(2).".length, {
__previousByDepth: { "2": { build: { id: "abc" } } },
Expand Down
11 changes: 11 additions & 0 deletions web_src/src/components/AutoCompleteInput/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ export const EXPR_FUNCTIONS: readonly ExprFunction[] = [
"Returns the payload from the immediate predecessor that emitted this event. Provide depth to walk upstream.",
example: "previous(2).data.image.version",
},
{
name: "run",
snippet: "run().",
description: "Returns the current run, exposing its id, url, and started_at.",
example: "run().url",
},
// String
{
name: "trim",
Expand Down Expand Up @@ -1405,6 +1411,11 @@ function normalizeSpecialFunctionExpr(expr: string): string | null {
return `__previousByDepth["${depth}"]${expr.slice(previousMatch[0].length)}`;
}

const runMatch = expr.match(/^run\(\)/);
if (runMatch) {
return `__run${expr.slice(runMatch[0].length)}`;
}

return expr;
}

Expand Down
3 changes: 3 additions & 0 deletions web_src/src/lib/exprEvaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -889,6 +889,9 @@ function evaluate(node: ASTNode, context: Record<string, unknown>): unknown {
return null;
};
}
if (node.name === "run") {
return () => (context.__run as unknown) ?? null;
Comment thread
cursor[bot] marked this conversation as resolved.
}
if (node.name in BUILTIN_FUNCTIONS) {
return BUILTIN_FUNCTIONS[node.name];
}
Expand Down
Loading
Loading