Files
pulse/scripts/generate-types.go
T
rcourtman 59391aebf7 Keep live Assistant workflow activity visible
Preserve workflow activity rows across active stream content and tool boundaries, and type the generated chat SSE route metadata that drives model activity rows.
2026-06-07 02:41:57 +01:00

195 lines
5.3 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
//go:build ignore
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.WorkflowStateData{}),
reflect.TypeOf(chat.SessionData{}),
reflect.TypeOf(chat.ToolStartData{}),
reflect.TypeOf(chat.ToolProgressData{}),
reflect.TypeOf(chat.ToolCancelData{}),
reflect.TypeOf(chat.ToolEndData{}),
reflect.TypeOf(chat.ApprovalPlanData{}),
reflect.TypeOf(chat.ApprovalContextConfidenceData{}),
reflect.TypeOf(chat.ApprovalPreflightData{}),
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: 'session'; data: SessionData }\n")
buf.WriteString(" | { type: 'content'; data: ContentData }\n")
buf.WriteString(" | { type: 'thinking'; data: ThinkingData }\n")
buf.WriteString(" | { type: 'workflow_state'; data: WorkflowStateData }\n")
buf.WriteString(" | { type: 'tool_start'; data: ToolStartData }\n")
buf.WriteString(" | { type: 'tool_progress'; data: ToolProgressData }\n")
buf.WriteString(" | { type: 'tool_cancel'; data: ToolCancelData }\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")
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"
}
}