Skip to content
Closed
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
3 changes: 2 additions & 1 deletion .ai/knowledge/modules/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Centralizes node logging by filtering `astral/log` output, writing log entries t
- File sink: `CreateLogFile` creates `~/.config/astrald/logs/astrald.log.<timestamp>` when the home directory is available -> `LogFile.LogEntry` serializes writes through a mutex and sends entries through a channel wrapper.
- Live stream: `log.listen` accepts the raw query stream -> creates a channel with optional input and output formats -> adds `logForwarder` -> waits until the client closes by reading from the channel -> deferred removal stops forwarding.
- Rendering: package init and loader-installed view functions render primitives, identities, queries, and entries with module theme colors; identity rendering falls back to fingerprint when no resolver is available.
- External-type rendering: blueprint-backed `astral.RuntimeObject` values have no compile-time view; `RuntimeObjectView` registers via `fmt.SetFallbackView` and renders `Type{Field: value, ...}` (struct) or `Type(value)` (alias), delegating each field to `fmt.ViewFor`. Runtime container carriers (`RuntimeSlice`/`RuntimeArray`/`RuntimeMap`) register per-type under their constant `ObjectType` (`slice`/`array`/`map`). Replaces the prior raw `%v` Go dump. See issue #337.

## Source

Expand All @@ -27,7 +28,7 @@ Centralizes node logging by filtering `astral/log` output, writing log entries t
- `mod/log/src/module.go`, `mod/log/src/config.go`, `mod/log/src/deps.go` - module state, dynamic log-level filter, config binding, and dependency wiring.
- `mod/log/src/log_file.go` - per-run log file creation and serialized entry writes.
- `mod/log/src/op_listen.go` - `log.listen` handler and live forwarding logger.
- `mod/log/views/` - terminal renderers and opt-in view registration functions.
- `mod/log/views/` - terminal renderers and opt-in view registration functions; `runtime_object_view.go` (external-type fallback) and `runtime_{slice,array,map}_view.go` (container carriers).
- `mod/log/styles/`, `mod/log/theme/` - reusable color, gradient, and style helpers for views.

## Surface
Expand Down
22 changes: 22 additions & 0 deletions astral/fmt/printf.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"io"
"os"
"reflect"
"sync/atomic"

"github.com/cryptopunkscc/astrald/astral"
"github.com/cryptopunkscc/astrald/sig"
Expand All @@ -16,8 +17,22 @@ type Printer struct {

var viewBuilders sig.Map[string, ViewBuilder]

// fallbackView builds a View for an astral.Object whose ObjectType has no entry in
// viewBuilders. Blueprint-backed objects (RuntimeObject) carry a runtime-only type name the
// per-type registry can never match, so they route here instead of the %v Stringify dump.
// nil until SetFallbackView installs one. ViewBuilder is not comparable, so the slot is an
// atomic.Pointer rather than a sig.Value.
var fallbackView atomic.Pointer[ViewBuilder]

type ViewBuilder func(astral.Object) View

// SetFallbackView installs the builder ViewFor consults after a per-type registry miss, before
// the Stringify path. A builder returning nil declines, leaving Stringify to handle the object.
// Replaces any prior fallback.
func SetFallbackView(fn ViewBuilder) {
fallbackView.Store(&fn)
}

func NewPrinter(writer io.Writer) *Printer {
return &Printer{Writer: writer}
}
Expand Down Expand Up @@ -257,6 +272,13 @@ func ViewFor(a any) (view View) {
if builder, found := viewBuilders.Get(o.ObjectType()); found {
return builder(o)
}
// why: external types carry a runtime-only name viewBuilders can't key on; route them
// through the fallback (generic blueprint render) before the raw %v Stringify dump.
if fn := fallbackView.Load(); fn != nil {
if v := (*fn)(o); v != nil {
return v
}
}
}

return stringView(astral.Stringify(a))
Expand Down
5 changes: 5 additions & 0 deletions astral/runtime_object.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ func (ro *RuntimeObject) Underlying() Object {
return ro.value
}

// Blueprint returns the schema backing this RuntimeObject, or nil if unbound. Callers reflect
// on the result — Fields for struct kind, Underlying for alias kind — to render or inspect a
// type the binary was not built with.
func (ro *RuntimeObject) Blueprint() *Blueprint { return ro.bp }

// GetRuntimeObject returns a fresh RuntimeObject backed by this Blueprint. Spec-zeros are
// resolved through defaultBlueprints; route through Blueprints.New for a custom registry.
func (bp *Blueprint) GetRuntimeObject() (*RuntimeObject, error) { return NewRuntimeObject(bp) }
Expand Down
36 changes: 36 additions & 0 deletions mod/log/views/runtime_array_view.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package views

import (
"strconv"

"github.com/cryptopunkscc/astrald/astral"
"github.com/cryptopunkscc/astrald/astral/fmt"
"github.com/cryptopunkscc/astrald/mod/log/styles"
)

// RuntimeArrayView renders a blueprint-backed fixed-length array as [N]{elem, elem, ...},
// delegating each element to fmt.ViewFor. The carrier's ObjectType is the constant "array", so
// a per-type builder matches it directly. See issue #337.
type RuntimeArrayView struct {
*astral.RuntimeArray
}

func (v RuntimeArrayView) Render() (out string) {
out += styles.Highlight.Render("["+strconv.Itoa(v.Len())+"]") +
styles.Highlight.Render("{")
for i := 0; i < v.Len(); i++ {
if i > 0 {
out += ", "
}
out += fmt.ViewFor(v.At(i)).Render()
}
out += styles.Highlight.Render("}")

return
}

func init() {
fmt.SetView(func(o *astral.RuntimeArray) fmt.View {
return RuntimeArrayView{RuntimeArray: o}
})
}
64 changes: 64 additions & 0 deletions mod/log/views/runtime_map_view.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package views

import (
"sort"

"github.com/cryptopunkscc/astrald/astral"
"github.com/cryptopunkscc/astrald/astral/fmt"
"github.com/cryptopunkscc/astrald/mod/log/styles"
)

// RuntimeMapView renders a blueprint-backed map as map[key: value, key: value], delegating both
// keys and values to fmt.ViewFor. The carrier's ObjectType is the constant "map", so a per-type
// builder matches it directly. See issue #337.
type RuntimeMapView struct {
*astral.RuntimeMap
}

type mapEntry struct {
key any
value astral.Object
}

func (v RuntimeMapView) Render() (out string) {
// why: RuntimeMap.Each iterates in unspecified order; collect and sort so a given map always
// logs identically. Keys are string or uint64 (homogeneous per map); less() orders both.
var entries []mapEntry
_ = v.Each(func(key any, value astral.Object) error {
entries = append(entries, mapEntry{key: key, value: value})
return nil
})
sort.Slice(entries, func(i, j int) bool {
return lessMapKey(entries[i].key, entries[j].key)
})

out += styles.Highlight.Render("map[")
for i, e := range entries {
if i > 0 {
out += ", "
}
out += fmt.ViewFor(e.key).Render() +
styles.Highlight.Render(": ") +
fmt.ViewFor(e.value).Render()
}
out += styles.Highlight.Render("]")

return
}

// lessMapKey orders two RuntimeMap keys. uint64 keys (uintN-keyed maps) compare numerically;
// anything else compares by its astral string form.
func lessMapKey(a, b any) bool {
if ua, ok := a.(uint64); ok {
if ub, ok := b.(uint64); ok {
return ua < ub
}
}
return astral.Stringify(a) < astral.Stringify(b)
}

func init() {
fmt.SetView(func(o *astral.RuntimeMap) fmt.View {
return RuntimeMapView{RuntimeMap: o}
})
}
54 changes: 54 additions & 0 deletions mod/log/views/runtime_object_view.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package views

import (
"github.com/cryptopunkscc/astrald/astral"
"github.com/cryptopunkscc/astrald/astral/fmt"
"github.com/cryptopunkscc/astrald/mod/log/styles"
"github.com/cryptopunkscc/astrald/mod/log/theme"
)

// RuntimeObjectView renders a blueprint-backed object whose type has no compiled-in view. It is
// the generic fallback for external types: struct kind renders Type{Field: value, ...}, alias
// kind renders Type(value). Each field value delegates to fmt.ViewFor, so nested primitives and
// nested external types render through the same machinery. See issue #337.
type RuntimeObjectView struct {
*astral.RuntimeObject
}

func (v RuntimeObjectView) Render() (out string) {
bp := v.Blueprint()
out += theme.Type.Bri(theme.Less).Render(v.ObjectType())

// alias kind: Type(value)
if bp.Kind() == astral.BlueprintKindAlias {
out += styles.Highlight.Render("(") +
fmt.ViewFor(v.Underlying()).Render() +
styles.Highlight.Render(")")
return
}

// struct kind: Type{Field: value, Field: value}
out += styles.Highlight.Render("{")
for i, f := range bp.Fields {
if i > 0 {
out += ", "
}
name := f.Name.String()
out += theme.Normal.Render(name) + ": " + fmt.ViewFor(v.Get(name)).Render()
}
out += styles.Highlight.Render("}")

return
}

func init() {
// why: external type names are runtime-only, so register as the fmt fallback rather than a
// per-type builder. Decline non-RuntimeObject and unbound carriers — they fall to Stringify.
fmt.SetFallbackView(func(o astral.Object) fmt.View {
ro, ok := o.(*astral.RuntimeObject)
if !ok || ro.Blueprint() == nil {
return nil
}
return RuntimeObjectView{RuntimeObject: ro}
})
}
96 changes: 96 additions & 0 deletions mod/log/views/runtime_object_view_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package views

import (
"regexp"
"strings"
"testing"

"github.com/cryptopunkscc/astrald/astral"
"github.com/cryptopunkscc/astrald/astral/fmt"
)

// ansi strips lipgloss color escapes so assertions compare on rendered text alone.
var ansi = regexp.MustCompile(`\x1b\[[0-9;]*m`)

func render(o astral.Object) string {
return ansi.ReplaceAllString(fmt.ViewFor(o).Render(), "")
}

func mustRO(t *testing.T, bp *astral.Blueprint) *astral.RuntimeObject {
t.Helper()
ro, err := astral.NewRuntimeObject(bp)
if err != nil {
t.Fatal(err)
}
return ro
}

func TestRuntimeObjectView_Struct(t *testing.T) {
bp := astral.NewBlueprint("test.song",
astral.Field{Name: "title", Spec: &astral.PrimitiveSpec{PrimitiveType: "string16"}},
astral.Field{Name: "track", Spec: &astral.PrimitiveSpec{PrimitiveType: "uint32"}},
)
ro := mustRO(t, bp)
if err := ro.Set("title", "My Song"); err != nil {
t.Fatal(err)
}
if err := ro.Set("track", uint32(7)); err != nil {
t.Fatal(err)
}

got := render(ro)

// why: a raw go dump leaks pointer hex and the &{ struct prefix; the generic view must not.
if strings.Contains(got, "0x") || strings.HasPrefix(got, "&{") {
t.Fatalf("render leaked a go dump: %q", got)
}
for _, want := range []string{"test.song", "title: ", "My Song", "track: ", "7"} {
if !strings.Contains(got, want) {
t.Fatalf("render %q missing %q", got, want)
}
}
// declared field order is preserved
if strings.Index(got, "title") > strings.Index(got, "track") {
t.Fatalf("fields out of declared order: %q", got)
}
}

func TestRuntimeObjectView_Alias(t *testing.T) {
bp := astral.NewBlueprintAlias("test.level", "uint8")
ro := mustRO(t, bp)
if err := ro.UnmarshalJSON([]byte("9")); err != nil {
t.Fatal(err)
}

got := render(ro)

if !strings.Contains(got, "test.level") || !strings.Contains(got, "9") {
t.Fatalf("alias render %q missing type or value", got)
}
if !strings.Contains(got, "(") || !strings.Contains(got, ")") {
t.Fatalf("alias render %q not in Type(value) form", got)
}
}

func TestRuntimeSliceView(t *testing.T) {
rs, err := astral.NewRuntimeSlice("uint32")
if err != nil {
t.Fatal(err)
}
for _, v := range []uint32{1, 2, 3} {
if err := rs.Append(astral.NewUint32(v)); err != nil {
t.Fatal(err)
}
}

got := render(rs)

if strings.Contains(got, "0x") {
t.Fatalf("slice render leaked a go dump: %q", got)
}
for _, want := range []string{"[", "1", "2", "3", "]"} {
if !strings.Contains(got, want) {
t.Fatalf("slice render %q missing %q", got, want)
}
}
}
33 changes: 33 additions & 0 deletions mod/log/views/runtime_slice_view.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package views

import (
"github.com/cryptopunkscc/astrald/astral"
"github.com/cryptopunkscc/astrald/astral/fmt"
"github.com/cryptopunkscc/astrald/mod/log/styles"
)

// RuntimeSliceView renders a blueprint-backed slice as [elem, elem, ...], delegating each
// element to fmt.ViewFor. The carrier's ObjectType is the constant "slice", so a per-type
// builder matches it directly. See issue #337.
type RuntimeSliceView struct {
*astral.RuntimeSlice
}

func (v RuntimeSliceView) Render() (out string) {
out += styles.Highlight.Render("[")
for i := 0; i < v.Len(); i++ {
if i > 0 {
out += ", "
}
out += fmt.ViewFor(v.At(i)).Render()
}
out += styles.Highlight.Render("]")

return
}

func init() {
fmt.SetView(func(o *astral.RuntimeSlice) fmt.View {
return RuntimeSliceView{RuntimeSlice: o}
})
}