diff --git a/packages/spec/api/flow.tsp b/packages/spec/api/flow.tsp index 02181ff0c..507a9f53a 100644 --- a/packages/spec/api/flow.tsp +++ b/packages/spec/api/flow.tsp @@ -2,6 +2,7 @@ using DevTools; namespace Api.Flow; +@AITools.explorationTool(#{ name: "ValidateWorkflow", title: "Validate Workflow", description: "Validate a workflow by checking its structure, connections, and node configurations for errors." }) @TanStackDB.collection model Flow { @primaryKey flowId: Id; @@ -17,14 +18,18 @@ model FlowDuplicateRequest { op FlowDuplicate(...FlowDuplicateRequest): {}; +@AITools.aiTool(#{ category: AITools.ToolCategory.Execution, title: "Run Flow" }) +@doc("Execute a workflow from the start node.") model FlowRunRequest { - flowId: Id; + @doc("The ULID of the workflow to run") flowId: Id; } op FlowRun(...FlowRunRequest): {}; +@AITools.aiTool(#{ category: AITools.ToolCategory.Execution, title: "Stop Flow" }) +@doc("Stop a running workflow execution.") model FlowStopRequest { - flowId: Id; + @doc("The ULID of the workflow to stop") flowId: Id; } op FlowStop(...FlowStopRequest): {}; @@ -35,6 +40,11 @@ model FlowVersion { @foreignKey flowId: Id; } +@AITools.explorationTool(#{ name: "GetFlowVariable", title: "Get Flow Variable", description: "Get a flow variable by its primary key." }) +@AITools.mutationTool( + #{ operation: AITools.CrudOperation.Insert, title: "Create Variable", name: "CreateVariable", description: "Create a new workflow variable that can be referenced in node expressions." }, + #{ operation: AITools.CrudOperation.Update, title: "Update Variable", name: "UpdateVariable", description: "Update an existing workflow variable." } +) @TanStackDB.collection model FlowVariable { @primaryKey flowVariableId: Id; @@ -47,6 +57,12 @@ enum HandleKind { Loop, } +@AITools.explorationTool(#{ name: "GetEdge", title: "Get Edge", description: "Get a edge by its primary key." }) +@AITools.mutationTool( + #{ operation: AITools.CrudOperation.Insert, title: "Connect Sequential Nodes", name: "ConnectSequentialNodes", exclude: #["sourceHandle"], description: "Connect two nodes in a sequential flow. Use this for ManualStart, JavaScript, and HTTP nodes which have a single output." }, + #{ operation: AITools.CrudOperation.Insert, title: "Connect Branching Nodes", name: "ConnectBranchingNodes", description: "Connect a branching node (Condition, For, ForEach) to another node. Requires sourceHandle: 'then' or 'else' for Condition nodes, 'then' or 'loop' for For/ForEach nodes." }, + #{ operation: AITools.CrudOperation.Delete, title: "Disconnect Nodes", name: "DisconnectNodes", description: "Remove an edge connection between nodes." } +) @TanStackDB.collection model Edge { @primaryKey edgeId: Id; @@ -78,6 +94,11 @@ model Position { y: float32; } +@AITools.explorationTool(#{ name: "GetNode", title: "Get Node", description: "Get a node by its primary key." }) +@AITools.mutationTool( + #{ operation: AITools.CrudOperation.Update, title: "Update Node Config", name: "UpdateNodeConfig", exclude: #["kind"], description: "Update general node properties like name or position." }, + #{ operation: AITools.CrudOperation.Delete, title: "Delete Node", name: "DeleteNode", description: "Delete a node from the workflow. Also removes all connected edges." } +) @TanStackDB.collection model Node { @primaryKey nodeId: Id; @@ -89,10 +110,14 @@ model Node { @visibility(Lifecycle.Read) info?: string; } +@AITools.explorationTool(#{ name: "GetNodeHttp", title: "Get Node Http", description: "Get a node http by its primary key." }) +@AITools.mutationTool( + #{ operation: AITools.CrudOperation.Insert, title: "Create HTTP Node", name: "CreateHttpNode", parent: "Node", exclude: #["kind"], description: "Create a new HTTP request node that makes an API call." } +) @TanStackDB.collection model NodeHttp { @primaryKey nodeId: Id; - @foreignKey httpId: Id; + @doc("The ULID of the HTTP request definition to use") @foreignKey httpId: Id; @foreignKey deltaHttpId?: Id; } @@ -101,28 +126,45 @@ enum ErrorHandling { Break, } +@AITools.explorationTool(#{ name: "GetNodeFor", title: "Get Node For", description: "Get a node for by its primary key." }) +@AITools.mutationTool( + #{ operation: AITools.CrudOperation.Insert, title: "Create For Loop Node", name: "CreateForNode", parent: "Node", exclude: #["kind"], description: "Create a for-loop node that iterates a fixed number of times." } +) @TanStackDB.collection model NodeFor { @primaryKey nodeId: Id; - iterations: int32; + @doc("Number of iterations to perform") iterations: int32; condition: string; errorHandling: ErrorHandling; } +@AITools.explorationTool(#{ name: "GetNodeForEach", title: "Get Node For Each", description: "Get a node for each by its primary key." }) +@AITools.mutationTool( + #{ operation: AITools.CrudOperation.Insert, title: "Create ForEach Loop Node", name: "CreateForEachNode", parent: "Node", exclude: #["kind"], description: "Create a forEach node that iterates over an array or object." } +) @TanStackDB.collection model NodeForEach { @primaryKey nodeId: Id; - path: string; + @doc("Path to the array/object to iterate (e.g., \"input.items\")") path: string; condition: string; errorHandling: ErrorHandling; } +@AITools.explorationTool(#{ name: "GetNodeCondition", title: "Get Node Condition", description: "Get a node condition by its primary key." }) +@AITools.mutationTool( + #{ operation: AITools.CrudOperation.Insert, title: "Create Condition Node", name: "CreateConditionNode", parent: "Node", exclude: #["kind"], description: "Create a condition node that routes flow based on a boolean expression. Has THEN and ELSE output handles." } +) @TanStackDB.collection model NodeCondition { @primaryKey nodeId: Id; condition: string; } +@AITools.explorationTool(#{ name: "GetNodeJs", title: "Get Node Js", description: "Get a node js by its primary key." }) +@AITools.mutationTool( + #{ operation: AITools.CrudOperation.Insert, title: "Create JavaScript Node", name: "CreateJsNode", parent: "Node", exclude: #["kind"], description: "Create a new JavaScript node in the workflow. JS nodes can transform data, make calculations, or perform custom logic." }, + #{ operation: AITools.CrudOperation.Update, title: "Update Node Code", name: "UpdateNodeCode", description: "Update the JavaScript code of a JS node." } +) @TanStackDB.collection model NodeJs { @primaryKey nodeId: Id; diff --git a/packages/spec/api/main.tsp b/packages/spec/api/main.tsp index 6062bcea7..abe3d50da 100644 --- a/packages/spec/api/main.tsp +++ b/packages/spec/api/main.tsp @@ -1,6 +1,7 @@ import "@the-dev-tools/spec-lib/core"; import "@the-dev-tools/spec-lib/protobuf"; import "@the-dev-tools/spec-lib/tanstack-db"; +import "@the-dev-tools/spec-lib/ai-tools"; import "./environment.tsp"; import "./export.tsp"; @@ -20,11 +21,11 @@ alias Id = bytes; model CommonTableFields { ...Keys; - key: string; - enabled: boolean; - value: string; - description: string; - order: float32; + @doc("Variable name (used to reference it in expressions)") key: string; + @doc("Whether the variable is active") enabled: boolean; + @doc("Variable value") value: string; + @doc("Description of what the variable is for") description: string; + @doc("Display order") order: float32; } @DevTools.project diff --git a/packages/spec/package.json b/packages/spec/package.json index c5edb8bd0..c35d863bc 100755 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -4,12 +4,25 @@ "type": "module", "files": [ "dist", + "src", "go.mod", "go.sum" ], "exports": { "./buf/*": "./dist/buf/typescript/*.ts", - "./tanstack-db/*": "./dist/tanstack-db/typescript/*.ts" + "./tanstack-db/*": "./dist/tanstack-db/typescript/*.ts", + "./tools": "./src/tools/index.ts", + "./tools/schemas": "./dist/tools/schemas.ts", + "./tools/common": "./src/tools/common.ts", + "./tools/execution": "./dist/ai-tools/v1/execution.ts", + "./tools/exploration": "./dist/ai-tools/v1/exploration.ts", + "./tools/mutation": "./dist/ai-tools/v1/mutation.ts" + }, + "scripts": { + "generate:schemas": "tsx src/tools/generate.ts" + }, + "dependencies": { + "effect": "catalog:" }, "devDependencies": { "@bufbuild/buf": "catalog:", @@ -18,8 +31,8 @@ "@the-dev-tools/eslint-config": "workspace:^", "@the-dev-tools/spec-lib": "workspace:^", "@types/node": "catalog:", - "effect": "catalog:", "prettier": "catalog:", + "tsx": "^4.19.0", "typescript": "catalog:" } } diff --git a/packages/spec/src/tools/common.ts b/packages/spec/src/tools/common.ts new file mode 100644 index 000000000..66b9da040 --- /dev/null +++ b/packages/spec/src/tools/common.ts @@ -0,0 +1,252 @@ +/** + * Common schemas and utilities for tool definitions + * These are shared building blocks used across multiple tool schemas + * + * IMPORTANT: Enums and types are DERIVED from the generated Protobuf types + * in @the-dev-tools/spec/buf/api/flow/v1/flow_pb to ensure consistency + * with the TypeSpec definitions. + */ + +import { Schema } from 'effect'; + +// ============================================================================= +// Import enums from generated Protobuf (derived from TypeSpec) +// ============================================================================= +import { + ErrorHandling as PbErrorHandling, + HandleKind as PbHandleKind, +} from '../../dist/buf/typescript/api/flow/v1/flow_pb.ts'; + +// ============================================================================= +// Common Field Schemas +// ============================================================================= + +/** + * ULID identifier schema - used for all entity IDs + * Matches the `Id` type in TypeSpec (main.tsp) + */ +export const UlidId = Schema.String.pipe( + Schema.pattern(/^[0-9A-HJKMNP-TV-Z]{26}$/), + Schema.annotations({ + title: 'ULID', + description: 'A ULID (Universally Unique Lexicographically Sortable Identifier)', + examples: ['01ARZ3NDEKTSV4RRFFQ69G5FAV'], + }), +); + +/** + * Flow ID - references a workflow + * Corresponds to Flow.flowId in flow.tsp + */ +export const FlowId = UlidId.pipe( + Schema.annotations({ + identifier: 'flowId', + description: 'The ULID of the workflow', + }), +); + +/** + * Node ID - references a node within a workflow + * Corresponds to Node.nodeId in flow.tsp + */ +export const NodeId = UlidId.pipe( + Schema.annotations({ + identifier: 'nodeId', + description: 'The ULID of the node', + }), +); + +/** + * Edge ID - references an edge connection + * Corresponds to Edge.edgeId in flow.tsp + */ +export const EdgeId = UlidId.pipe( + Schema.annotations({ + identifier: 'edgeId', + description: 'The ULID of the edge', + }), +); + +// ============================================================================= +// Position Schema (matches Position model in flow.tsp) +// ============================================================================= + +/** + * Canvas position for nodes + * Derived from: model Position { x: float32; y: float32; } in flow.tsp + */ +export const Position = Schema.Struct({ + x: Schema.Number.pipe( + Schema.annotations({ + description: 'X coordinate on the canvas', + }), + ), + y: Schema.Number.pipe( + Schema.annotations({ + description: 'Y coordinate on the canvas', + }), + ), +}).pipe( + Schema.annotations({ + identifier: 'Position', + description: 'Position on the canvas', + }), +); + +export const OptionalPosition = Schema.optional( + Position.pipe( + Schema.annotations({ + description: 'Position on the canvas (optional)', + }), + ), +); + +// ============================================================================= +// Enums DERIVED from TypeSpec/Protobuf definitions +// ============================================================================= + +/** + * SAFETY NET: These types and Record<> patterns ensure TypeScript will ERROR + * if the backend adds new enum values to TypeSpec that we haven't handled. + * + * How it works: + * 1. We exclude UNSPECIFIED (protobuf default) from each enum type + * 2. We use Record which REQUIRES all enum values as keys + * 3. If backend adds e.g. PARALLEL to HandleKind, TypeScript errors: + * "Property 'PARALLEL' is missing in type..." + * + * This turns silent drift into compile-time errors! + */ + +// Types that exclude the UNSPECIFIED protobuf default value +type ValidHandleKind = Exclude; +type ValidErrorHandling = Exclude; + +/** + * Helper: Creates a Schema.Literal from all values in an enum mapping. + * This ensures the schema automatically includes all mapped values. + */ +function literalFromValues>(mapping: T) { + const values = Object.values(mapping) as [string, ...string[]]; + return Schema.Literal(...values); +} + +/** + * Error handling strategies for loop nodes + * Derived from: enum ErrorHandling { Ignore, Break } in flow.tsp + * + * EXHAUSTIVE: Record forces all enum values to be present. + * If backend adds a new value, TypeScript will error until it's added here. + */ +const errorHandlingValues: Record = { + [PbErrorHandling.IGNORE]: 'ignore', + [PbErrorHandling.BREAK]: 'break', +}; + +export const ErrorHandling = literalFromValues(errorHandlingValues).pipe( + Schema.annotations({ + identifier: 'ErrorHandling', + description: 'How to handle errors: "ignore" continues, "break" stops the loop', + }), +); + +/** + * Source handle types for connecting nodes + * Derived from: enum HandleKind { Then, Else, Loop } in flow.tsp + * + * EXHAUSTIVE: Record forces all enum values to be present. + * If backend adds a new value (e.g., PARALLEL), TypeScript will error until it's added here. + */ +const handleKindValues: Record = { + [PbHandleKind.THEN]: 'then', + [PbHandleKind.ELSE]: 'else', + [PbHandleKind.LOOP]: 'loop', +}; + +export const SourceHandle = literalFromValues(handleKindValues).pipe( + Schema.annotations({ + identifier: 'SourceHandle', + description: + 'Output handle for branching nodes. Use "then"/"else" for Condition nodes, "loop"/"then" for For/ForEach nodes.', + }), +); + +/** + * API documentation categories + */ +export const ApiCategory = Schema.Literal( + 'messaging', + 'payments', + 'project-management', + 'storage', + 'database', + 'email', + 'calendar', + 'crm', + 'social', + 'analytics', + 'developer', +).pipe( + Schema.annotations({ + identifier: 'ApiCategory', + description: 'Category of the API', + }), +); + +// ============================================================================= +// Display Name Schema +// ============================================================================= + +/** + * Display name for nodes + */ +export const NodeName = Schema.String.pipe( + Schema.minLength(1), + Schema.maxLength(100), + Schema.annotations({ + description: 'Display name for the node', + examples: ['Transform Data', 'Fetch User', 'Check Status'], + }), +); + +// ============================================================================= +// Code Schema (for JS nodes) +// ============================================================================= + +/** + * JavaScript code for JS nodes + */ +export const JsCode = Schema.String.pipe( + Schema.annotations({ + description: + 'The function body only. Write code directly - do NOT define inner functions. Use ctx for input. MUST have a return statement. The tool auto-wraps with "export default function(ctx) { ... }". Example: "const result = ctx.value * 2; return { result };"', + examples: [ + 'const result = ctx.value * 2; return { result };', + 'const items = ctx.data.filter(x => x.active); return { items, count: items.length };', + ], + }), +); + +// ============================================================================= +// Condition Expression Schema +// ============================================================================= + +/** + * Boolean condition expression using expr-lang syntax + */ +export const ConditionExpression = Schema.String.pipe( + Schema.annotations({ + description: + 'Boolean expression using expr-lang syntax. Use == for equality (NOT ===). Use Input to reference previous node output (e.g., "Input.status == 200", "Input.success == true")', + examples: ['Input.status == 200', 'Input.success == true', 'Input.count > 0'], + }), +); + +// ============================================================================= +// Type Exports +// ============================================================================= + +export type Position = typeof Position.Type; +export type ErrorHandling = typeof ErrorHandling.Type; +export type SourceHandle = typeof SourceHandle.Type; +export type ApiCategory = typeof ApiCategory.Type; diff --git a/packages/spec/src/tools/generate.ts b/packages/spec/src/tools/generate.ts new file mode 100644 index 000000000..9b865b595 --- /dev/null +++ b/packages/spec/src/tools/generate.ts @@ -0,0 +1,89 @@ +#!/usr/bin/env tsx +/** + * JSON Schema Generator Script + * + * This script generates JSON Schema files from Effect Schema definitions. + * Run with: pnpm tsx src/tools/generate.ts + * + * Output: + * - dist/tools/schemas.json - All tool schemas as JSON + * - dist/tools/schemas.ts - TypeScript file with const exports + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { allToolSchemas, executionSchemas, explorationSchemas, mutationSchemas } from './index.ts'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Output directory +const outDir = path.resolve(__dirname, '../../dist/tools'); + +// Ensure output directory exists +fs.mkdirSync(outDir, { recursive: true }); + +// ============================================================================= +// Generate JSON file +// ============================================================================= + +const jsonOutput = { + $schema: 'http://json-schema.org/draft-07/schema#', + generatedAt: new Date().toISOString(), + tools: { + all: allToolSchemas, + execution: executionSchemas, + exploration: explorationSchemas, + mutation: mutationSchemas, + }, +}; + +fs.writeFileSync( + path.join(outDir, 'schemas.json'), + JSON.stringify(jsonOutput, null, 2), +); + +console.log('✓ Generated dist/tools/schemas.json'); + +// ============================================================================= +// Generate TypeScript file (for direct imports without runtime generation) +// ============================================================================= + +const tsOutput = `/** + * AUTO-GENERATED FILE - DO NOT EDIT + * Generated from Effect Schema definitions + * Run 'pnpm generate:schemas' to regenerate + */ + +export const allToolSchemas = ${JSON.stringify(allToolSchemas, null, 2)} as const; + +export const executionSchemas = ${JSON.stringify(executionSchemas, null, 2)} as const; + +export const explorationSchemas = ${JSON.stringify(explorationSchemas, null, 2)} as const; + +export const mutationSchemas = ${JSON.stringify(mutationSchemas, null, 2)} as const; + +// Individual schema exports +${allToolSchemas + .map( + (schema) => + `export const ${schema.name}Schema = ${JSON.stringify(schema, null, 2)} as const;`, + ) + .join('\n\n')} +`; + +fs.writeFileSync(path.join(outDir, 'schemas.ts'), tsOutput); + +console.log('✓ Generated dist/tools/schemas.ts'); + +// ============================================================================= +// Summary +// ============================================================================= + +console.log('\nGenerated schemas summary:'); +console.log(` Execution tools: ${executionSchemas.length}`); +console.log(` Exploration tools: ${explorationSchemas.length}`); +console.log(` Mutation tools: ${mutationSchemas.length}`); +console.log(` Total: ${allToolSchemas.length}`); diff --git a/packages/spec/src/tools/index.ts b/packages/spec/src/tools/index.ts new file mode 100644 index 000000000..6a563c707 --- /dev/null +++ b/packages/spec/src/tools/index.ts @@ -0,0 +1,172 @@ +/** + * Tool Schema Definitions using Effect Schema + * + * Single source of truth for AI tool definitions. + * Mutation tools are auto-generated from @mutationTool decorator on collection models. + */ + +import { JSONSchema, Schema } from 'effect'; + +// Re-export common schemas and generated tool schemas +export * from './common.ts'; +export * from '../../dist/ai-tools/v1/execution.ts'; +export * from '../../dist/ai-tools/v1/exploration.ts'; +export * from '../../dist/ai-tools/v1/mutation.ts'; + +// Import schema groups from generated files +import { ExecutionSchemas } from '../../dist/ai-tools/v1/execution.ts'; +import { ExplorationSchemas } from '../../dist/ai-tools/v1/exploration.ts'; +import { MutationSchemas } from '../../dist/ai-tools/v1/mutation.ts'; + +// ============================================================================= +// Tool Definition Type +// ============================================================================= + +export interface ToolDefinition { + name: string; + description: string; + parameters: object; +} + +// ============================================================================= +// JSON Schema Generation +// ============================================================================= + +/** Recursively resolve $ref references in a JSON Schema */ +function resolveRefs(obj: unknown, defs: Record): unknown { + if (obj === null || typeof obj !== 'object') return obj; + if (Array.isArray(obj)) return obj.map((item) => resolveRefs(item, defs)); + + const record = obj as Record; + + // Handle $ref + if ('$ref' in record && typeof record['$ref'] === 'string') { + const defName = record['$ref'].replace('#/$defs/', ''); + const resolved = defs[defName]; + if (resolved) { + const { $ref: _, ...rest } = record; + return { ...(resolveRefs(resolved, defs) as Record), ...rest }; + } + } + + // Handle allOf with single $ref (common Effect pattern) + if ('allOf' in record && Array.isArray(record['allOf']) && record['allOf'].length === 1) { + const first = record['allOf'][0] as Record; + if ('$ref' in first) { + const { allOf: _, ...rest } = record; + return { ...(resolveRefs(first, defs) as Record), ...rest }; + } + } + + // Recursively process all properties + const result: Record = {}; + for (const [key, value] of Object.entries(record)) { + if (key === '$defs' || key === '$schema') continue; + result[key] = resolveRefs(value, defs); + } + return result; +} + +/** Convert an Effect Schema to a tool definition with JSON Schema parameters */ +function schemaToToolDefinition(schema: Schema.Schema): ToolDefinition { + const jsonSchema = JSONSchema.make(schema) as { + $schema: string; + $defs: Record; + $ref: string; + }; + + const defs = jsonSchema.$defs ?? {}; + const defName = (jsonSchema.$ref ?? '').replace('#/$defs/', ''); + const def = defs[defName] as { + description?: string; + type: string; + properties: Record; + required?: string[]; + } | undefined; + + return { + name: defName || 'unknown', + description: def?.description ?? '', + parameters: def + ? { + type: def.type, + properties: resolveRefs(def.properties, defs), + required: def.required, + additionalProperties: false, + } + : jsonSchema, + }; +} + +// ============================================================================= +// Auto-generated Tool Definitions (no manual listing needed) +// ============================================================================= + +export const executionSchemas = Object.values(ExecutionSchemas).map((s) => + schemaToToolDefinition(s as Schema.Schema), +); + +export const explorationSchemas = Object.values(ExplorationSchemas).map((s) => + schemaToToolDefinition(s as Schema.Schema), +); + +export const mutationSchemas = Object.values(MutationSchemas).map((s) => + schemaToToolDefinition(s as Schema.Schema), +); + +/** All tool schemas combined - ready for AI tool calling */ +export const allToolSchemas = [...executionSchemas, ...explorationSchemas, ...mutationSchemas]; + +// ============================================================================= +// Effect Schemas (for runtime validation) +// ============================================================================= + +export const EffectSchemas = { + Execution: ExecutionSchemas, + Exploration: ExplorationSchemas, + Mutation: MutationSchemas, +} as const; + +// ============================================================================= +// Validation Helper +// ============================================================================= + +// Build schema map dynamically - no manual maintenance needed +const schemaMap: Record> = Object.fromEntries( + Object.entries(EffectSchemas).flatMap(([, group]) => + Object.entries(group).map(([name, schema]) => [ + name.charAt(0).toLowerCase() + name.slice(1), + schema as Schema.Schema, + ]), + ), +); + +/** + * Validate tool input against the Effect Schema + * + * @example + * const result = validateToolInput('createJsNode', { + * flowId: '01ARZ3NDEKTSV4RRFFQ69G5FAV', + * name: 'Transform Data', + * code: 'return { result: ctx.value * 2 };' + * }); + */ +export function validateToolInput( + toolName: string, + input: unknown, +): { success: true; data: unknown } | { success: false; errors: string[] } { + const schema = schemaMap[toolName]; + if (!schema) { + return { success: false, errors: [`Unknown tool: ${toolName}`] }; + } + + try { + const decoded = Schema.decodeUnknownSync(schema)(input); + return { success: true, data: decoded }; + } catch (error) { + if (error instanceof Error) { + return { success: false, errors: [error.message] }; + } + return { success: false, errors: ['Unknown validation error'] }; + } +} diff --git a/packages/spec/tsconfig.lib.json b/packages/spec/tsconfig.lib.json index c8a7c6987..0258c20c9 100644 --- a/packages/spec/tsconfig.lib.json +++ b/packages/spec/tsconfig.lib.json @@ -1,6 +1,6 @@ { "extends": "../../tsconfig.json", - "include": ["dist", "lib"], + "include": ["dist", "lib", "src/tools"], "exclude": ["node_modules", "*.ts"], "references": [ { diff --git a/packages/spec/tspconfig.yaml b/packages/spec/tspconfig.yaml index 8c30b4272..d35646eca 100644 --- a/packages/spec/tspconfig.yaml +++ b/packages/spec/tspconfig.yaml @@ -3,6 +3,7 @@ output-dir: '{project-root}/dist' emit: - '@the-dev-tools/spec-lib/protobuf' - '@the-dev-tools/spec-lib/tanstack-db' + - '@the-dev-tools/spec-lib/ai-tools' options: '@the-dev-tools/spec-lib': diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index adac19ad8..97ce59158 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -813,6 +813,10 @@ importers: version: link:../spec packages/spec: + dependencies: + effect: + specifier: 'catalog:' + version: 3.19.14 devDependencies: '@bufbuild/buf': specifier: 'catalog:' @@ -832,12 +836,12 @@ importers: '@types/node': specifier: 'catalog:' version: 25.0.3 - effect: - specifier: 'catalog:' - version: 3.19.14 prettier: specifier: 'catalog:' version: 3.7.4 + tsx: + specifier: ^4.19.0 + version: 4.21.0 typescript: specifier: 'catalog:' version: 5.9.3 diff --git a/tools/spec-lib/package.json b/tools/spec-lib/package.json index c81a0de37..e2bfe17e7 100755 --- a/tools/spec-lib/package.json +++ b/tools/spec-lib/package.json @@ -17,6 +17,11 @@ "types": "./dist/src/tanstack-db/index.d.ts", "default": "./dist/src/tanstack-db/index.js", "typespec": "./src/tanstack-db/main.tsp" + }, + "./ai-tools": { + "types": "./dist/src/ai-tools/index.d.ts", + "default": "./dist/src/ai-tools/index.js", + "typespec": "./src/ai-tools/main.tsp" } }, "devDependencies": { diff --git a/tools/spec-lib/src/ai-tools/emitter.tsx b/tools/spec-lib/src/ai-tools/emitter.tsx new file mode 100644 index 000000000..7808c9c95 --- /dev/null +++ b/tools/spec-lib/src/ai-tools/emitter.tsx @@ -0,0 +1,349 @@ +import { For, Indent, refkey, Show, SourceDirectory } from '@alloy-js/core'; +import { SourceFile, VarDeclaration } from '@alloy-js/typescript'; +import { EmitContext, getDoc, Model, ModelProperty, Program } from '@typespec/compiler'; +import { Output, useTsp, writeOutput } from '@typespec/emitter-framework'; +import { Array, String } from 'effect'; +import { join } from 'node:path/posix'; +import { primaryKeys } from '../core/index.jsx'; +import { formatStringLiteral, getFieldSchema } from './field-schema.js'; +import { aiTools, explorationTools, MutationToolOptions, mutationTools, ToolCategory } from './lib.js'; + +export const $onEmit = async (context: EmitContext) => { + const { emitterOutputDir, program } = context; + + if (program.compilerOptions.noEmit) return; + + const tools = aiTools(program); + const mutations = mutationTools(program); + const explorations = explorationTools(program); + if (tools.size === 0 && mutations.size === 0 && explorations.size === 0) { + return; + } + + await writeOutput( + program, + + + + + , + join(emitterOutputDir, 'ai-tools'), + ); +}; + +interface ResolvedProperty { + optional: boolean; + property: ModelProperty; +} + +interface ResolvedTool { + description?: string | undefined; + name: string; + properties: ResolvedProperty[]; + title: string; +} + +function isVisibleFor(property: ModelProperty, phase: 'Create' | 'Update'): boolean { + const visibilityDec = property.decorators.find( + (d) => d.decorator.name === '$visibility', + ); + if (!visibilityDec) return true; + + return visibilityDec.args.some((arg) => { + const val = arg.value as { value?: { name?: string } } | undefined; + return val?.value?.name === phase; + }); +} + +function resolveToolProperties(program: Program, collectionModel: Model, toolDef: MutationToolOptions): ResolvedProperty[] { + const { exclude = [], operation, parent: parentName } = toolDef; + const parent = parentName ? collectionModel.namespace?.models.get(parentName) : undefined; + + switch (operation) { + case 'Insert': { + const props: ResolvedProperty[] = []; + if (parent) { + for (const prop of parent.properties.values()) { + if (primaryKeys(program).has(prop)) continue; + if (!isVisibleFor(prop, 'Create')) continue; + if (exclude.includes(prop.name)) continue; + props.push({ optional: prop.optional, property: prop }); + } + } + for (const prop of collectionModel.properties.values()) { + if (primaryKeys(program).has(prop)) continue; + if (!isVisibleFor(prop, 'Create')) continue; + if (exclude.includes(prop.name)) continue; + props.push({ optional: prop.optional, property: prop }); + } + return props; + } + case 'Update': { + const props: ResolvedProperty[] = []; + for (const prop of collectionModel.properties.values()) { + if (!isVisibleFor(prop, 'Update')) continue; + if (primaryKeys(program).has(prop)) { + props.push({ optional: false, property: prop }); + } else { + if (exclude.includes(prop.name)) continue; + props.push({ optional: true, property: prop }); + } + } + return props; + } + case 'Delete': { + const props: ResolvedProperty[] = []; + for (const prop of collectionModel.properties.values()) { + if (primaryKeys(program).has(prop)) { + props.push({ optional: false, property: prop }); + } + } + return props; + } + } +} + +function resolveExplorationTools(program: Program): ResolvedTool[] { + const tools: ResolvedTool[] = []; + + for (const [model, toolDefs] of explorationTools(program).entries()) { + for (const toolDef of toolDefs) { + const properties: ResolvedProperty[] = []; + for (const prop of model.properties.values()) { + if (primaryKeys(program).has(prop)) { + properties.push({ optional: false, property: prop }); + } + } + if (properties.length === 0) continue; + + tools.push({ + description: toolDef.description, + name: toolDef.name, + properties, + title: toolDef.title, + }); + } + } + + return tools; +} + +function resolveMutationTools(program: Program): ResolvedTool[] { + const tools: ResolvedTool[] = []; + + for (const [model, toolDefs] of mutationTools(program).entries()) { + for (const toolDef of toolDefs) { + const name = toolDef.name ?? `${toolDef.operation}${model.name}`; + const properties = resolveToolProperties(program, model, toolDef); + tools.push({ + description: toolDef.description, + name, + properties, + title: toolDef.title, + }); + } + } + + return tools; +} + +function resolveAiTools(program: Program): Partial> { + const result: Partial> = {}; + + for (const [model, options] of aiTools(program).entries()) { + const properties: ResolvedProperty[] = []; + for (const prop of model.properties.values()) { + properties.push({ optional: prop.optional, property: prop }); + } + const category = options.category; + if (!result[category]) result[category] = []; + result[category]!.push({ + description: getDoc(program, model), + name: model.name, + properties, + title: options.title ?? model.name, + }); + } + + return result; +} + +const CategoryFiles = () => { + const { program } = useTsp(); + + const resolvedMutationTools = resolveMutationTools(program); + const resolvedExplorationTools = resolveExplorationTools(program); + const aiToolsByCategory = resolveAiTools(program); + + const categories: { category: ToolCategory; tools: ResolvedTool[] }[] = []; + + if (resolvedMutationTools.length > 0) { + categories.push({ category: 'Mutation', tools: resolvedMutationTools }); + } + + const allExploration = [...resolvedExplorationTools, ...(aiToolsByCategory['Exploration'] ?? [])]; + if (allExploration.length > 0) { + categories.push({ category: 'Exploration', tools: allExploration }); + } + + const executionTools = aiToolsByCategory['Execution'] ?? []; + if (executionTools.length > 0) { + categories.push({ category: 'Execution', tools: executionTools }); + } + + return ( + + {({ category, tools }) => ( + + + {'\n'} + + {(tool) => } + + + + {'{'} + {'\n'} + + + {(tool) => <>{tool.name}} + + , + + {'\n'} + {'}'} as const + + {'\n\n'} + + {(tool) => ( + <> + export type {tool.name} = typeof {tool.name}.Type;{'\n'} + + )} + + + )} + + ); +}; + +const SchemaImports = ({ tools }: { tools: ResolvedTool[] }) => { + const { program } = useTsp(); + const commonImports = new Set(); + + for (const { properties } of tools) { + for (const { property } of properties) { + const fieldSchema = getFieldSchema(property, program); + if (fieldSchema.importFrom === 'common') { + commonImports.add(fieldSchema.schemaName); + } + } + } + + const commonImportList = Array.sort(Array.fromIterable(commonImports), String.Order); + + return ( + <> + import {'{'} Schema {'}'} from 'effect'; + {'\n\n'} + 0}> + import {'{'} + {'\n'} + + + {(name) => <>{name}} + + + {'\n'} + {'}'} from '../../../src/tools/common.ts'; + {'\n'} + + + ); +}; + +const ToolSchema = ({ tool }: { tool: ResolvedTool }) => { + const identifier = String.uncapitalize(tool.name); + + return ( + + Schema.Struct({'{'} + {'\n'} + + + {({ optional, property }) => } + + + {'\n'} + {'}'}).pipe( + {'\n'} + + Schema.annotations({'{'} + {'\n'} + + identifier: '{identifier}',{'\n'} + title: '{tool.title}',{'\n'} + description: {formatStringLiteral(tool.description ?? '')},{'\n'} + + {'}'}), + + {'\n'}) + + ); +}; + +interface PropertySchemaProps { + isOptional: boolean; + property: ModelProperty; +} + +const PropertySchema = ({ isOptional, property }: PropertySchemaProps) => { + const { program } = useTsp(); + const doc = getDoc(program, property); + const fieldSchema = getFieldSchema(property, program); + + const needsOptionalWrapper = isOptional && !fieldSchema.includesOptional; + + if (doc || fieldSchema.needsDescription) { + const description = doc ?? ''; + // When optional, wrap the annotated inner schema with Schema.optional() + // Schema.optional() returns a PropertySignature that can't be piped + const annotatedInner = ( + <> + {fieldSchema.expression}.pipe( + {'\n'} + + Schema.annotations({'{'} + {'\n'} + description: {formatStringLiteral(description)},{'\n'} + {'}'}), + + {'\n'}) + + ); + + if (needsOptionalWrapper) { + return ( + <> + {property.name}: Schema.optional({annotatedInner}) + + ); + } + + return ( + <> + {property.name}: {annotatedInner} + + ); + } + + const schemaExpr = needsOptionalWrapper + ? `Schema.optional(${fieldSchema.expression})` + : fieldSchema.expression; + + return ( + <> + {property.name}: {schemaExpr} + + ); +}; diff --git a/tools/spec-lib/src/ai-tools/field-schema.ts b/tools/spec-lib/src/ai-tools/field-schema.ts new file mode 100644 index 000000000..1ebb7aa0f --- /dev/null +++ b/tools/spec-lib/src/ai-tools/field-schema.ts @@ -0,0 +1,175 @@ +import { ModelProperty, Program } from '@typespec/compiler'; +import { $ } from '@typespec/compiler/typekit'; + +export interface FieldSchemaResult { + expression: string; + importFrom: 'common' | 'effect' | 'none'; + includesOptional: boolean; + needsDescription: boolean; + schemaName: string; +} + +export function getFieldSchema(property: ModelProperty, program: Program): FieldSchemaResult { + const { name, type } = property; + + // Check for known field names that map to common.ts schemas + const knownFieldSchemas: Record = { + code: 'JsCode', + condition: 'ConditionExpression', + edgeId: 'EdgeId', + errorHandling: 'ErrorHandling', + flowId: 'FlowId', + flowVariableId: 'UlidId', + httpId: 'UlidId', + nodeId: 'NodeId', + position: 'OptionalPosition', + sourceHandle: 'SourceHandle', + sourceId: 'NodeId', + targetId: 'NodeId', + }; + + // Position field is special - it uses OptionalPosition from common when optional + if (name === 'position') { + if (property.optional) { + return { + expression: 'OptionalPosition', + importFrom: 'common', + includesOptional: true, + needsDescription: false, + schemaName: 'OptionalPosition', + }; + } + return { + expression: 'Position', + importFrom: 'common', + includesOptional: false, + needsDescription: false, + schemaName: 'Position', + }; + } + + // Name field uses NodeName + if (name === 'name') { + return { + expression: 'NodeName', + importFrom: 'common', + includesOptional: false, + needsDescription: false, + schemaName: 'NodeName', + }; + } + + // Check if it's a known field + const knownSchema = knownFieldSchemas[name]; + if (knownSchema) { + return { + expression: knownSchema, + importFrom: 'common', + includesOptional: false, + needsDescription: false, + schemaName: knownSchema, + }; + } + + // Check the actual type + if ($(program).scalar.is(type)) { + const scalarName = type.name; + + // bytes type → UlidId + if (scalarName === 'bytes') { + return { + expression: 'UlidId', + importFrom: 'common', + includesOptional: false, + needsDescription: true, + schemaName: 'UlidId', + }; + } + + // string type + if (scalarName === 'string') { + return { + expression: 'Schema.String', + importFrom: 'effect', + includesOptional: false, + needsDescription: true, + schemaName: 'Schema.String', + }; + } + + // int32 type + if (scalarName === 'int32') { + return { + expression: 'Schema.Number.pipe(Schema.int())', + importFrom: 'effect', + includesOptional: false, + needsDescription: true, + schemaName: 'Schema.Number', + }; + } + + // float32 type + if (scalarName === 'float32') { + return { + expression: 'Schema.Number', + importFrom: 'effect', + includesOptional: false, + needsDescription: true, + schemaName: 'Schema.Number', + }; + } + + // boolean type + if (scalarName === 'boolean') { + return { + expression: 'Schema.Boolean', + importFrom: 'effect', + includesOptional: false, + needsDescription: true, + schemaName: 'Schema.Boolean', + }; + } + } + + // Check for enum types + if ($(program).enum.is(type)) { + const enumName = type.name; + // Map known enum names to common.ts schemas + if (enumName === 'ErrorHandling') { + return { + expression: 'ErrorHandling', + importFrom: 'common', + includesOptional: false, + needsDescription: false, + schemaName: 'ErrorHandling', + }; + } + if (enumName === 'HandleKind') { + return { + expression: 'SourceHandle', + importFrom: 'common', + includesOptional: false, + needsDescription: false, + schemaName: 'SourceHandle', + }; + } + } + + // Default to Schema.String for unknown types + return { + expression: 'Schema.String', + importFrom: 'effect', + includesOptional: false, + needsDescription: true, + schemaName: 'Schema.String', + }; +} + +export function formatStringLiteral(str: string): string { + // Check if we need multi-line formatting + if (str.length > 80 || str.includes('\n')) { + return '`' + str.replace(/`/g, '\\`').replace(/\$/g, '\\$') + '`'; + } + // Use single quotes for short strings + return "'" + str.replace(/'/g, "\\'") + "'"; +} diff --git a/tools/spec-lib/src/ai-tools/index.ts b/tools/spec-lib/src/ai-tools/index.ts new file mode 100644 index 000000000..f27acb8e4 --- /dev/null +++ b/tools/spec-lib/src/ai-tools/index.ts @@ -0,0 +1,2 @@ +export { $onEmit } from './emitter.jsx'; +export { $decorators, $lib } from './lib.js'; diff --git a/tools/spec-lib/src/ai-tools/lib.ts b/tools/spec-lib/src/ai-tools/lib.ts new file mode 100644 index 000000000..197de6b13 --- /dev/null +++ b/tools/spec-lib/src/ai-tools/lib.ts @@ -0,0 +1,97 @@ +import { createTypeSpecLibrary, DecoratorContext, EnumValue, Model } from '@typespec/compiler'; +import { makeStateFactory } from '../utils.js'; + +export const $lib = createTypeSpecLibrary({ + diagnostics: {}, + name: '@the-dev-tools/spec-lib/ai-tools', +}); + +export const $decorators = { + 'DevTools.AITools': { + aiTool, + explorationTool, + mutationTool, + }, +}; + +const { makeStateMap } = makeStateFactory((_) => $lib.createStateSymbol(_)); + +export type ToolCategory = 'Execution' | 'Exploration' | 'Mutation'; + +export interface AIToolOptions { + category: ToolCategory; + title?: string | undefined; +} + +export const aiTools = makeStateMap('aiTools'); + +interface RawAIToolOptions { + category: EnumValue; + title?: string; +} + +function aiTool({ program }: DecoratorContext, target: Model, options: RawAIToolOptions) { + // Extract category name from EnumValue + const category = options.category.value.name as ToolCategory; + aiTools(program).set(target, { + category, + title: options.title, + }); +} + +export type CrudOperation = 'Delete' | 'Insert' | 'Update'; + +export interface MutationToolOptions { + description?: string | undefined; + exclude?: string[] | undefined; + name?: string | undefined; + operation: CrudOperation; + parent?: string | undefined; + title: string; +} + +export const mutationTools = makeStateMap('mutationTools'); + +interface RawMutationToolOptions { + description?: string; + exclude?: string[]; + name?: string; + operation: EnumValue; + parent?: string; + title: string; +} + +function mutationTool({ program }: DecoratorContext, target: Model, ...tools: RawMutationToolOptions[]) { + const resolved: MutationToolOptions[] = tools.map((tool) => ({ + description: tool.description, + exclude: tool.exclude, + name: tool.name, + operation: tool.operation.value.name as CrudOperation, + parent: tool.parent, + title: tool.title, + })); + mutationTools(program).set(target, resolved); +} + +export interface ExplorationToolOptions { + description?: string | undefined; + name: string; + title: string; +} + +export const explorationTools = makeStateMap('explorationTools'); + +interface RawExplorationToolOptions { + description?: string; + name: string; + title: string; +} + +function explorationTool({ program }: DecoratorContext, target: Model, ...tools: RawExplorationToolOptions[]) { + const resolved: ExplorationToolOptions[] = tools.map((tool) => ({ + description: tool.description, + name: tool.name, + title: tool.title, + })); + explorationTools(program).set(target, resolved); +} diff --git a/tools/spec-lib/src/ai-tools/main.tsp b/tools/spec-lib/src/ai-tools/main.tsp new file mode 100644 index 000000000..ff813727e --- /dev/null +++ b/tools/spec-lib/src/ai-tools/main.tsp @@ -0,0 +1,42 @@ +import "../core"; +import "../../dist/src/ai-tools"; + +namespace DevTools.AITools { + enum ToolCategory { + Mutation, + Exploration, + Execution, + } + + model AIToolOptions { + category: ToolCategory; + title?: string; + } + + extern dec aiTool(target: Reflection.Model, options: valueof AIToolOptions); + + enum CrudOperation { + Insert, + Update, + Delete, + } + + model MutationToolOptions { + operation: CrudOperation; + title: string; + name?: string; + description?: string; + parent?: string; + exclude?: string[]; + } + + extern dec mutationTool(target: Reflection.Model, ...tools: valueof MutationToolOptions[]); + + model ExplorationToolOptions { + name: string; + title: string; + description?: string; + } + + extern dec explorationTool(target: Reflection.Model, ...tools: valueof ExplorationToolOptions[]); +}