Build decision flow editor and runtime integration

This commit is contained in:
Remi
2025-09-20 18:33:32 +02:00
parent 6999235668
commit cf6748b9bc
23 changed files with 4030 additions and 115 deletions

View File

@@ -0,0 +1,12 @@
import { createError } from 'h3'
import { buildRuntimeDecisionTree } from '../../../services/decisionFlowService'
export default defineEventHandler(async (event) => {
const slugParam = event.context.params?.slug
if (typeof slugParam !== 'string' || !slugParam.trim()) {
throw createError({ statusCode: 400, statusMessage: 'Missing flow identifier' })
}
const tree = await buildRuntimeDecisionTree(slugParam.trim())
return tree
})

View File

@@ -0,0 +1,8 @@
import { listDecisionFlows } from '../../services/decisionFlowService'
import { requireAdmin } from '../../utils/auth'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const flows = await listDecisionFlows()
return flows
})

View File

@@ -0,0 +1,71 @@
import { readBody, createError } from 'h3'
import { requireAdmin } from '../../utils/auth'
import { DecisionFlow } from '../../models/DecisionFlow'
import { getFlowWithNodes } from '../../services/decisionFlowService'
function sanitizeSlug(input: string) {
return input.toLowerCase().replace(/[^a-z0-9-_]/gi, '-')
}
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const body = await readBody<Record<string, any>>(event)
const rawSlug = typeof body.slug === 'string' ? body.slug.trim() : ''
const slug = rawSlug ? sanitizeSlug(rawSlug) : ''
if (!slug) {
throw createError({ statusCode: 400, statusMessage: 'Slug is required' })
}
const rawName = typeof body.name === 'string' ? body.name.trim() : ''
if (!rawName) {
throw createError({ statusCode: 400, statusMessage: 'Name is required' })
}
const existing = await DecisionFlow.findOne({ slug })
if (existing) {
throw createError({ statusCode: 409, statusMessage: 'A decision flow with this slug already exists' })
}
const startState = (typeof body.startState === 'string' ? body.startState.trim() : '') || 'START'
const description = typeof body.description === 'string' ? body.description.trim() : undefined
const schemaVersion = (typeof body.schemaVersion === 'string' ? body.schemaVersion.trim() : '') || '1.0'
const roles = Array.isArray(body.roles)
? body.roles
.map((role: any) => (typeof role === 'string' ? role.trim() : ''))
.filter((role: string) => role.length)
: ['pilot', 'atc', 'system']
const phases = Array.isArray(body.phases)
? body.phases
.map((phase: any) => (typeof phase === 'string' ? phase.trim() : ''))
.filter((phase: string) => phase.length)
: []
const endStates = Array.isArray(body.endStates)
? body.endStates
.map((state: any) => (typeof state === 'string' ? state.trim() : ''))
.filter((state: string) => state.length)
: [startState]
const flow = new DecisionFlow({
slug,
name: rawName,
description,
schemaVersion,
startState,
endStates,
variables: body.variables && typeof body.variables === 'object' ? body.variables : {},
flags: body.flags && typeof body.flags === 'object' ? body.flags : {},
policies: body.policies && typeof body.policies === 'object' ? body.policies : {},
hooks: body.hooks && typeof body.hooks === 'object' ? body.hooks : {},
roles,
phases,
})
await flow.save()
const { flow: serialized } = await getFlowWithNodes(slug)
return serialized
})

View File

@@ -0,0 +1,14 @@
import { createError } from 'h3'
import { requireAdmin } from '../../../../utils/auth'
import { getFlowWithNodes } from '../../../../services/decisionFlowService'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const slug = event.context.params?.slug
if (typeof slug !== 'string' || !slug.trim()) {
throw createError({ statusCode: 400, statusMessage: 'Missing flow identifier' })
}
const data = await getFlowWithNodes(slug.trim())
return data
})

View File

@@ -0,0 +1,150 @@
import { createError, readBody } from 'h3'
import { requireAdmin } from '../../../../utils/auth'
import { DecisionFlow } from '../../../../models/DecisionFlow'
import { getFlowWithNodes } from '../../../../services/decisionFlowService'
function sanitizeStringArray(values: any): string[] | undefined {
if (!Array.isArray(values)) return undefined
const mapped = values
.map((value: any) => (typeof value === 'string' ? value.trim() : ''))
.filter((value: string) => value.length)
if (!mapped.length) return undefined
return Array.from(new Set(mapped))
}
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const slugParam = event.context.params?.slug
if (typeof slugParam !== 'string' || !slugParam.trim()) {
throw createError({ statusCode: 400, statusMessage: 'Missing flow identifier' })
}
const slug = slugParam.trim()
const flow = await DecisionFlow.findOne({ slug })
if (!flow) {
throw createError({ statusCode: 404, statusMessage: 'Decision flow not found' })
}
const body = await readBody<Record<string, any>>(event)
if (typeof body.name === 'string' && body.name.trim()) {
flow.name = body.name.trim()
}
if (typeof body.description === 'string') {
flow.description = body.description.trim() || undefined
}
if (typeof body.schemaVersion === 'string') {
const version = body.schemaVersion.trim()
flow.schemaVersion = version || flow.schemaVersion
}
if (typeof body.startState === 'string' && body.startState.trim()) {
flow.startState = body.startState.trim()
}
const endStates = sanitizeStringArray(body.endStates)
if (endStates) {
flow.endStates = endStates
}
const roles = sanitizeStringArray(body.roles)
if (roles) {
flow.roles = roles
}
const phases = sanitizeStringArray(body.phases)
if (phases) {
flow.phases = phases
}
if (body.variables && typeof body.variables === 'object') {
flow.variables = body.variables
flow.markModified('variables')
}
if (body.flags && typeof body.flags === 'object') {
flow.flags = body.flags
flow.markModified('flags')
}
if (body.policies && typeof body.policies === 'object') {
flow.policies = body.policies
flow.markModified('policies')
}
if (body.hooks && typeof body.hooks === 'object') {
flow.hooks = body.hooks
flow.markModified('hooks')
}
if (body.layout && typeof body.layout === 'object') {
const layout = flow.layout || { zoom: 1, pan: { x: 0, y: 0 }, groups: [] }
const zoomValue = body.layout.zoom
const zoom = typeof zoomValue === 'number' ? zoomValue : Number(zoomValue)
if (Number.isFinite(zoom)) {
layout.zoom = Math.min(Math.max(zoom, 0.25), 3)
}
if (body.layout.pan && typeof body.layout.pan === 'object') {
const panX = body.layout.pan.x
const panY = body.layout.pan.y
const parsedX = typeof panX === 'number' ? panX : Number(panX)
const parsedY = typeof panY === 'number' ? panY : Number(panY)
if (Number.isFinite(parsedX)) layout.pan = layout.pan || { x: 0, y: 0 }
if (Number.isFinite(parsedX)) layout.pan.x = parsedX
if (Number.isFinite(parsedY)) layout.pan = layout.pan || { x: 0, y: 0 }
if (Number.isFinite(parsedY)) layout.pan.y = parsedY
}
if (Array.isArray(body.layout.groups)) {
layout.groups = body.layout.groups
.map((group: any) => {
if (!group || typeof group !== 'object') return null
const id = typeof group.id === 'string' ? group.id.trim() : ''
const label = typeof group.label === 'string' ? group.label.trim() : ''
if (!id || !label) return null
if (!group.bounds || typeof group.bounds !== 'object') return null
const bounds = {
x: Number(group.bounds.x) || 0,
y: Number(group.bounds.y) || 0,
width: Number(group.bounds.width) || 0,
height: Number(group.bounds.height) || 0,
}
return {
id,
label,
color: typeof group.color === 'string' ? group.color.trim() || undefined : undefined,
bounds,
}
})
.filter((group): group is NonNullable<typeof group> => Boolean(group))
}
flow.layout = layout
flow.markModified('layout')
}
if (body.metadata && typeof body.metadata === 'object') {
const metadata = flow.metadata || {}
if (typeof body.metadata.notes === 'string') {
metadata.notes = body.metadata.notes.trim() || undefined
}
if (Array.isArray(body.metadata.tags)) {
metadata.tags = body.metadata.tags
.map((tag: any) => (typeof tag === 'string' ? tag.trim() : ''))
.filter((tag: string) => tag.length)
}
if (typeof body.metadata.ownerId === 'string') {
metadata.ownerId = body.metadata.ownerId.trim() || undefined
}
if (typeof body.metadata.lastEditedBy === 'string') {
metadata.lastEditedBy = body.metadata.lastEditedBy.trim() || undefined
}
flow.metadata = metadata
flow.markModified('metadata')
}
await flow.save()
const data = await getFlowWithNodes(slug)
return data
})

View File

@@ -0,0 +1,101 @@
import { createError, readBody } from 'h3'
import { requireAdmin } from '../../../../utils/auth'
import { DecisionFlow } from '../../../../models/DecisionFlow'
import { DecisionNode } from '../../../../models/DecisionNode'
import {
sanitizeLayout,
sanitizeLLMTemplate,
sanitizeMetadata,
sanitizeTransition,
} from '../../../../utils/decisionSanitizer'
import { serializeNodeDocument } from '../../../../services/decisionFlowService'
const ROLE_SET = new Set(['pilot', 'atc', 'system'])
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const slugParam = event.context.params?.slug
if (typeof slugParam !== 'string' || !slugParam.trim()) {
throw createError({ statusCode: 400, statusMessage: 'Missing flow identifier' })
}
const slug = slugParam.trim()
const flow = await DecisionFlow.findOne({ slug })
if (!flow) {
throw createError({ statusCode: 404, statusMessage: 'Decision flow not found' })
}
const body = await readBody<Record<string, any>>(event)
const rawStateId = typeof body.stateId === 'string' ? body.stateId.trim() : ''
if (!rawStateId) {
throw createError({ statusCode: 400, statusMessage: 'stateId is required' })
}
const stateId = rawStateId.toUpperCase()
const existingNode = await DecisionNode.findOne({ flow: flow._id, stateId })
if (existingNode) {
throw createError({ statusCode: 409, statusMessage: 'State already exists in this flow' })
}
const role = typeof body.role === 'string' ? body.role.trim().toLowerCase() : ''
if (!ROLE_SET.has(role)) {
throw createError({ statusCode: 400, statusMessage: 'role must be pilot, atc or system' })
}
const phase = typeof body.phase === 'string' ? body.phase.trim() : ''
if (!phase) {
throw createError({ statusCode: 400, statusMessage: 'phase is required' })
}
const transitions = Array.isArray(body.transitions)
? body.transitions.map((transition: any, index: number) => sanitizeTransition(transition, index))
: []
const layout = sanitizeLayout(body.layout) || { x: 0, y: 0 }
const metadata = sanitizeMetadata(body.metadata)
const llmTemplate = sanitizeLLMTemplate(body.llmTemplate)
const readbackRequired = Array.isArray(body.readbackRequired)
? body.readbackRequired
.map((entry: any) => (typeof entry === 'string' ? entry.trim() : ''))
.filter((entry: string) => entry.length)
: []
const node = new DecisionNode({
flow: flow._id,
stateId,
title: typeof body.title === 'string' ? body.title.trim() || undefined : undefined,
summary: typeof body.summary === 'string' ? body.summary.trim() || undefined : undefined,
role,
phase,
sayTemplate: typeof body.sayTemplate === 'string' ? body.sayTemplate.trim() || undefined : undefined,
utteranceTemplate:
typeof body.utteranceTemplate === 'string' ? body.utteranceTemplate.trim() || undefined : undefined,
elseSayTemplate:
typeof body.elseSayTemplate === 'string' ? body.elseSayTemplate.trim() || undefined : undefined,
readbackRequired,
autoBehavior: typeof body.autoBehavior === 'string' ? body.autoBehavior.trim() || undefined : undefined,
actions: Array.isArray(body.actions) ? body.actions : [],
handoff:
body.handoff && typeof body.handoff === 'object' && typeof body.handoff.to === 'string'
? {
to: body.handoff.to.trim(),
freq: typeof body.handoff.freq === 'string' ? body.handoff.freq.trim() || undefined : undefined,
note: typeof body.handoff.note === 'string' ? body.handoff.note.trim() || undefined : undefined,
}
: undefined,
guard: typeof body.guard === 'string' ? body.guard.trim() || undefined : undefined,
trigger: typeof body.trigger === 'string' ? body.trigger.trim() || undefined : undefined,
frequency: typeof body.frequency === 'string' ? body.frequency.trim() || undefined : undefined,
frequencyName:
typeof body.frequencyName === 'string' ? body.frequencyName.trim() || undefined : undefined,
transitions,
layout,
metadata,
llmTemplate,
})
await node.save()
return serializeNodeDocument(node)
})

View File

@@ -0,0 +1,31 @@
import { createError } from 'h3'
import { requireAdmin } from '../../../../../../utils/auth'
import { DecisionFlow } from '../../../../../../models/DecisionFlow'
import { DecisionNode } from '../../../../../../models/DecisionNode'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const slugParam = event.context.params?.slug
const stateParam = event.context.params?.stateId
if (typeof slugParam !== 'string' || !slugParam.trim()) {
throw createError({ statusCode: 400, statusMessage: 'Missing flow identifier' })
}
if (typeof stateParam !== 'string' || !stateParam.trim()) {
throw createError({ statusCode: 400, statusMessage: 'Missing state identifier' })
}
const slug = slugParam.trim()
const stateId = stateParam.trim().toUpperCase()
const flow = await DecisionFlow.findOne({ slug })
if (!flow) {
throw createError({ statusCode: 404, statusMessage: 'Decision flow not found' })
}
const result = await DecisionNode.deleteOne({ flow: flow._id, stateId })
if (!result.deletedCount) {
throw createError({ statusCode: 404, statusMessage: 'State not found' })
}
return { success: true }
})

View File

@@ -0,0 +1,137 @@
import { createError, readBody } from 'h3'
import { requireAdmin } from '../../../../../../utils/auth'
import { DecisionFlow } from '../../../../../../models/DecisionFlow'
import { DecisionNode } from '../../../../../../models/DecisionNode'
import {
sanitizeLayout,
sanitizeLLMTemplate,
sanitizeMetadata,
sanitizeTransition,
} from '../../../../../../utils/decisionSanitizer'
import { serializeNodeDocument } from '../../../../../../services/decisionFlowService'
const ROLE_SET = new Set(['pilot', 'atc', 'system'])
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const slugParam = event.context.params?.slug
const stateParam = event.context.params?.stateId
if (typeof slugParam !== 'string' || !slugParam.trim()) {
throw createError({ statusCode: 400, statusMessage: 'Missing flow identifier' })
}
if (typeof stateParam !== 'string' || !stateParam.trim()) {
throw createError({ statusCode: 400, statusMessage: 'Missing state identifier' })
}
const slug = slugParam.trim()
const stateId = stateParam.trim().toUpperCase()
const flow = await DecisionFlow.findOne({ slug })
if (!flow) {
throw createError({ statusCode: 404, statusMessage: 'Decision flow not found' })
}
const node = await DecisionNode.findOne({ flow: flow._id, stateId })
if (!node) {
throw createError({ statusCode: 404, statusMessage: 'State not found' })
}
const body = await readBody<Record<string, any>>(event)
if (typeof body.title === 'string') {
node.title = body.title.trim() || undefined
}
if (typeof body.summary === 'string') {
node.summary = body.summary.trim() || undefined
}
if (typeof body.role === 'string') {
const role = body.role.trim().toLowerCase()
if (!ROLE_SET.has(role)) {
throw createError({ statusCode: 400, statusMessage: 'role must be pilot, atc or system' })
}
node.role = role
}
if (typeof body.phase === 'string' && body.phase.trim()) {
node.phase = body.phase.trim()
}
if (typeof body.sayTemplate === 'string') {
node.sayTemplate = body.sayTemplate.trim() || undefined
}
if (typeof body.utteranceTemplate === 'string') {
node.utteranceTemplate = body.utteranceTemplate.trim() || undefined
}
if (typeof body.elseSayTemplate === 'string') {
node.elseSayTemplate = body.elseSayTemplate.trim() || undefined
}
if (Array.isArray(body.readbackRequired)) {
node.readbackRequired = body.readbackRequired
.map((entry: any) => (typeof entry === 'string' ? entry.trim() : ''))
.filter((entry: string) => entry.length)
}
if (typeof body.autoBehavior === 'string') {
node.autoBehavior = body.autoBehavior.trim() || undefined
}
if (Array.isArray(body.actions)) {
node.actions = body.actions
}
if (body.handoff && typeof body.handoff === 'object') {
if (typeof body.handoff.to === 'string' && body.handoff.to.trim()) {
node.handoff = {
to: body.handoff.to.trim(),
freq: typeof body.handoff.freq === 'string' ? body.handoff.freq.trim() || undefined : undefined,
note: typeof body.handoff.note === 'string' ? body.handoff.note.trim() || undefined : undefined,
}
} else {
node.handoff = undefined
}
}
if (typeof body.guard === 'string') {
node.guard = body.guard.trim() || undefined
}
if (typeof body.trigger === 'string') {
node.trigger = body.trigger.trim() || undefined
}
if (typeof body.frequency === 'string') {
node.frequency = body.frequency.trim() || undefined
}
if (typeof body.frequencyName === 'string') {
node.frequencyName = body.frequencyName.trim() || undefined
}
if (Array.isArray(body.transitions)) {
node.transitions = body.transitions.map((transition: any, index: number) => sanitizeTransition(transition, index))
}
const layout = sanitizeLayout(body.layout)
if (layout) {
node.layout = layout
}
const metadata = sanitizeMetadata(body.metadata)
if (metadata) {
node.metadata = metadata
}
const llmTemplate = sanitizeLLMTemplate(body.llmTemplate)
if (llmTemplate) {
node.llmTemplate = llmTemplate
}
await node.save()
return serializeNodeDocument(node)
})

View File

@@ -0,0 +1,42 @@
import { createError, readBody } from 'h3'
import { requireAdmin } from '../../../../../../utils/auth'
import { DecisionFlow } from '../../../../../../models/DecisionFlow'
import { DecisionNode } from '../../../../../../models/DecisionNode'
import { sanitizeLayout } from '../../../../../../utils/decisionSanitizer'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const slugParam = event.context.params?.slug
const stateParam = event.context.params?.stateId
if (typeof slugParam !== 'string' || !slugParam.trim()) {
throw createError({ statusCode: 400, statusMessage: 'Missing flow identifier' })
}
if (typeof stateParam !== 'string' || !stateParam.trim()) {
throw createError({ statusCode: 400, statusMessage: 'Missing state identifier' })
}
const slug = slugParam.trim()
const stateId = stateParam.trim().toUpperCase()
const flow = await DecisionFlow.findOne({ slug })
if (!flow) {
throw createError({ statusCode: 404, statusMessage: 'Decision flow not found' })
}
const node = await DecisionNode.findOne({ flow: flow._id, stateId })
if (!node) {
throw createError({ statusCode: 404, statusMessage: 'State not found' })
}
const body = await readBody<Record<string, any>>(event)
const layoutUpdate = sanitizeLayout(body)
if (!layoutUpdate) {
throw createError({ statusCode: 400, statusMessage: 'Invalid layout payload' })
}
const existingLayout = node.layout || { x: 0, y: 0 }
node.layout = { ...existingLayout, ...layoutUpdate }
await node.save()
return { success: true, layout: node.layout }
})

View File

@@ -0,0 +1,19 @@
import { readBody } from 'h3'
import { requireAdmin } from '../../../utils/auth'
import { importATCDecisionTree } from '../../../services/decisionImportService'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const body = await readBody<Record<string, any> | undefined>(event)
const { flow, importedStates } = await importATCDecisionTree({
slug: typeof body?.slug === 'string' ? body.slug : undefined,
name: typeof body?.name === 'string' ? body.name : undefined,
description: typeof body?.description === 'string' ? body.description : undefined,
})
return {
flow,
importedStates,
}
})

View File

@@ -0,0 +1,103 @@
import mongoose from 'mongoose'
import type {
DecisionFlowLayout,
DecisionFlowMetadata,
} from '~~/shared/types/decision'
export interface DecisionFlowDocument extends mongoose.Document {
slug: string
name: string
description?: string
schemaVersion?: string
startState: string
endStates: string[]
variables: Record<string, any>
flags: Record<string, any>
policies: Record<string, any>
hooks: Record<string, any>
roles: string[]
phases: string[]
layout?: DecisionFlowLayout
metadata?: DecisionFlowMetadata
createdAt: Date
updatedAt: Date
}
const decisionFlowSchema = new mongoose.Schema<DecisionFlowDocument>(
{
slug: { type: String, required: true, unique: true, index: true },
name: { type: String, required: true },
description: { type: String },
schemaVersion: { type: String },
startState: { type: String, required: true },
endStates: { type: [String], default: () => [] },
variables: { type: mongoose.Schema.Types.Mixed, default: () => ({}) },
flags: { type: mongoose.Schema.Types.Mixed, default: () => ({}) },
policies: { type: mongoose.Schema.Types.Mixed, default: () => ({}) },
hooks: { type: mongoose.Schema.Types.Mixed, default: () => ({}) },
roles: { type: [String], default: () => [] },
phases: { type: [String], default: () => [] },
layout: {
type: new mongoose.Schema<DecisionFlowLayout>(
{
zoom: { type: Number, default: 1 },
pan: {
type: new mongoose.Schema({ x: { type: Number, default: 0 }, y: { type: Number, default: 0 } }, { _id: false }),
default: () => ({ x: 0, y: 0 }),
},
groups: {
type: [
new mongoose.Schema(
{
id: { type: String, required: true },
label: { type: String, required: true },
color: { type: String },
bounds: {
type: new mongoose.Schema(
{
x: { type: Number, required: true },
y: { type: Number, required: true },
width: { type: Number, required: true },
height: { type: Number, required: true },
},
{ _id: false }
),
required: true,
},
},
{ _id: false }
),
],
default: () => [],
},
},
{ _id: false }
),
default: () => ({ zoom: 1, pan: { x: 0, y: 0 }, groups: [] }),
},
metadata: {
type: new mongoose.Schema<DecisionFlowMetadata>(
{
notes: { type: String },
tags: { type: [String], default: () => [] },
ownerId: { type: String },
lastEditedBy: { type: String },
},
{ _id: false }
),
default: undefined,
},
},
{ timestamps: true }
)
decisionFlowSchema.index({ updatedAt: -1 })
decisionFlowSchema.set('toJSON', {
virtuals: true,
getters: true,
})
export const DecisionFlow =
(mongoose.models.DecisionFlow as mongoose.Model<DecisionFlowDocument>) ||
mongoose.model<DecisionFlowDocument>('DecisionFlow', decisionFlowSchema)

View File

@@ -0,0 +1,178 @@
import mongoose from 'mongoose'
import type {
DecisionNodeAutoTrigger,
DecisionNodeLayout,
DecisionNodeLLMPlaceholder,
DecisionNodeLLMTemplate,
DecisionNodeMetadata,
DecisionNodeModel,
DecisionNodeTransition,
} from '~~/shared/types/decision'
export interface DecisionNodeDocument
extends mongoose.Document,
Omit<DecisionNodeModel, 'stateId' | 'transitions'> {
flow: mongoose.Types.ObjectId
stateId: string
transitions: DecisionNodeTransition[]
createdAt: Date
updatedAt: Date
}
const llmPlaceholderSchema = new mongoose.Schema<DecisionNodeLLMPlaceholder>(
{
key: { type: String, required: true },
label: { type: String, required: true },
description: { type: String },
required: { type: Boolean, default: false },
example: { type: String },
defaultValue: { type: String },
type: { type: String, default: 'text' },
},
{ _id: false }
)
const llmTemplateSchema = new mongoose.Schema<DecisionNodeLLMTemplate>(
{
summary: { type: String },
prompt: { type: String },
responseSchema: { type: String },
autoProceed: { type: Boolean, default: false },
temperature: { type: Number },
topP: { type: Number },
maxOutputTokens: { type: Number },
placeholders: { type: [llmPlaceholderSchema], default: () => [] },
guardrails: { type: [String], default: () => [] },
notes: { type: String },
},
{ _id: false }
)
const metadataSchema = new mongoose.Schema<DecisionNodeMetadata>(
{
tags: { type: [String], default: () => [] },
notes: { type: String },
pinned: { type: Boolean, default: false },
complexity: { type: String, enum: ['low', 'medium', 'high'], default: 'medium' },
},
{ _id: false }
)
const layoutSchema = new mongoose.Schema<DecisionNodeLayout>(
{
x: { type: Number, required: true },
y: { type: Number, required: true },
width: { type: Number },
height: { type: Number },
color: { type: String },
icon: { type: String },
locked: { type: Boolean, default: false },
},
{ _id: false }
)
const autoTriggerSchema = new mongoose.Schema<DecisionNodeAutoTrigger>(
{
id: { type: String, required: true },
type: { type: String, enum: ['telemetry', 'variable', 'expression'], required: true },
parameter: { type: String },
variable: { type: String },
operator: { type: String },
value: { type: mongoose.Schema.Types.Mixed },
unit: { type: String },
expression: { type: String },
description: { type: String },
once: { type: Boolean, default: true },
delayMs: { type: Number },
},
{ _id: false }
)
const transitionSchema = new mongoose.Schema<DecisionNodeTransition>(
{
key: { type: String, required: true },
type: {
type: String,
enum: ['next', 'ok', 'bad', 'timer', 'auto', 'interrupt', 'return'],
default: 'next',
},
target: { type: String, required: true },
label: { type: String },
description: { type: String },
condition: { type: String },
guard: { type: String },
order: { type: Number, default: 0 },
timer: {
type: new mongoose.Schema(
{
afterSeconds: { type: Number, required: true },
allowManualProceed: { type: Boolean, default: true },
},
{ _id: false }
),
default: undefined,
},
autoTrigger: { type: autoTriggerSchema, default: undefined },
metadata: {
type: new mongoose.Schema(
{
color: { type: String },
icon: { type: String },
notes: { type: String },
previewTemplate: { type: String },
},
{ _id: false }
),
default: undefined,
},
},
{ _id: false }
)
const decisionNodeSchema = new mongoose.Schema<DecisionNodeDocument>(
{
flow: { type: mongoose.Schema.Types.ObjectId, ref: 'DecisionFlow', required: true, index: true },
stateId: { type: String, required: true },
title: { type: String },
summary: { type: String },
role: { type: String, enum: ['pilot', 'atc', 'system'], required: true },
phase: { type: String, required: true },
sayTemplate: { type: String },
utteranceTemplate: { type: String },
elseSayTemplate: { type: String },
readbackRequired: { type: [String], default: () => [] },
autoBehavior: { type: String },
actions: { type: [mongoose.Schema.Types.Mixed], default: () => [] },
handoff: {
type: new mongoose.Schema(
{
to: { type: String, required: true },
freq: { type: String },
note: { type: String },
},
{ _id: false }
),
default: undefined,
},
guard: { type: String },
trigger: { type: String },
frequency: { type: String },
frequencyName: { type: String },
transitions: { type: [transitionSchema], default: () => [] },
layout: { type: layoutSchema, default: undefined },
metadata: { type: metadataSchema, default: undefined },
llmTemplate: { type: llmTemplateSchema, default: undefined },
},
{ timestamps: true }
)
decisionNodeSchema.index({ flow: 1, stateId: 1 }, { unique: true })
decisionNodeSchema.set('toJSON', {
virtuals: true,
getters: true,
})
export const DecisionNode =
(mongoose.models.DecisionNode as mongoose.Model<DecisionNodeDocument>) ||
mongoose.model<DecisionNodeDocument>('DecisionNode', decisionNodeSchema)

View File

@@ -0,0 +1,203 @@
import { createError } from 'h3'
import { DecisionFlow, type DecisionFlowDocument } from '../models/DecisionFlow'
import { DecisionNode, type DecisionNodeDocument } from '../models/DecisionNode'
import type {
DecisionFlowModel,
DecisionFlowSummary,
DecisionNodeModel,
DecisionNodeTransition,
RuntimeDecisionAutoTransition,
RuntimeDecisionState,
RuntimeDecisionTree,
} from '~~/shared/types/decision'
export function serializeFlowDocument(doc: DecisionFlowDocument, nodeCount = 0): DecisionFlowModel {
return {
id: String(doc._id),
slug: doc.slug,
name: doc.name,
description: doc.description || undefined,
schemaVersion: doc.schemaVersion || undefined,
startState: doc.startState,
endStates: Array.isArray(doc.endStates) ? doc.endStates : [],
variables: doc.variables || {},
flags: doc.flags || {},
policies: doc.policies || {},
hooks: doc.hooks || {},
roles: Array.isArray(doc.roles) ? doc.roles : [],
phases: Array.isArray(doc.phases) ? doc.phases : [],
layout: doc.layout || undefined,
metadata: doc.metadata || undefined,
createdAt: doc.createdAt?.toISOString?.() || new Date().toISOString(),
updatedAt: doc.updatedAt?.toISOString?.() || new Date().toISOString(),
nodeCount,
}
}
export function serializeNodeDocument(doc: DecisionNodeDocument): DecisionNodeModel {
const obj = doc.toObject<DecisionNodeDocument>({ virtuals: false })
return {
stateId: obj.stateId,
title: obj.title || undefined,
summary: obj.summary || undefined,
role: obj.role as any,
phase: obj.phase,
sayTemplate: obj.sayTemplate || undefined,
utteranceTemplate: obj.utteranceTemplate || undefined,
elseSayTemplate: obj.elseSayTemplate || undefined,
readbackRequired: Array.isArray(obj.readbackRequired) ? obj.readbackRequired : [],
autoBehavior: obj.autoBehavior || undefined,
actions: Array.isArray(obj.actions) ? obj.actions : [],
handoff: obj.handoff || undefined,
guard: obj.guard || undefined,
trigger: obj.trigger || undefined,
frequency: obj.frequency || undefined,
frequencyName: obj.frequencyName || undefined,
transitions: Array.isArray(obj.transitions) ? obj.transitions : [],
layout: obj.layout || undefined,
metadata: obj.metadata || undefined,
llmTemplate: obj.llmTemplate || undefined,
createdAt: obj.createdAt?.toISOString?.(),
updatedAt: obj.updatedAt?.toISOString?.(),
}
}
export async function listDecisionFlows(): Promise<DecisionFlowSummary[]> {
const flows = await DecisionFlow.find().sort({ updatedAt: -1 }).lean()
if (!flows.length) {
return []
}
const ids = flows.map((flow) => flow._id)
const counts = await DecisionNode.aggregate([
{ $match: { flow: { $in: ids } } },
{ $group: { _id: '$flow', count: { $sum: 1 } } },
])
const countMap = counts.reduce<Record<string, number>>((acc, entry) => {
acc[String(entry._id)] = entry.count
return acc
}, {})
return flows.map((flow) => ({
id: String(flow._id),
slug: flow.slug,
name: flow.name,
description: flow.description || undefined,
startState: flow.startState,
nodeCount: countMap[String(flow._id)] || 0,
updatedAt: flow.updatedAt?.toISOString?.() || new Date().toISOString(),
createdAt: flow.createdAt?.toISOString?.() || new Date().toISOString(),
}))
}
export async function getFlowWithNodes(slug: string): Promise<{ flow: DecisionFlowModel; nodes: DecisionNodeModel[] }> {
const flowDoc = await DecisionFlow.findOne({ slug })
if (!flowDoc) {
throw createError({ statusCode: 404, statusMessage: 'Decision flow not found' })
}
const nodes = await DecisionNode.find({ flow: flowDoc._id }).sort({ stateId: 1 })
const flow = serializeFlowDocument(flowDoc, nodes.length)
const serializedNodes = nodes.map((node) => serializeNodeDocument(node))
return { flow, nodes: serializedNodes }
}
function toRuntimeTransitions(
transitions: DecisionNodeTransition[],
types: Array<DecisionNodeTransition['type']>,
includeAuto = false
) {
return transitions
.filter((transition) => types.includes(transition.type) || (includeAuto && transition.type === 'auto'))
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((transition) => ({
to: transition.target,
label: transition.label || undefined,
when: transition.condition || undefined,
guard: transition.guard || undefined,
}))
}
function toRuntimeTimers(transitions: DecisionNodeTransition[]): RuntimeDecisionState['timer_next'] {
return transitions
.filter((transition) => transition.type === 'timer' && transition.timer)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((transition) => ({
to: transition.target,
after_s: transition.timer?.afterSeconds ?? 0,
label: transition.label || undefined,
}))
}
function toRuntimeAutoTransitions(transitions: DecisionNodeTransition[]): RuntimeDecisionAutoTransition[] {
return transitions
.filter((transition) => transition.autoTrigger)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((transition) => ({
id: transition.key,
to: transition.target,
label: transition.label || undefined,
description: transition.description || undefined,
condition: transition.condition || undefined,
guard: transition.guard || undefined,
trigger: transition.autoTrigger || null,
metadata: transition.metadata || undefined,
}))
}
function serializeRuntimeState(node: DecisionNodeDocument): RuntimeDecisionState {
const obj = node.toObject<DecisionNodeDocument>({ virtuals: false })
const transitions = Array.isArray(obj.transitions) ? obj.transitions : []
return {
role: obj.role as any,
phase: obj.phase,
say_tpl: obj.sayTemplate || undefined,
utterance_tpl: obj.utteranceTemplate || undefined,
else_say_tpl: obj.elseSayTemplate || undefined,
next: toRuntimeTransitions(transitions, ['next'], true),
ok_next: toRuntimeTransitions(transitions, ['ok']),
bad_next: toRuntimeTransitions(transitions, ['bad']),
timer_next: toRuntimeTimers(transitions),
auto: obj.autoBehavior || null,
readback_required: Array.isArray(obj.readbackRequired) ? obj.readbackRequired : undefined,
actions: Array.isArray(obj.actions) ? obj.actions : undefined,
handoff: obj.handoff || undefined,
guard: obj.guard || undefined,
trigger: obj.trigger || undefined,
frequency: obj.frequency || undefined,
frequencyName: obj.frequencyName || undefined,
auto_transitions: toRuntimeAutoTransitions(transitions),
metadata: obj.metadata || undefined,
}
}
export async function buildRuntimeDecisionTree(slug: string): Promise<RuntimeDecisionTree> {
const flowDoc = await DecisionFlow.findOne({ slug })
if (!flowDoc) {
throw createError({ statusCode: 404, statusMessage: 'Decision flow not found' })
}
const nodes = await DecisionNode.find({ flow: flowDoc._id })
const states = nodes.reduce<Record<string, RuntimeDecisionState>>((acc, node) => {
acc[node.stateId] = serializeRuntimeState(node)
return acc
}, {})
return {
schema_version: flowDoc.schemaVersion || '1.0',
name: flowDoc.slug,
description: flowDoc.description || undefined,
start_state: flowDoc.startState,
end_states: Array.isArray(flowDoc.endStates) ? flowDoc.endStates : [],
variables: flowDoc.variables || {},
flags: flowDoc.flags || {},
policies: flowDoc.policies || {},
hooks: flowDoc.hooks || {},
roles: Array.isArray(flowDoc.roles) ? flowDoc.roles : [],
phases: Array.isArray(flowDoc.phases) ? flowDoc.phases : [],
states,
}
}

View File

@@ -0,0 +1,197 @@
import { randomUUID } from 'node:crypto'
import atcDecisionTree from '~~/shared/data/atcDecisionTree'
import { DecisionFlow } from '../models/DecisionFlow'
import { DecisionNode } from '../models/DecisionNode'
import type { DecisionNodeTransition } from '~~/shared/types/decision'
import { getFlowWithNodes } from './decisionFlowService'
interface LegacyTransition {
to?: string
when?: string
label?: string
condition?: string
guard?: string
after_s?: number
allowManualProceed?: boolean
description?: string
}
export interface ImportDecisionTreeOptions {
slug?: string
name?: string
description?: string
}
const ROLE_COLORS: Record<string, string> = {
pilot: '#0ea5e9',
atc: '#22d3ee',
system: '#f59e0b',
}
function createTransition(
type: DecisionNodeTransition['type'],
data: LegacyTransition,
order: number
): DecisionNodeTransition | null {
if (!data || typeof data !== 'object') return null
const target = typeof data.to === 'string' ? data.to.trim() : ''
if (!target) return null
const transition: DecisionNodeTransition = {
key: `${type}_${randomUUID().slice(0, 8)}`,
type,
target,
order,
}
const label = typeof data.label === 'string' ? data.label.trim() : undefined
if (label) transition.label = label
const condition =
typeof data.when === 'string'
? data.when.trim()
: typeof data.condition === 'string'
? data.condition.trim()
: undefined
if (condition) transition.condition = condition
const guard = typeof data.guard === 'string' ? data.guard.trim() : undefined
if (guard) transition.guard = guard
const description = typeof data.description === 'string' ? data.description.trim() : undefined
if (description) transition.description = description
if (type === 'timer') {
const after = typeof data.after_s === 'number' ? data.after_s : Number(data.after_s)
if (Number.isFinite(after)) {
transition.timer = {
afterSeconds: Number(after),
allowManualProceed: data.allowManualProceed !== false,
}
}
}
return transition
}
export async function importATCDecisionTree(options: ImportDecisionTreeOptions = {}) {
const slug = typeof options.slug === 'string' && options.slug.trim().length
? options.slug.trim()
: atcDecisionTree.name || 'icao_atc_decision_tree'
const name = typeof options.name === 'string' && options.name.trim().length
? options.name.trim()
: 'ATC Decision Tree'
const existingFlow = await DecisionFlow.findOne({ slug })
const flow = existingFlow || new DecisionFlow({ slug, name })
flow.name = name
flow.description = options.description ?? atcDecisionTree.description ?? flow.description
flow.schemaVersion = atcDecisionTree.schema_version || '1.0'
flow.startState = atcDecisionTree.start_state
flow.endStates = Array.isArray(atcDecisionTree.end_states) ? atcDecisionTree.end_states : []
flow.variables = atcDecisionTree.variables || {}
flow.flags = atcDecisionTree.flags || {}
flow.policies = atcDecisionTree.policies || {}
flow.hooks = atcDecisionTree.hooks || {}
flow.roles = Array.isArray(atcDecisionTree.roles) ? atcDecisionTree.roles : flow.roles
flow.phases = Array.isArray(atcDecisionTree.phases) ? atcDecisionTree.phases : flow.phases
flow.layout = flow.layout || { zoom: 0.9, pan: { x: 0, y: 0 }, groups: [] }
await flow.save()
await DecisionNode.deleteMany({ flow: flow._id })
const phases = Array.isArray(flow.phases) && flow.phases.length ? flow.phases : ['General']
const phaseColumns = new Map<string, number>()
phases.forEach((phase, index) => phaseColumns.set(phase, index))
const phaseRowCounters = new Map<string, number>()
const stateEntries = Object.entries(atcDecisionTree.states || {})
const nodesToInsert = stateEntries.map(([stateId, state]) => {
const role = typeof state.role === 'string' ? state.role : 'system'
const phase = typeof state.phase === 'string' ? state.phase : 'General'
const columnIndex = phaseColumns.has(phase) ? phaseColumns.get(phase)! : phaseColumns.size
if (!phaseColumns.has(phase)) {
phaseColumns.set(phase, columnIndex)
}
const rowIndex = phaseRowCounters.get(phase) || 0
phaseRowCounters.set(phase, rowIndex + 1)
const transitions: DecisionNodeTransition[] = []
let order = 0
if (Array.isArray(state.next)) {
for (const entry of state.next as LegacyTransition[]) {
const transition = createTransition('next', entry, order++)
if (transition) transitions.push(transition)
}
}
if (Array.isArray(state.ok_next)) {
for (const entry of state.ok_next as LegacyTransition[]) {
const transition = createTransition('ok', entry, order++)
if (transition) transitions.push(transition)
}
}
if (Array.isArray(state.bad_next)) {
for (const entry of state.bad_next as LegacyTransition[]) {
const transition = createTransition('bad', entry, order++)
if (transition) transitions.push(transition)
}
}
if (Array.isArray(state.timer_next)) {
for (const entry of state.timer_next as LegacyTransition[]) {
const transition = createTransition('timer', entry, order++)
if (transition) transitions.push(transition)
}
}
const readbackRequired = Array.isArray(state.readback_required)
? state.readback_required.filter((item: any) => typeof item === 'string' && item.trim().length)
: []
const layout = {
x: columnIndex * 340,
y: rowIndex * 220,
color: ROLE_COLORS[role] || '#38bdf8',
}
return {
flow: flow._id,
stateId,
title: typeof state.title === 'string' ? state.title.trim() || undefined : undefined,
summary: typeof state.summary === 'string' ? state.summary.trim() || undefined : undefined,
role,
phase,
sayTemplate: typeof state.say_tpl === 'string' ? state.say_tpl : undefined,
utteranceTemplate: typeof state.utterance_tpl === 'string' ? state.utterance_tpl : undefined,
elseSayTemplate: typeof state.else_say_tpl === 'string' ? state.else_say_tpl : undefined,
readbackRequired,
autoBehavior: typeof state.auto === 'string' ? state.auto : undefined,
actions: Array.isArray(state.actions) ? state.actions : [],
handoff: state.handoff && typeof state.handoff === 'object' ? state.handoff : undefined,
guard: typeof state.guard === 'string' ? state.guard : undefined,
trigger: typeof state.trigger === 'string' ? state.trigger : undefined,
frequency: typeof state.frequency === 'string' ? state.frequency : undefined,
frequencyName: typeof state.frequencyName === 'string' ? state.frequencyName : undefined,
transitions,
layout,
}
})
await DecisionNode.insertMany(nodesToInsert)
const { flow: serializedFlow, nodes } = await getFlowWithNodes(slug)
return {
flow: serializedFlow,
nodes,
importedStates: nodes.length,
}
}

View File

@@ -0,0 +1,258 @@
import { randomUUID } from 'node:crypto'
import type {
DecisionNodeAutoTrigger,
DecisionNodeLayout,
DecisionNodeLLMPlaceholder,
DecisionNodeLLMTemplate,
DecisionNodeMetadata,
DecisionNodeTransition,
} from '~~/shared/types/decision'
const TRANSITION_TYPES = new Set(['next', 'ok', 'bad', 'timer', 'auto', 'interrupt', 'return'])
const AUTO_TRIGGER_TYPES = new Set(['telemetry', 'variable', 'expression'])
const COMPARISON_OPERATORS = new Set(['>', '>=', '<', '<=', '==', '!='])
function asTrimmedString(input: any): string | undefined {
if (typeof input === 'string') {
const trimmed = input.trim()
return trimmed.length ? trimmed : undefined
}
return undefined
}
function asNumber(input: any): number | undefined {
if (typeof input === 'number' && Number.isFinite(input)) {
return input
}
if (typeof input === 'string' && input.trim()) {
const parsed = Number(input)
if (Number.isFinite(parsed)) {
return parsed
}
}
return undefined
}
function asBoolean(input: any, fallback: boolean): boolean {
if (typeof input === 'boolean') return input
if (typeof input === 'string') {
const normalized = input.trim().toLowerCase()
if (normalized === 'true') return true
if (normalized === 'false') return false
}
return fallback
}
export function sanitizeLayout(raw: any): DecisionNodeLayout | undefined {
if (!raw || typeof raw !== 'object') return undefined
const x = asNumber(raw.x) ?? 0
const y = asNumber(raw.y) ?? 0
const layout: DecisionNodeLayout = { x, y }
const width = asNumber(raw.width)
const height = asNumber(raw.height)
if (typeof width === 'number') layout.width = width
if (typeof height === 'number') layout.height = height
const color = asTrimmedString(raw.color)
if (color) layout.color = color
const icon = asTrimmedString(raw.icon)
if (icon) layout.icon = icon
if (typeof raw.locked === 'boolean') {
layout.locked = raw.locked
}
return layout
}
export function sanitizeMetadata(raw: any): DecisionNodeMetadata | undefined {
if (!raw || typeof raw !== 'object') return undefined
const metadata: DecisionNodeMetadata = {}
if (Array.isArray(raw.tags)) {
metadata.tags = raw.tags
.map((tag: any) => asTrimmedString(tag))
.filter((tag): tag is string => Boolean(tag))
}
const notes = asTrimmedString(raw.notes)
if (notes) metadata.notes = notes
if (typeof raw.pinned === 'boolean') metadata.pinned = raw.pinned
const complexity = asTrimmedString(raw.complexity)
if (complexity && ['low', 'medium', 'high'].includes(complexity)) {
metadata.complexity = complexity as DecisionNodeMetadata['complexity']
}
return Object.keys(metadata).length ? metadata : undefined
}
function sanitizeLLMPlaceholder(raw: any): DecisionNodeLLMPlaceholder | null {
if (!raw || typeof raw !== 'object') return null
const key = asTrimmedString(raw.key)
const label = asTrimmedString(raw.label)
if (!key || !label) return null
const placeholder: DecisionNodeLLMPlaceholder = { key, label }
const description = asTrimmedString(raw.description)
if (description) placeholder.description = description
if (typeof raw.required === 'boolean') placeholder.required = raw.required
const example = asTrimmedString(raw.example)
if (example) placeholder.example = example
const defaultValue = asTrimmedString(raw.defaultValue)
if (defaultValue !== undefined) placeholder.defaultValue = defaultValue
const type = asTrimmedString(raw.type)
if (type && ['text', 'number', 'choice'].includes(type)) {
placeholder.type = type as DecisionNodeLLMPlaceholder['type']
}
return placeholder
}
export function sanitizeLLMTemplate(raw: any): DecisionNodeLLMTemplate | undefined {
if (!raw || typeof raw !== 'object') return undefined
const template: DecisionNodeLLMTemplate = {}
const summary = asTrimmedString(raw.summary)
if (summary) template.summary = summary
const prompt = asTrimmedString(raw.prompt)
if (prompt) template.prompt = prompt
const schema = asTrimmedString(raw.responseSchema)
if (schema) template.responseSchema = schema
if (raw.autoProceed !== undefined) template.autoProceed = asBoolean(raw.autoProceed, false)
const temperature = asNumber(raw.temperature)
if (typeof temperature === 'number') template.temperature = temperature
const topP = asNumber(raw.topP)
if (typeof topP === 'number') template.topP = topP
const maxOutputTokens = asNumber(raw.maxOutputTokens)
if (typeof maxOutputTokens === 'number') template.maxOutputTokens = maxOutputTokens
if (Array.isArray(raw.guardrails)) {
template.guardrails = raw.guardrails
.map((item: any) => asTrimmedString(item))
.filter((item): item is string => Boolean(item))
}
const notes = asTrimmedString(raw.notes)
if (notes) template.notes = notes
if (Array.isArray(raw.placeholders)) {
const placeholders = raw.placeholders
.map((item: any) => sanitizeLLMPlaceholder(item))
.filter((item): item is DecisionNodeLLMPlaceholder => Boolean(item))
template.placeholders = placeholders
}
return Object.keys(template).length ? template : undefined
}
export function sanitizeAutoTrigger(raw: any): DecisionNodeAutoTrigger | undefined {
if (!raw || typeof raw !== 'object') return undefined
const type = asTrimmedString(raw.type)
if (!type || !AUTO_TRIGGER_TYPES.has(type)) {
throw new Error('Invalid auto trigger type')
}
const trigger: DecisionNodeAutoTrigger = {
id: asTrimmedString(raw.id) || `auto_${randomUUID()}`,
type: type as DecisionNodeAutoTrigger['type'],
}
if (type === 'expression') {
const expression = asTrimmedString(raw.expression)
if (!expression) {
throw new Error('Expression trigger requires an expression')
}
trigger.expression = expression
} else if (type === 'telemetry') {
const parameter = asTrimmedString(raw.parameter)
if (!parameter) {
throw new Error('Telemetry trigger requires a parameter')
}
trigger.parameter = parameter as DecisionNodeAutoTrigger['parameter']
const operator = asTrimmedString(raw.operator)
if (!operator || !COMPARISON_OPERATORS.has(operator)) {
throw new Error('Telemetry trigger requires a valid operator')
}
trigger.operator = operator as DecisionNodeAutoTrigger['operator']
const value = raw.value !== undefined ? raw.value : undefined
if (value === undefined) {
throw new Error('Telemetry trigger requires a value')
}
const numericValue = asNumber(value)
trigger.value = numericValue !== undefined ? numericValue : value
const unit = asTrimmedString(raw.unit)
if (unit) trigger.unit = unit
} else if (type === 'variable') {
const variable = asTrimmedString(raw.variable)
if (!variable) {
throw new Error('Variable trigger requires a variable path')
}
trigger.variable = variable
const operator = asTrimmedString(raw.operator)
if (!operator || !COMPARISON_OPERATORS.has(operator)) {
throw new Error('Variable trigger requires a valid operator')
}
trigger.operator = operator as DecisionNodeAutoTrigger['operator']
const value = raw.value !== undefined ? raw.value : undefined
if (value === undefined) {
throw new Error('Variable trigger requires a value')
}
const numericValue = asNumber(value)
trigger.value = numericValue !== undefined ? numericValue : value
}
if (raw.once !== undefined) {
trigger.once = asBoolean(raw.once, true)
}
const delayMs = asNumber(raw.delayMs)
if (typeof delayMs === 'number') trigger.delayMs = delayMs
const description = asTrimmedString(raw.description)
if (description) trigger.description = description
return trigger
}
export function sanitizeTransition(raw: any, index = 0): DecisionNodeTransition {
if (!raw || typeof raw !== 'object') {
throw new Error('Invalid transition payload')
}
const key = asTrimmedString(raw.key) || `tr_${randomUUID()}`
const type = asTrimmedString(raw.type)
const normalizedType = type && TRANSITION_TYPES.has(type) ? type : 'next'
const target = asTrimmedString(raw.target)
if (!target) {
throw new Error('Transition target is required')
}
const transition: DecisionNodeTransition = {
key,
type: normalizedType as DecisionNodeTransition['type'],
target,
order: typeof raw.order === 'number' ? raw.order : index,
}
const label = asTrimmedString(raw.label)
if (label) transition.label = label
const description = asTrimmedString(raw.description)
if (description) transition.description = description
const condition = asTrimmedString(raw.condition)
if (condition) transition.condition = condition
const guard = asTrimmedString(raw.guard)
if (guard) transition.guard = guard
if (raw.timer && typeof raw.timer === 'object') {
const timerValue = asNumber(raw.timer.afterSeconds)
if (typeof timerValue === 'number') {
transition.timer = {
afterSeconds: timerValue,
allowManualProceed: asBoolean(raw.timer.allowManualProceed, true),
}
}
}
if (raw.autoTrigger) {
transition.autoTrigger = sanitizeAutoTrigger(raw.autoTrigger)
}
if (raw.metadata && typeof raw.metadata === 'object') {
const color = asTrimmedString(raw.metadata.color)
const icon = asTrimmedString(raw.metadata.icon)
const notes = asTrimmedString(raw.metadata.notes)
const previewTemplate = asTrimmedString(raw.metadata.previewTemplate)
const metadata: DecisionNodeTransition['metadata'] = {}
if (color) metadata.color = color
if (icon) metadata.icon = icon
if (notes) metadata.notes = notes
if (previewTemplate) metadata.previewTemplate = previewTemplate
if (Object.keys(metadata).length) {
transition.metadata = metadata
}
}
return transition
}