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
22 changes: 16 additions & 6 deletions tsunami/app/atom.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,22 @@ import (
"github.com/wavetermdev/waveterm/tsunami/util"
)

// AtomMeta provides metadata about an atom for validation and documentation
type AtomMeta struct {
Desc string // short, user-facing
Units string // "ms", "GiB", etc.
Min *float64 // optional minimum (numeric types)
Max *float64 // optional maximum (numeric types)
Enum []string // allowed values if finite set
Pattern string // regex constraint for strings
}

// Atom[T] represents a typed atom implementation
type Atom[T any] struct {
name string
client *engine.ClientImpl
}

// logInvalidAtomSet logs an error when an atom is being set during component render
func logInvalidAtomSet(atomName string) {
_, file, line, ok := runtime.Caller(2)
Expand Down Expand Up @@ -58,12 +74,6 @@ func logMutationWarning(atomName string) {
}
}

// Atom[T] represents a typed atom implementation
type Atom[T any] struct {
name string
client *engine.ClientImpl
}

// AtomName implements the vdom.Atom interface
func (a Atom[T]) AtomName() string {
return a.name
Expand Down
30 changes: 25 additions & 5 deletions tsunami/app/defaultclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ func DefineComponent[P any](name string, renderFn func(props P) any) vdom.Compon
return engine.DefineComponentEx(engine.GetDefaultClient(), name, renderFn)
}

func Ptr[T any](v T) *T {
return &v
}

func SetGlobalEventHandler(handler func(event vdom.VDomEvent)) {
engine.GetDefaultClient().SetGlobalEventHandler(handler)
}
Expand All @@ -35,30 +39,46 @@ func SendAsyncInitiation() error {
return engine.GetDefaultClient().SendAsyncInitiation()
}

func ConfigAtom[T any](name string, defaultValue T) Atom[T] {
func ConfigAtom[T any](name string, defaultValue T, meta *AtomMeta) Atom[T] {
fullName := "$config." + name
client := engine.GetDefaultClient()
atom := engine.MakeAtomImpl(defaultValue)
engineMeta := convertAppMetaToEngineMeta(meta)
atom := engine.MakeAtomImpl(defaultValue, engineMeta)
client.Root.RegisterAtom(fullName, atom)
return Atom[T]{name: fullName, client: client}
}
Comment on lines +42 to 49
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Backwards-incompatible API change: ensure all call sites add the meta arg.

ConfigAtom/DataAtom now require a third parameter. Verify no two-arg calls remain.

Run:

Also applies to: 51-58


🏁 Script executed:

#!/bin/bash
# Find legacy two-arg calls to ConfigAtom/DataAtom
rg -nP --type=go -C1 '\bConfigAtom\s*\(\s*[^,]+,\s*[^,]+\s*\)' | rg -v '&app\.AtomMeta|\bnil\s*\)'
rg -nP --type=go -C1 '\bDataAtom\s*\(\s*[^,]+,\s*[^,]+\s*\)'   | rg -v '&app\.AtomMeta|\bnil\s*\)'

Length of output: 370


Backwards-incompatible API change — add missing meta argument to ConfigAtom/DataAtom call sites

Found a legacy two-arg call; update to pass the new third meta parameter.

  • tsunami/demo/cpuchart/app.go:19 — cpuDataAtom = app.DataAtom("cpuData", func() []CPUDataPoint { ... })
🤖 Prompt for AI Agents
In tsunami/app/defaultclient.go around lines 42-49 the ConfigAtom signature now
requires a third meta parameter; update legacy two-arg call sites (e.g.,
tsunami/demo/cpuchart/app.go:19) to pass the new meta argument—either nil if no
metadata is needed or a constructed &AtomMeta{...} with the required fields. Do
the same for DataAtom call sites: add the meta parameter to each call, recompile
and run tests to ensure no remaining references to the old two-argument form.


func DataAtom[T any](name string, defaultValue T) Atom[T] {
func DataAtom[T any](name string, defaultValue T, meta *AtomMeta) Atom[T] {
fullName := "$data." + name
client := engine.GetDefaultClient()
atom := engine.MakeAtomImpl(defaultValue)
engineMeta := convertAppMetaToEngineMeta(meta)
atom := engine.MakeAtomImpl(defaultValue, engineMeta)
client.Root.RegisterAtom(fullName, atom)
return Atom[T]{name: fullName, client: client}
}

func SharedAtom[T any](name string, defaultValue T) Atom[T] {
fullName := "$shared." + name
client := engine.GetDefaultClient()
atom := engine.MakeAtomImpl(defaultValue)
atom := engine.MakeAtomImpl(defaultValue, nil)
client.Root.RegisterAtom(fullName, atom)
return Atom[T]{name: fullName, client: client}
}

func convertAppMetaToEngineMeta(appMeta *AtomMeta) *engine.AtomMeta {
if appMeta == nil {
return nil
}
return &engine.AtomMeta{
Description: appMeta.Desc,
Units: appMeta.Units,
Min: appMeta.Min,
Max: appMeta.Max,
Enum: appMeta.Enum,
Pattern: appMeta.Pattern,
}
}

// HandleDynFunc registers a dynamic HTTP handler function with the internal http.ServeMux.
// The pattern MUST start with "/dyn/" to be valid. This allows registration of dynamic
// routes that can be handled at runtime.
Expand Down
22 changes: 14 additions & 8 deletions tsunami/demo/cpuchart/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,12 @@ import (

// Global atoms for config and data
var (
dataPointCountAtom = app.ConfigAtom("dataPointCount", 60)
cpuDataAtom = app.DataAtom("cpuData", func() []CPUDataPoint {
dataPointCountAtom = app.ConfigAtom("dataPointCount", 60, &app.AtomMeta{
Desc: "Number of CPU data points to display in the chart",
Min: app.Ptr(10.0),
Max: app.Ptr(300.0),
})
cpuDataAtom = app.DataAtom("cpuData", func() []CPUDataPoint {
// Initialize with empty data points to maintain consistent chart size
dataPointCount := 60 // Default value for initialization
initialData := make([]CPUDataPoint, dataPointCount)
Expand All @@ -24,13 +28,15 @@ var (
}
}
return initialData
}())
}(), &app.AtomMeta{
Desc: "Historical CPU usage data points for charting",
})
)

type CPUDataPoint struct {
Time int64 `json:"time"` // Unix timestamp in seconds
CPUUsage *float64 `json:"cpuUsage"` // CPU usage percentage (nil for empty slots)
Timestamp string `json:"timestamp"` // Human readable timestamp
Time int64 `json:"time" desc:"Unix timestamp (seconds since epoch)" units:"s"`
CPUUsage *float64 `json:"cpuUsage" desc:"CPU usage percentage" units:"%" min:"0" max:"100"`
Timestamp string `json:"timestamp" desc:"Human-readable HH:MM:SS"`
}

type StatsPanelProps struct {
Expand Down Expand Up @@ -207,7 +213,7 @@ var App = app.DefineComponent("App", func(_ struct{}) any {
}, "Real-Time CPU Usage Monitor"),
vdom.H("p", map[string]any{
"className": "text-gray-400",
}, "Live CPU usage data collected using gopsutil, displaying 60 seconds of history"),
}, "Live CPU usage data collected using gopsutil, displaying ", dataPointCount, " seconds of history"),
),

// Controls
Expand Down Expand Up @@ -334,7 +340,7 @@ var App = app.DefineComponent("App", func(_ struct{}) any {
vdom.H("span", map[string]any{
"className": "text-blue-400 mt-1",
}, "•"),
"Rolling window of 60 seconds of historical data",
"Rolling window of ", dataPointCount, " seconds of historical data",
),
vdom.H("li", map[string]any{
"className": "flex items-start gap-2",
Expand Down
41 changes: 32 additions & 9 deletions tsunami/demo/githubaction/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,37 @@ import (

// Global atoms for config and data
var (
pollIntervalAtom = app.ConfigAtom("pollInterval", 5)
repositoryAtom = app.ConfigAtom("repository", "wavetermdev/waveterm")
workflowAtom = app.ConfigAtom("workflow", "build-helper.yml")
maxWorkflowRunsAtom = app.ConfigAtom("maxWorkflowRuns", 10)
workflowRunsAtom = app.DataAtom("workflowRuns", []WorkflowRun{})
lastErrorAtom = app.DataAtom("lastError", "")
isLoadingAtom = app.DataAtom("isLoading", true)
lastRefreshTimeAtom = app.DataAtom("lastRefreshTime", time.Time{})
pollIntervalAtom = app.ConfigAtom("pollInterval", 5, &app.AtomMeta{
Desc: "Polling interval for GitHub API requests",
Units: "s",
Min: app.Ptr(1.0),
Max: app.Ptr(300.0),
})
repositoryAtom = app.ConfigAtom("repository", "wavetermdev/waveterm", &app.AtomMeta{
Desc: "GitHub repository in owner/repo format",
Pattern: `^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$`,
})
workflowAtom = app.ConfigAtom("workflow", "build-helper.yml", &app.AtomMeta{
Desc: "GitHub Actions workflow file name",
Pattern: `^.+\.(yml|yaml)$`,
})
maxWorkflowRunsAtom = app.ConfigAtom("maxWorkflowRuns", 10, &app.AtomMeta{
Desc: "Maximum number of workflow runs to fetch",
Min: app.Ptr(1.0),
Max: app.Ptr(100.0),
})
workflowRunsAtom = app.DataAtom("workflowRuns", []WorkflowRun{}, &app.AtomMeta{
Desc: "List of GitHub Actions workflow runs",
})
lastErrorAtom = app.DataAtom("lastError", "", &app.AtomMeta{
Desc: "Last error message from GitHub API",
})
isLoadingAtom = app.DataAtom("isLoading", true, &app.AtomMeta{
Desc: "Loading state for workflow data fetch",
})
lastRefreshTimeAtom = app.DataAtom("lastRefreshTime", time.Time{}, &app.AtomMeta{
Desc: "Timestamp of last successful data refresh",
})
)

type WorkflowRun struct {
Expand Down Expand Up @@ -388,7 +411,7 @@ var App = app.DefineComponent("App",
vdom.H("span", map[string]any{
"className": "text-blue-400 mt-1",
}, "•"),
"Polls GitHub API every 5 seconds for real-time updates",
"Polls GitHub API every ", pollInterval, " seconds for real-time updates",
),
vdom.H("li", map[string]any{
"className": "flex items-start gap-2",
Expand Down
12 changes: 8 additions & 4 deletions tsunami/demo/pomodoro/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ var (
BreakMode = Mode{Name: "Break", Duration: 5}

// Data atom to expose remaining seconds to external systems
remainingSecondsAtom = app.DataAtom("remainingSeconds", WorkMode.Duration*60)
remainingSecondsAtom = app.DataAtom("remainingSeconds", WorkMode.Duration*60, &app.AtomMeta{
Desc: "Remaining seconds in current pomodoro timer",
Units: "s",
Min: app.Ptr(0.0),
Max: app.Ptr(3600.0),
})
)

type TimerDisplayProps struct {
Expand All @@ -34,7 +39,6 @@ type ControlButtonsProps struct {
OnMode func(int) `json:"onMode"`
}


var TimerDisplay = app.DefineComponent("TimerDisplay",
func(props TimerDisplayProps) any {
minutes := props.RemainingSeconds / 60
Expand Down Expand Up @@ -151,7 +155,7 @@ var App = app.DefineComponent("App",
if !isRunning.Get() {
return
}

// Calculate remaining time and update remainingSeconds
elapsed := time.Since(startTime.Current)
remaining := totalDuration.Current - elapsed
Expand Down Expand Up @@ -190,7 +194,7 @@ var App = app.DefineComponent("App",
),
TimerDisplay(TimerDisplayProps{
RemainingSeconds: remainingSecondsAtom.Get(),
Mode: mode.Get(),
Mode: mode.Get(),
}),
ControlButtons(ControlButtonsProps{
IsRunning: isRunning.Get(),
Expand Down
13 changes: 10 additions & 3 deletions tsunami/demo/recharts/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,16 @@ import (

// Global atoms for config and data
var (
chartDataAtom = app.DataAtom("chartData", generateInitialData())
chartTypeAtom = app.ConfigAtom("chartType", "line")
isAnimatingAtom = app.SharedAtom("isAnimating", false)
chartDataAtom = app.DataAtom("chartData", generateInitialData(), &app.AtomMeta{
Desc: "Chart data points for system metrics visualization",
})
chartTypeAtom = app.ConfigAtom("chartType", "line", &app.AtomMeta{
Desc: "Type of chart to display",
Enum: []string{"line", "area", "bar"},
})
isAnimatingAtom = app.ConfigAtom("isAnimating", false, &app.AtomMeta{
Desc: "Whether the chart is currently animating with live data",
})
)

type DataPoint struct {
Expand Down
2 changes: 2 additions & 0 deletions tsunami/demo/tabletest/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ var sampleData = app.DataAtom("sampleData", []Person{
{Name: "Henry Taylor", Age: 33, Email: "henry@example.com", City: "San Diego"},
{Name: "Ivy Chen", Age: 26, Email: "ivy@example.com", City: "Dallas"},
{Name: "Jack Anderson", Age: 31, Email: "jack@example.com", City: "San Jose"},
}, &app.AtomMeta{
Desc: "Sample person data for table display testing",
})

// The App component is the required entry point for every Tsunami application
Expand Down
5 changes: 4 additions & 1 deletion tsunami/demo/tsunamiconfig/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import (

// Global atoms for config
var (
serverURLAtom = app.ConfigAtom("serverURL", "")
serverURLAtom = app.ConfigAtom("serverURL", "", &app.AtomMeta{
Desc: "Server URL for config API (can be full URL, hostname:port, or just port)",
Pattern: `^(https?://.*|[a-zA-Z0-9.-]+:\d+|\d+|[a-zA-Z0-9.-]+)$`,
})
)

type URLInputProps struct {
Expand Down
25 changes: 24 additions & 1 deletion tsunami/engine/atomimpl.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,33 @@ package engine
import (
"encoding/json"
"fmt"
"reflect"
"sync"
)

// AtomMeta provides metadata about an atom for validation and documentation
type AtomMeta struct {
Description string // short, user-facing
Units string // "ms", "GiB", etc.
Min *float64 // optional minimum (numeric types)
Max *float64 // optional maximum (numeric types)
Enum []string // allowed values if finite set
Pattern string // regex constraint for strings
}

type AtomImpl[T any] struct {
lock *sync.Mutex
val T
usedBy map[string]bool // component waveid -> true
meta *AtomMeta // optional metadata
}

func MakeAtomImpl[T any](initialVal T) *AtomImpl[T] {
func MakeAtomImpl[T any](initialVal T, meta *AtomMeta) *AtomImpl[T] {
return &AtomImpl[T]{
lock: &sync.Mutex{},
val: initialVal,
usedBy: make(map[string]bool),
meta: meta,
}
}
Comment on lines +30 to 37
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Meta escape hatch: copy metadata at construction to prevent external mutation

Storing the caller-provided *AtomMeta directly allows later unsynchronized mutations by the caller, leading to data races and inconsistent schemas. Clone it on input.

Apply:

 func MakeAtomImpl[T any](initialVal T, meta *AtomMeta) *AtomImpl[T] {
 	return &AtomImpl[T]{
 		lock:   &sync.Mutex{},
 		val:    initialVal,
 		usedBy: make(map[string]bool),
-		meta:   meta,
+		meta:   cloneAtomMeta(meta),
 	}
 }

Add once in this file:

// cloneAtomMeta makes a deep copy to keep AtomMeta immutable/owned by the atom.
func cloneAtomMeta(in *AtomMeta) *AtomMeta {
	if in == nil {
		return nil
	}
	out := *in // shallow copy first
	if in.Enum != nil {
		out.Enum = append([]string(nil), in.Enum...)
	}
	if in.Min != nil {
		v := *in.Min
		out.Min = &v
	}
	if in.Max != nil {
		v := *in.Max
		out.Max = &v
	}
	return &out
}
🤖 Prompt for AI Agents
In tsunami/engine/atomimpl.go around lines 29 to 36, the AtomImpl constructor
stores the caller-provided *AtomMeta pointer directly which allows external
mutation and data races; add a cloneAtomMeta helper in this file that
deep-copies AtomMeta (copy struct, clone Enum slice, copy Min/Max pointers) and
call cloneAtomMeta(meta) when constructing AtomImpl so the atom owns an
immutable copy of the metadata.


Expand Down Expand Up @@ -84,3 +97,13 @@ func (a *AtomImpl[T]) GetUsedBy() []string {
}
return keys
}

func (a *AtomImpl[T]) GetMeta() *AtomMeta {
a.lock.Lock()
defer a.lock.Unlock()
return a.meta
}
Comment on lines +101 to +105
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Do not return internal pointer; return a clone to avoid data races

Returning a.meta exposes internal state for mutation without locks. Return a cloned copy.

Apply:

 func (a *AtomImpl[T]) GetMeta() *AtomMeta {
 	a.lock.Lock()
 	defer a.lock.Unlock()
-	return a.meta
+	return cloneAtomMeta(a.meta)
 }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In tsunami/engine/atomimpl.go around lines 100-104, the method currently returns
the internal pointer a.meta which exposes internal state; modify GetMeta to hold
the lock, defensively handle nil, create a cloned copy of the AtomMeta while
locked (e.g., metaCopy := *a.meta; return &metaCopy) or call/implement an
AtomMeta.Clone() that deep-copies any reference fields, then return that clone
so callers cannot mutate internal state outside the lock.


func (a *AtomImpl[T]) GetAtomType() reflect.Type {
return reflect.TypeOf((*T)(nil)).Elem()
}
2 changes: 1 addition & 1 deletion tsunami/engine/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ func UseLocal(vc *RenderContextImpl, initialVal any) string {
atomName := "$local." + vc.GetCompWaveId() + "#" + strconv.Itoa(hookVal.Idx)
if !hookVal.Init {
hookVal.Init = true
atom := MakeAtomImpl(initialVal)
atom := MakeAtomImpl(initialVal, nil)
vc.Root.RegisterAtom(atomName, atom)
closedAtomName := atomName
hookVal.UnmountFn = func() {
Expand Down
Loading
Loading