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 .github/workflows/test-suite.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ jobs:
test:
name: 🧪 Test ${{ matrix.name }} ${{ matrix.needs-sidecar && '(sidecar)' }}
strategy:
fail-fast: false
matrix:
include:
- name: 🐧 Linux (amd64)
Expand All @@ -76,6 +77,7 @@ jobs:
needs-sidecar: true
- name: 🖥️ Windows (amd64)
os: windows-latest
continue-on-error: true # WSL .exe interop broken on GH runners since image 20260202
- name: 🖥️ Windows (arm64)
os: windows-11-arm
needs-sidecar: true
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@
dist/
.idea
.plasmactl/
launchr_*
49 changes: 49 additions & 0 deletions example/plugins/action_result_schema/action.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
runtime: plugin
action:
title: Result Schema Example
description: Demonstrates structured output with result schema

arguments:
- name: count
type: integer
default: 3
description: Number of items to generate

options:
- name: prefix
type: string
default: "item"
description: Prefix for generated items

result:
type: object
description: Generated items with summary
properties:
items:
type: array
description: List of generated items
items:
type: object
properties:
id:
type: integer
description: Item identifier
name:
type: string
description: Item name
created_at:
type: string
description: Creation timestamp
summary:
type: object
description: Summary statistics
properties:
total:
type: integer
description: Total number of items
prefix:
type: string
description: Prefix used for generation
required:
- items
- summary
98 changes: 98 additions & 0 deletions example/plugins/action_result_schema/plugin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Package action_result_schema provides an example of creating an action
// with structured output using result schema.
// It demonstrates how to define a result schema in action.yaml and return
// structured data that can be output as JSON with --json flag.
package action_result_schema //nolint:revive // using underscore for better example naming

import (
"context"
_ "embed"
"fmt"
"time"

"github.com/launchrctl/launchr"
"github.com/launchrctl/launchr/pkg/action"
)

// Embed action yaml file. It is later used in DiscoverActions.
//
//go:embed action.yaml
var actionYaml []byte

func init() {
launchr.RegisterPlugin(&Plugin{})
}

// Plugin is [launchr.Plugin] providing example plugin action with result schema.
type Plugin struct{}

// PluginInfo implements [launchr.Plugin] interface.
func (p *Plugin) PluginInfo() launchr.PluginInfo {
return launchr.PluginInfo{}
}

// Item represents a generated item.
type Item struct {
ID int `json:"id"`
Name string `json:"name"`
CreatedAt string `json:"created_at"`
}

// Summary contains summary statistics.
type Summary struct {
Total int `json:"total"`
Prefix string `json:"prefix"`
}

// Result is the structured output of the action.
type Result struct {
Items []Item `json:"items"`
Summary Summary `json:"summary"`
}

// DiscoverActions implements [launchr.ActionDiscoveryPlugin] interface.
func (p *Plugin) DiscoverActions(_ context.Context) ([]*action.Action, error) {
// Create the action from yaml definition.
a := action.NewFromYAML("example:result-schema", actionYaml)

// Use NewFnRuntimeWithResult to create a runtime that returns structured output.
a.SetRuntime(action.NewFnRuntimeWithResult(func(_ context.Context, a *action.Action) (any, error) {
input := a.Input()

// Get input parameters.
count := input.Arg("count").(int)
prefix := input.Opt("prefix").(string)

// Generate items.
items := make([]Item, count)
now := time.Now()
for i := 0; i < count; i++ {
items[i] = Item{
ID: i + 1,
Name: fmt.Sprintf("%s_%d", prefix, i+1),
CreatedAt: now.Add(time.Duration(i) * time.Second).Format(time.RFC3339),
}
}

// Build result.
result := Result{
Items: items,
Summary: Summary{
Total: count,
Prefix: prefix,
},
}

// Print human-readable output for non-JSON mode.
launchr.Term().Printfln("Generated %d items with prefix %q:", count, prefix)
for _, item := range items {
launchr.Term().Printfln(" - %s (id=%d)", item.Name, item.ID)
}

// Return the structured result.
// This will be captured by the runtime and serialized as JSON when --json is used.
return result, nil
}))

return []*action.Action{a}, nil
}
26 changes: 26 additions & 0 deletions internal/launchr/streams.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,3 +236,29 @@ func WithSensitiveMask(m *SensitiveMask) StreamsModifierFn {
streams.err.out = m.MaskWriter(streams.err.out)
}
}

// capturingStreams wraps a Streams and captures stdout to a buffer.
type capturingStreams struct {
base Streams
stdoutBuf io.Writer
captureW *Out
}

// NewCapturingStreams creates a Streams wrapper that captures stdout to a buffer
// WITHOUT writing to the original stream. This is used for actions with result schemas
// where stdout contains structured data (JSON) that launchr will parse and format.
// stderr still flows through normally for logs/debug messages.
func NewCapturingStreams(base Streams, stdoutBuf io.Writer) Streams {
return &capturingStreams{
base: base,
stdoutBuf: stdoutBuf,
captureW: NewOut(stdoutBuf), // Capture only, no terminal passthrough
}
}

func (c *capturingStreams) In() *In { return c.base.In() }
func (c *capturingStreams) Out() *Out { return c.captureW }
func (c *capturingStreams) Err() *Out { return c.base.Err() }
func (c *capturingStreams) Close() error {
return c.base.Close()
}
9 changes: 5 additions & 4 deletions internal/launchr/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,11 @@ func IsSELinuxEnabled() bool {

// CmdEarlyParsed is all parsed command information on early stage.
type CmdEarlyParsed struct {
Command string // Command is the requested command.
Args []string // Args are all arguments provided in the command line.
IsVersion bool // IsVersion when version was requested.
IsGen bool // IsGen when in generate mod.
Command string // Command is the requested command.
Args []string // Args are all arguments provided in the command line.
IsVersion bool // IsVersion when version was requested.
IsGen bool // IsGen when in generate mod.
IsCompletion bool // IsCompletion when shell completion was requested.
}

// EarlyPeekCommand parses all available information during init stage.
Expand Down
52 changes: 34 additions & 18 deletions pkg/action/jsonschema.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const (
jsonschemaPropOpts = "options"
jsonschemaPropRuntime = "runtime"
jsonschemaPropPersistent = "persistent"
jsonschemaPropResult = "result"
)

// validateJSONSchema validates arguments and options according to
Expand Down Expand Up @@ -50,26 +51,41 @@ func (a *DefAction) JSONSchema() jsonschema.Schema {
args, argsReq := a.Arguments.JSONSchema()
opts, optsReq := a.Options.JSONSchema()

return jsonschema.Schema{
Type: jsonschema.Object,
Required: []string{jsonschemaPropArgs, jsonschemaPropOpts},
Properties: map[string]any{
jsonschemaPropArgs: map[string]any{
"type": "object",
"title": "Arguments",
"properties": args,
"required": argsReq,
"additionalProperties": false,
},
jsonschemaPropOpts: map[string]any{
"type": "object",
"title": "Options",
"properties": opts,
"required": optsReq,
"additionalProperties": false,
},
props := map[string]any{
jsonschemaPropArgs: map[string]any{
"type": "object",
"title": "Arguments",
"properties": args,
"required": argsReq,
"additionalProperties": false,
},
jsonschemaPropOpts: map[string]any{
"type": "object",
"title": "Options",
"properties": opts,
"required": optsReq,
"additionalProperties": false,
},
}

// Add result schema if defined.
if a.Result != nil {
props[jsonschemaPropResult] = a.Result.Raw()
}

return jsonschema.Schema{
Type: jsonschema.Object,
Required: []string{jsonschemaPropArgs, jsonschemaPropOpts},
Properties: props,
}
}

// ResultJSONSchema returns the result schema if defined, nil otherwise.
func (a *DefAction) ResultJSONSchema() map[string]any {
if a.Result == nil {
return nil
}
return a.Result.Raw()
}

// JSONSchema collects all arguments json schema definition and also returns fields that are required.
Expand Down
52 changes: 45 additions & 7 deletions pkg/action/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -364,12 +364,20 @@ func (m *actionManagerMap) ValidateInput(a *Action, input *Input) error {
return nil
}

// Run status constants.
const (
runStatusCreated = "created"
runStatusError = "error"
runStatusFinished = "finished"
)

// RunInfo stores information about a running action.
type RunInfo struct {
ID string
Action *Action
Status string
// @todo add more info for status like error message or exit code. Or have it in output.
Result any // Result contains the structured result if the runtime implements RuntimeResultProvider.
Error string // Error contains the error message if the action failed.
}

type runManagerMap struct {
Expand All @@ -388,7 +396,7 @@ func (m *runManagerMap) registerRun(a *Action, id string) RunInfo {
ri := RunInfo{
ID: id,
Action: a,
Status: "created",
Status: runStatusCreated,
}
m.runStore[id] = ri
return ri
Expand All @@ -403,10 +411,32 @@ func (m *runManagerMap) updateRunStatus(id string, st string) {
}
}

func (m *runManagerMap) updateRunInfo(ri RunInfo) {
m.mx.Lock()
defer m.mx.Unlock()
m.runStore[ri.ID] = ri
}

// Run executes an action in foreground.
func (m *runManagerMap) Run(ctx context.Context, a *Action) (RunInfo, error) {
// @todo add the same status change info
return m.registerRun(a, ""), a.Execute(ctx)
ri := m.registerRun(a, "")
err := a.Execute(ctx)

// Capture result if the runtime implements RuntimeResultProvider.
if rp, ok := a.Runtime().(RuntimeResultProvider); ok {
ri.Result = rp.Result()
}

// Capture error message.
if err != nil {
ri.Error = err.Error()
ri.Status = runStatusError
} else {
ri.Status = runStatusFinished
}
m.updateRunInfo(ri)

return ri, err
}

// RunBackground executes an action in background.
Expand All @@ -419,15 +449,23 @@ func (m *runManagerMap) RunBackground(ctx context.Context, a *Action, runID stri
err := a.Execute(ctx)
chErr <- err
close(chErr)

// Capture result if the runtime implements RuntimeResultProvider.
if rp, ok := a.Runtime().(RuntimeResultProvider); ok {
ri.Result = rp.Result()
}

if err != nil {
ri.Error = err.Error()
if errors.Is(err, context.Canceled) {
m.updateRunStatus(ri.ID, "canceled")
ri.Status = "canceled"
} else {
m.updateRunStatus(ri.ID, "error")
ri.Status = "error"
}
} else {
m.updateRunStatus(ri.ID, "finished")
ri.Status = "finished"
}
m.updateRunInfo(ri)
}()
// @todo rethink returned values.
return ri, chErr
Expand Down
Loading
Loading