Files
pulse/scripts/generate-types.go
T
rcourtman 72945d04f4 Harden Pulse Assistant approval planning
Persist approval requests as governed planned actions with context confidence and stream that plan into Pulse Assistant approvals.
Reuse the planned action identity when approved actions execute so the unified audit trail stays continuous.
2026-04-23 23:36:57 +01:00

187 lines
5.0 KiB
Go

// Generates TypeScript types for frontend consumption from Go types.
//
// Currently this focuses on Pulse Assistant chat SSE event payloads.
// Source of truth: internal/ai/chat event data structs.
//
// Usage:
//
// go run ./scripts/generate-types.go
//
// Output:
//
// frontend-modern/src/api/generated/aiChatEvents.ts
package main
import (
"bytes"
"fmt"
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/chat"
)
type tsTypeDef struct {
Name string
Body string
}
func main() {
outPath := filepath.Join("frontend-modern", "src", "api", "generated", "aiChatEvents.ts")
defs := []reflect.Type{
reflect.TypeOf(chat.ContentData{}),
reflect.TypeOf(chat.ThinkingData{}),
reflect.TypeOf(chat.ExploreStatusData{}),
reflect.TypeOf(chat.ToolStartData{}),
reflect.TypeOf(chat.ToolEndData{}),
reflect.TypeOf(chat.ApprovalPlanData{}),
reflect.TypeOf(chat.ApprovalContextConfidenceData{}),
reflect.TypeOf(chat.ApprovalNeededData{}),
reflect.TypeOf(chat.QuestionData{}),
reflect.TypeOf(chat.Question{}),
reflect.TypeOf(chat.QuestionOption{}),
reflect.TypeOf(chat.DoneData{}),
reflect.TypeOf(chat.ErrorData{}),
}
tsDefs := make([]tsTypeDef, 0, len(defs)+4)
for _, t := range defs {
tsDefs = append(tsDefs, tsTypeDef{Name: t.Name(), Body: renderInterface(t)})
}
// Sort for stable output.
sort.Slice(tsDefs, func(i, j int) bool { return tsDefs[i].Name < tsDefs[j].Name })
var buf bytes.Buffer
buf.WriteString("// Code generated by scripts/generate-types.go; DO NOT EDIT.\n")
buf.WriteString("// Source: internal/ai/chat event payload structs.\n\n")
buf.WriteString("/* eslint-disable */\n\n")
for _, d := range tsDefs {
buf.WriteString(d.Body)
buf.WriteString("\n\n")
}
// Stream event union matches internal/api/contract_test.go snapshots.
buf.WriteString("export type AIChatStreamEvent =\n")
buf.WriteString(" | { type: 'content'; data: ContentData }\n")
buf.WriteString(" | { type: 'thinking'; data: ThinkingData }\n")
buf.WriteString(" | { type: 'explore_status'; data: ExploreStatusData }\n")
buf.WriteString(" | { type: 'tool_start'; data: ToolStartData }\n")
buf.WriteString(" | { type: 'tool_end'; data: ToolEndData }\n")
buf.WriteString(" | { type: 'approval_needed'; data: ApprovalNeededData }\n")
// QuestionData is wrapped by the backend as {question_id, questions} plus session_id in some callers.
// The contract test covers {question_id, questions}; the UI currently expects session_id too.
// Keep session_id optional for backward compatibility.
buf.WriteString(" | { type: 'question'; data: (QuestionData & { session_id?: string }) }\n")
buf.WriteString(" | { type: 'done'; data?: DoneData }\n")
buf.WriteString(" | { type: 'error'; data: ErrorData }\n")
buf.WriteString(";\n")
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
fatal(err)
}
if err := os.WriteFile(outPath, buf.Bytes(), 0o644); err != nil {
fatal(err)
}
}
func fatal(err error) {
fmt.Fprintln(os.Stderr, err.Error())
os.Exit(1)
}
func renderInterface(t reflect.Type) string {
if t.Kind() != reflect.Struct {
return ""
}
var b strings.Builder
b.WriteString("export interface ")
b.WriteString(t.Name())
b.WriteString(" {\n")
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
if f.PkgPath != "" { // unexported
continue
}
jsonName, optional := jsonFieldName(f)
if jsonName == "-" || jsonName == "" {
continue
}
b.WriteString(" ")
b.WriteString(jsonName)
if optional {
b.WriteString("?: ")
} else {
b.WriteString(": ")
}
b.WriteString(goTypeToTS(f.Type))
b.WriteString(";\n")
}
b.WriteString("}")
return b.String()
}
func jsonFieldName(f reflect.StructField) (name string, optional bool) {
tag := f.Tag.Get("json")
if tag == "" {
// Default to lower_snake? The backend always uses json tags for these structs.
return "", false
}
parts := strings.Split(tag, ",")
name = parts[0]
for _, p := range parts[1:] {
if p == "omitempty" {
optional = true
break
}
}
// Pointers are optional in practice.
if f.Type.Kind() == reflect.Pointer {
optional = true
}
return name, optional
}
func goTypeToTS(t reflect.Type) string {
switch t.Kind() {
case reflect.String:
return "string"
case reflect.Bool:
return "boolean"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return "number"
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return "number"
case reflect.Float32, reflect.Float64:
return "number"
case reflect.Slice, reflect.Array:
return goTypeToTS(t.Elem()) + "[]"
case reflect.Pointer:
return goTypeToTS(t.Elem())
case reflect.Struct:
// Named structs become references.
if t.Name() != "" {
return t.Name()
}
// Fallback anonymous struct shape.
return "Record<string, unknown>"
case reflect.Map:
// We only need map for generic payloads; keep unknown to avoid lying.
return "Record<string, unknown>"
case reflect.Interface:
return "unknown"
default:
return "unknown"
}
}