Skip to content
Merged
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
7 changes: 7 additions & 0 deletions backend/pkg/executor/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ func (e *Executor) runAction(ctx context.Context, authHeader string, node model.
return currentPath, err
}
result.Output = tag
vars["tag.output"] = tag
return currentPath, nil

case "comment":
Expand All @@ -236,6 +237,7 @@ func (e *Executor) runAction(ctx context.Context, authHeader string, node model.
return currentPath, err
}
result.Output = text
vars["comment.output"] = text
return currentPath, nil

case "move", "copy":
Expand All @@ -254,6 +256,9 @@ func (e *Executor) runAction(ctx context.Context, authHeader string, node model.
return currentPath, err
}
result.Output = destPath
// Keyed per actionType (not a single shared "action.output") so a workflow that
// chains both a copy and a move can still address each result independently.
vars[actionType+".output"] = destPath
if actionType == "move" {
currentPath = destPath
}
Expand All @@ -269,6 +274,7 @@ func (e *Executor) runAction(ctx context.Context, authHeader string, node model.
return currentPath, err
}
result.Output = destPath
vars["rename.output"] = destPath
return destPath, nil

case "notify":
Expand All @@ -281,6 +287,7 @@ func (e *Executor) runAction(ctx context.Context, authHeader string, node model.
return currentPath, err
}
result.Output = "sent"
vars["notify.output"] = "sent"
return currentPath, nil

default:
Expand Down
41 changes: 41 additions & 0 deletions backend/pkg/executor/executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,47 @@ func TestRunTriggerLLMAction(t *testing.T) {
}
}

// TestActionOutputIsReferenceableDownstream proves that an action node's result is not just
// recorded on the NodeResult but also written into the shared vars map under a
// "<actionType>.output" key (mirroring llm.output), so a later node's {{...}} template can
// reference what an earlier action node produced.
func TestActionOutputIsReferenceableDownstream(t *testing.T) {
fLLM := &fakeLLM{}
fFiles := &fakeFiles{content: "file body", name: "doc.txt"}
fGraph := &fakeGraph{}

wf := model.WorkflowDefinition{
ID: "wf-2",
Graph: model.WorkflowGraph{
Nodes: []model.WorkflowNode{
{ID: "trigger", Type: "trigger", Data: map[string]any{}},
{ID: "action-tag", Type: "action", Data: map[string]any{
"actionType": "tag",
"actionParams": map[string]any{"tag": "reviewed"},
}},
{ID: "action-comment", Type: "action", Data: map[string]any{
"actionType": "comment",
"actionParams": map[string]any{"comment": "applied tag: {{tag.output}}"},
}},
},
Edges: []model.WorkflowEdge{
{ID: "e1", Source: "trigger", Target: "action-tag"},
{ID: "e2", Source: "action-tag", Target: "action-comment"},
},
},
}

e := New(fLLM, fFiles, fGraph, discardLogger())
record := e.Run(context.Background(), "token", wf, "manual", "/Docs/doc.txt")

if record.Status != "succeeded" {
t.Fatalf("expected status succeeded, got %s (error: %v)", record.Status, record.Error)
}
if fFiles.commented[1] != "applied tag: reviewed" {
t.Fatalf("expected downstream node to see the tag action's output via {{tag.output}}, got comment %q", fFiles.commented[1])
}
}

func TestRunStopsOnNodeFailure(t *testing.T) {
fLLM := &fakeLLM{err: errFakeLLM}
fFiles := &fakeFiles{content: "x", name: "x.txt"}
Expand Down
91 changes: 91 additions & 0 deletions frontend/src/components/NodeDetailsPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,16 @@
</template>
</template>

<div v-if="outputHint" class="workflows-ndv-output">
<p class="workflows-ndv-label">{{ $gettext('Output') }}</p>
<p class="workflows-ndv-output-description">{{ outputHint.description }}</p>
<p class="workflows-ndv-output-tokens">
<code v-for="token in outputHint.tokens" :key="token" class="workflows-ndv-output-token">{{
token
}}</code>
</p>
</div>

<oc-text-input
v-model="condition"
class="workflows-ndv-field"
Expand Down Expand Up @@ -165,6 +175,65 @@ const showFileSourceWarning = computed(
!hasUpstreamFileSource(props.node.id, props.nodes, props.edges)
)

// Every syntax shown here must be a variable key the backend executor (backend/pkg/executor/
// executor.go) actually writes into its shared "vars" map before rendering a downstream node's
// {{...}} templates — never a token that only looks plausible. Today that's:
// - "file.name" / "file.content", set once per run from the triggering resource
// - "llm.output", set by runLLM() after every LLM node
// - "<actionType>.output" (tag/comment/move/copy/rename/notify), set by runAction() per node
interface OutputHint {
tokens: string[]
description: string
}

const actionOutputHints: Record<string, OutputHint> = {
tag: {
tokens: ['{{tag.output}}'],
description: $gettext('The tag value that was applied to the file.')
},
comment: {
tokens: ['{{comment.output}}'],
description: $gettext('The comment text that was added to the file.')
},
move: {
tokens: ['{{move.output}}'],
description: $gettext('The path the file was moved to.')
},
copy: {
tokens: ['{{copy.output}}'],
description: $gettext('The path of the copy that was created.')
},
rename: {
tokens: ['{{rename.output}}'],
description: $gettext('The file path after the rename.')
},
notify: {
tokens: ['{{notify.output}}'],
description: $gettext('A fixed "sent" status once the notification has gone out.')
}
}

const outputHint = computed<OutputHint | null>(() => {
if (props.node.type === 'trigger') {
return {
tokens: ['{{file.name}}', '{{file.content}}'],
description: $gettext(
'Available in every downstream node whenever this workflow runs against a specific file — e.g. a file-event trigger, or a manual run targeting a file.'
)
}
}
if (props.node.type === 'llm') {
return {
tokens: ['{{llm.output}}'],
description: $gettext("The LLM's response text.")
}
}
if (props.node.type === 'action' && props.node.data.actionType) {
return actionOutputHints[props.node.data.actionType] ?? null
}
return null
})

const patch = (partial: Partial<WorkflowNodeData>) => emit('update', { ...props.node.data, ...partial })

const field = <K extends keyof WorkflowNodeData>(key: K) =>
Expand Down Expand Up @@ -274,4 +343,26 @@ const paramMessage = actionParam('message')
padding-top: 1rem;
border-top: 1px solid var(--oc-color-border, rgba(0, 0, 0, 0.1));
}
.workflows-ndv-output {
border: 1px solid var(--oc-color-border, #d9d9d9);
border-radius: 4px;
padding: 0.75rem;
background: var(--oc-color-background-muted, rgba(0, 0, 0, 0.03));
}
.workflows-ndv-output-description {
opacity: 0.7;
margin: 0.25rem 0 0.5rem;
}
.workflows-ndv-output-tokens {
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.workflows-ndv-output-token {
font-family: monospace;
background: var(--oc-color-background-highlight, rgba(0, 0, 0, 0.08));
padding: 0.15rem 0.4rem;
border-radius: 4px;
}
</style>
82 changes: 82 additions & 0 deletions frontend/tests/unit/NodeDetailsPanel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,85 @@ describe('NodeDetailsPanel file-source warning', () => {
expect(wrapper.find('.workflows-ndv-warning').exists()).toBe(false)
})
})

// oc-* components come from ownCloud's design system, registered globally by the host shell
// at runtime. They aren't available in this unit test environment, so they're stubbed out —
// we only care about the plain-HTML "Output" hint markup this panel renders itself.
const outputHintStubs = {
'oc-icon': true,
'oc-button': true,
'oc-text-input': true
}

function mountOutputHintPanel(node: WorkflowNode) {
return mount(NodeDetailsPanel, {
props: { node, nodes: [], edges: [] },
global: {
plugins: [createGettext({ translations: {} })],
stubs: outputHintStubs
}
})
}

describe('NodeDetailsPanel output hint', () => {
it('shows {{llm.output}} for an LLM node', () => {
const node: WorkflowNode = {
id: 'llm-1',
type: 'llm',
position: { x: 0, y: 0 },
data: { prompt: 'Summarize {{file.content}}' }
}
const wrapper = mountOutputHintPanel(node)
const text = wrapper.text()
expect(text).toContain('Output')
expect(text).toContain('{{llm.output}}')
})

it('shows {{tag.output}} for a tag action node, matching the executor-wired vars key', () => {
const node: WorkflowNode = {
id: 'action-1',
type: 'action',
position: { x: 0, y: 0 },
data: { actionType: 'tag' }
}
const wrapper = mountOutputHintPanel(node)
const text = wrapper.text()
expect(text).toContain('Output')
expect(text).toContain('{{tag.output}}')
})

it('shows {{move.output}} for a move action node', () => {
const node: WorkflowNode = {
id: 'action-2',
type: 'action',
position: { x: 0, y: 0 },
data: { actionType: 'move' }
}
const wrapper = mountOutputHintPanel(node)
expect(wrapper.text()).toContain('{{move.output}}')
})

it('shows {{notify.output}} for a notify action node', () => {
const node: WorkflowNode = {
id: 'action-3',
type: 'action',
position: { x: 0, y: 0 },
data: { actionType: 'notify' }
}
const wrapper = mountOutputHintPanel(node)
expect(wrapper.text()).toContain('{{notify.output}}')
})

it('shows {{file.name}} and {{file.content}} for a trigger node', () => {
const node: WorkflowNode = {
id: 'trigger-1',
type: 'trigger',
position: { x: 0, y: 0 },
data: { triggerType: 'event', event: { type: 'upload' } }
}
const wrapper = mountOutputHintPanel(node)
const text = wrapper.text()
expect(text).toContain('{{file.name}}')
expect(text).toContain('{{file.content}}')
})
})
Loading