diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml new file mode 100644 index 00000000..a9654160 --- /dev/null +++ b/.github/workflows/integration-test.yml @@ -0,0 +1,236 @@ +name: Swamp Integration Tests + +on: + pull_request: + branches: [main] + types: [opened, synchronize] + +permissions: + contents: read + +jobs: + integration-test: + name: System Test + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Compile swamp binary + run: deno run compile + + - name: Verify swamp binary works + run: | + ./swamp --version + ./swamp --help + + - name: Create test repository structure + run: | + mkdir -p test-repo/{inputs/keeb/shell,inputs/mermaid/workflow-diagram,workflows,resources} + cd test-repo + + # Initialize as swamp repo + ../swamp repo init + + - name: Create test models with shell commands + run: | + cd test-repo + + # Model 1: Download a sample file + ../swamp model create keeb/shell download-model --json > /tmp/download.json + DOWNLOAD_ID=$(cat /tmp/download.json | jq -r '.id') + # Edit the generated file to add our attributes + printf 'id: %s\nname: download-model\nversion: 1\ntags: {}\nattributes:\n run: "curl -s https://httpbin.org/json > sample.json && cat sample.json"\n workingDir: "/tmp"\n' "${DOWNLOAD_ID}" > "inputs/keeb/shell/${DOWNLOAD_ID}.yaml" + + # Model 2: Process the downloaded file + ../swamp model create keeb/shell process-model --json > /tmp/process.json + PROCESS_ID=$(cat /tmp/process.json | jq -r '.id') + printf 'id: %s\nname: process-model\nversion: 1\ntags: {}\nattributes:\n run: "wc -l /tmp/sample.json | cut -d'\''$'\'' -f1"\n workingDir: "/tmp"\n' "${PROCESS_ID}" > "inputs/keeb/shell/${PROCESS_ID}.yaml" + + # Model 3: Create summary using self-reference + ../swamp model create keeb/shell summary-model --json > /tmp/summary.json + SUMMARY_ID=$(cat /tmp/summary.json | jq -r '.id') + SELF_EXPR=$(printf '%s' '$' '{{ self.name }}') + printf 'id: %s\nname: summary-model\nversion: 1\ntags: {}\nattributes:\n run: "echo '\''Summary for %s: Processing completed'\''"\n workingDir: "/tmp"\n' "${SUMMARY_ID}" "${SELF_EXPR}" > "inputs/keeb/shell/${SUMMARY_ID}.yaml" + + # Model 4: Final report referencing other models + ../swamp model create keeb/shell final-report-model --json > /tmp/final.json + FINAL_ID=$(cat /tmp/final.json | jq -r '.id') + MODEL_DOWNLOAD_EXPR=$(printf '%s' '$' '{{ model.download-model.input.attributes.run }}') + MODEL_SUMMARY_EXPR=$(printf '%s' '$' '{{ model.summary-model.input.attributes.run }}') + printf 'id: %s\nname: final-report-model\nversion: 1\ntags: {}\nattributes:\n run: "echo '\''Final Report - Download: %s | Process result available | Summary: %s'\''"\n workingDir: "/tmp"\n' "${FINAL_ID}" "${MODEL_DOWNLOAD_EXPR}" "${MODEL_SUMMARY_EXPR}" > "inputs/keeb/shell/${FINAL_ID}.yaml" + + # Model 5: Mermaid diagram generator (will consume workflow execution) + ../swamp model create mermaid/workflow-diagram diagram-model --json > /tmp/diagram.json + DIAGRAM_ID=$(cat /tmp/diagram.json | jq -r '.id') + # Note: We'll populate this model's attributes after workflow execution + + - name: Create integration test workflow + run: | + cd test-repo + + ../swamp workflow create integration-test --json > /tmp/workflow.json + WORKFLOW_ID=$(cat /tmp/workflow.json | jq -r '.id') + + # Create workflow YAML file using printf + printf 'id: %s\nname: integration-test\ndescription: Integration test workflow with dependencies and cross-model references\njobs:\n - name: download\n description: Download sample data\n steps:\n - name: download-step\n task:\n type: model_method\n modelIdOrName: download-model\n methodName: execute\n - name: process\n description: Process downloaded data\n dependsOn:\n - job: download\n condition:\n type: succeeded\n ref: download\n steps:\n - name: process-step\n task:\n type: model_method\n modelIdOrName: process-model\n methodName: execute\n - name: summarize\n description: Create summary with self-reference\n dependsOn:\n - job: process\n condition:\n type: succeeded\n ref: process\n steps:\n - name: summary-step\n task:\n type: model_method\n modelIdOrName: summary-model\n methodName: execute\n - name: final-report\n description: Create final report with cross-model references\n dependsOn:\n - job: summarize\n condition:\n type: succeeded\n ref: summarize\n steps:\n - name: final-step\n task:\n type: model_method\n modelIdOrName: final-report-model\n methodName: execute\n' "${WORKFLOW_ID}" > "workflows/workflow-${WORKFLOW_ID}.yaml" + + - name: Validate models and workflow + run: | + cd test-repo + + # Validate all models + ../swamp model validate download-model --json + ../swamp model validate process-model --json + ../swamp model validate summary-model --json + ../swamp model validate final-report-model --json + + # Validate workflow + ../swamp workflow validate integration-test --json + + - name: Execute integration workflow + run: | + cd test-repo + + # Run the workflow and capture output + ../swamp workflow run integration-test --json > workflow-output.json + + # Display results + echo "=== Workflow Execution Results ===" + cat workflow-output.json | jq . + + # Verify workflow succeeded + STATUS=$(cat workflow-output.json | jq -r '.status') + if [ "$STATUS" != "succeeded" ]; then + echo "ERROR: Workflow execution failed with status: $STATUS" + exit 1 + fi + + - name: Validate artifacts and dependencies + run: | + cd test-repo + + # Check that all jobs completed successfully + JOB_STATUSES=$(cat workflow-output.json | jq -r '.jobs[] | "\(.name): \(.status)"') + echo "=== Job Statuses ===" + echo "$JOB_STATUSES" + + # Verify no jobs failed + FAILED_JOBS=$(cat workflow-output.json | jq -r '.jobs[] | select(.status == "failed") | .name') + if [ -n "$FAILED_JOBS" ]; then + echo "ERROR: Failed jobs found: $FAILED_JOBS" + exit 1 + fi + + # Check that data artifacts were created + echo "=== Checking Data Artifacts ===" + find data -name "*.yaml" -exec echo "Found data: {}" \; 2>/dev/null || echo "No data directory found (expected for shell models)" + + # Check that log artifacts were created + echo "=== Checking Log Artifacts ===" + find logs -name "*.log" -exec echo "Found log: {}" \; 2>/dev/null || echo "No logs directory found" + + - name: Validate expression evaluation + run: | + cd test-repo + + # Check that expressions were evaluated correctly by examining logs + echo "=== Validating Expression Evaluation ===" + + # Look for evidence that self-reference worked in summary model + if grep -r "Summary for summary-model" logs/ 2>/dev/null; then + echo "✓ Self-reference expression evaluation succeeded" + else + echo "⚠ Could not verify self-reference evaluation (may be in data artifacts)" + fi + + # Look for evidence that cross-model references worked in final report + if grep -r "Final Report - Download:" logs/ 2>/dev/null; then + echo "✓ Cross-model reference expression evaluation succeeded" + else + echo "⚠ Could not verify cross-model reference evaluation (may be in data artifacts)" + fi + + echo "=== Integration Test Completed Successfully ===" + + - name: Generate Mermaid workflow diagram + run: | + cd test-repo + + echo "=== Generating Mermaid Diagram ===" + + # Get the diagram model ID + DIAGRAM_ID=$(cat /tmp/diagram.json | jq -r '.id') + + # Create input for diagram model using printf to avoid YAML parsing issues + printf 'id: DIAGRAM_ID_PLACEHOLDER\nname: diagram-model\nversion: 1\ntags: {}\nattributes:\n workflowExecution:\n workflowName: "integration-test"\n status: "succeeded"\n jobs:\n - name: "download"\n status: "succeeded"\n steps:\n - name: "download-step"\n status: "succeeded"\n task:\n type: "model_method"\n modelIdOrName: "download-model"\n methodName: "execute"\n - name: "process"\n status: "succeeded"\n dependsOn:\n - job: "download"\n condition:\n type: "succeeded"\n jobName: "download"\n steps:\n - name: "process-step"\n status: "succeeded"\n task:\n type: "model_method"\n modelIdOrName: "process-model"\n methodName: "execute"\n - name: "summarize"\n status: "succeeded"\n dependsOn:\n - job: "process"\n condition:\n type: "succeeded"\n jobName: "process"\n steps:\n - name: "summary-step"\n status: "succeeded"\n task:\n type: "model_method"\n modelIdOrName: "summary-model"\n methodName: "execute"\n - name: "final-report"\n status: "succeeded"\n dependsOn:\n - job: "summarize"\n condition:\n type: "succeeded"\n jobName: "summarize"\n steps:\n - name: "final-step"\n status: "succeeded"\n task:\n type: "model_method"\n modelIdOrName: "final-report-model"\n methodName: "execute"\n title: "Integration Test Workflow Execution"\n includeSteps: true\n colorScheme:\n succeeded: "#90EE90"\n failed: "#FFB6C1"\n cancelled: "#D3D3D3"\n skipped: "#FFFFE0"\n' > "/tmp/diagram-template.yaml" + # Replace placeholder and copy to final location + sed "s/DIAGRAM_ID_PLACEHOLDER/${DIAGRAM_ID}/g" "/tmp/diagram-template.yaml" > "inputs/mermaid/workflow-diagram/${DIAGRAM_ID}.yaml" + + # Generate the Mermaid diagram + ../swamp model method run diagram-model generate --json > diagram-output.json + + # Display diagram generation result + echo "=== Diagram Generation Result ===" + cat diagram-output.json | jq . + + # Copy generated diagram to artifacts directory + mkdir -p ../artifacts + if [ -d "files" ]; then + find files -name "*.mmd" -exec cp {} ../artifacts/ \; + echo "✓ Mermaid diagram copied to artifacts" + else + echo "⚠ No diagram files found" + fi + + - name: Test error handling + run: | + cd test-repo + + echo "=== Testing Error Handling ===" + + # Create a workflow that should fail + ../swamp model create keeb/shell failing-model --json > /tmp/failing.json + FAILING_ID=$(cat /tmp/failing.json | jq -r '.id') + printf 'id: %s\nname: failing-model\nversion: 1\ntags: {}\nattributes:\n run: "false"\n' "${FAILING_ID}" > "inputs/keeb/shell/${FAILING_ID}.yaml" + + # Run the failing model and check the exit code in the output + ../swamp model method run failing-model execute --json > failing-output.json + EXIT_CODE=$(cat failing-output.json | jq -r '.data.attributes.exitCode') + if [ "$EXIT_CODE" = "1" ]; then + echo "✓ Error handling works correctly - model command failed with exit code $EXIT_CODE" + else + echo "ERROR: Expected failing model command to have exitCode 1, but got: $EXIT_CODE" + cat failing-output.json | jq . + exit 1 + fi + + - name: Upload workflow artifacts + uses: actions/upload-artifact@v4 + if: always() # Upload even if tests fail + with: + name: swamp-integration-test-artifacts + path: | + artifacts/ + test-repo/workflow-output.json + test-repo/diagram-output.json + test-repo/logs/ + test-repo/data/ + test-repo/files/ + test-repo/resources/ + retention-days: 30 + + - name: Upload Mermaid diagram + uses: actions/upload-artifact@v4 + if: always() + with: + name: workflow-diagram + path: | + artifacts/*.mmd + retention-days: 30 \ No newline at end of file diff --git a/.github/workflows/workflow-validation.yml b/.github/workflows/workflow-validation.yml new file mode 100644 index 00000000..9726efea --- /dev/null +++ b/.github/workflows/workflow-validation.yml @@ -0,0 +1,226 @@ +name: Workflow Validation + +on: + pull_request: + branches: [main] + paths: + - '.github/workflows/**' + push: + branches: [main] + paths: + - '.github/workflows/**' + +permissions: + contents: read + +jobs: + validate-workflows: + name: Validate GitHub Actions Workflows + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install yq for YAML validation + run: | + sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 + sudo chmod +x /usr/local/bin/yq + yq --version + + - name: Validate YAML syntax + run: | + echo "=== Validating YAML Syntax ===" + + YAML_ERROR=0 + for file in .github/workflows/*.yml .github/workflows/*.yaml; do + if [[ -f "$file" ]]; then + echo "Checking YAML syntax: $file" + if yq eval '.' "$file" > /dev/null 2>&1; then + echo "✓ Valid YAML: $file" + else + echo "✗ Invalid YAML: $file" + yq eval '.' "$file" 2>&1 || true + YAML_ERROR=1 + fi + fi + done + + if [ $YAML_ERROR -eq 1 ]; then + echo "❌ YAML syntax validation failed" + exit 1 + else + echo "✅ All workflow files have valid YAML syntax" + fi + + - name: Validate GitHub Actions workflow structure + run: | + echo "=== Validating GitHub Actions Workflow Structure ===" + + WORKFLOW_ERROR=0 + for file in .github/workflows/*.yml .github/workflows/*.yaml; do + if [[ -f "$file" ]]; then + echo "Validating workflow structure: $file" + + # Check required top-level fields + if ! yq eval 'has("name")' "$file" | grep -q true; then + echo "✗ Missing required 'name' field in $file" + WORKFLOW_ERROR=1 + fi + + if ! yq eval 'has("on")' "$file" | grep -q true; then + echo "✗ Missing required 'on' field in $file" + WORKFLOW_ERROR=1 + fi + + if ! yq eval 'has("jobs")' "$file" | grep -q true; then + echo "✗ Missing required 'jobs' field in $file" + WORKFLOW_ERROR=1 + fi + + # Check that jobs have required fields + job_names=$(yq eval '.jobs | keys | .[]' "$file" 2>/dev/null || echo "") + if [ -n "$job_names" ]; then + while IFS= read -r job_name; do + if [ -n "$job_name" ]; then + echo " Checking job: $job_name" + + # Check runs-on field + if ! yq eval ".jobs.$job_name | has(\"runs-on\")" "$file" | grep -q true; then + echo " ✗ Job '$job_name' missing required 'runs-on' field in $file" + WORKFLOW_ERROR=1 + fi + + # Check steps field + if ! yq eval ".jobs.$job_name | has(\"steps\")" "$file" | grep -q true; then + echo " ✗ Job '$job_name' missing required 'steps' field in $file" + WORKFLOW_ERROR=1 + fi + fi + done <<< "$job_names" + fi + + if [ $WORKFLOW_ERROR -eq 0 ]; then + echo "✓ Valid workflow structure: $file" + fi + fi + done + + if [ $WORKFLOW_ERROR -eq 1 ]; then + echo "❌ Workflow structure validation failed" + exit 1 + else + echo "✅ All workflows have valid structure" + fi + + - name: Check for common workflow issues + run: | + echo "=== Checking for Common Workflow Issues ===" + + ISSUES_FOUND=0 + for file in .github/workflows/*.yml .github/workflows/*.yaml; do + if [[ -f "$file" ]]; then + echo "Checking for common issues: $file" + + # Check for deprecated actions + if grep -q "actions/checkout@v[12]" "$file"; then + echo " ⚠️ Warning: Using outdated checkout action in $file" + echo " Consider updating to actions/checkout@v4" + fi + + if grep -q "actions/setup-node@v[12]" "$file"; then + echo " ⚠️ Warning: Using outdated setup-node action in $file" + echo " Consider updating to actions/setup-node@v4" + fi + + # Check for missing permissions + if ! yq eval 'has("permissions")' "$file" | grep -q true; then + echo " ⚠️ Warning: No permissions specified in $file" + echo " Consider adding explicit permissions for security" + fi + + # Check for hardcoded secrets in workflow + GITHUB_EXPR=$(printf '%s' '$' '{{') + if grep -i "password\|secret\|token" "$file" | grep -v "$GITHUB_EXPR" | grep -v "secrets\." | grep -v "GITHUB_TOKEN"; then + echo " ⚠️ Warning: Potential hardcoded secrets found in $file" + ISSUES_FOUND=1 + fi + + # Check for shell injection risks + if grep -E '$\{[^}]*\}' "$file" | grep -v "$GITHUB_EXPR"; then + echo " ⚠️ Warning: Potential shell injection risk with unescaped variables in $file" + fi + + echo "✓ Common issues check completed for: $file" + fi + done + + if [ $ISSUES_FOUND -eq 0 ]; then + echo "✅ No critical issues found in workflow files" + else + echo "❌ Some issues found that should be addressed" + # Don't fail the build for warnings, just report them + fi + + - name: Validate workflow triggers and events + run: | + echo "=== Validating Workflow Triggers ===" + + TRIGGER_ERROR=0 + for file in .github/workflows/*.yml .github/workflows/*.yaml; do + if [[ -f "$file" ]]; then + echo "Checking triggers: $file" + + # Get workflow name + workflow_name=$(yq eval '.name' "$file") + echo " Workflow: $workflow_name" + + # Check trigger configuration + trigger_types=$(yq eval '.on | keys | .[]' "$file" 2>/dev/null || echo "") + if [ -n "$trigger_types" ]; then + echo " Triggers found: $(echo "$trigger_types" | tr '\n' ' ')" + + # Warn about workflow_dispatch for better debugging + if ! echo "$trigger_types" | grep -q "workflow_dispatch"; then + echo " ℹ️ Info: Consider adding workflow_dispatch for manual testing" + fi + + # Check for conflicting triggers + if echo "$trigger_types" | grep -q "push" && echo "$trigger_types" | grep -q "pull_request"; then + echo " ⚠️ Warning: Both push and pull_request triggers found" + echo " This may cause workflows to run twice on PR updates" + fi + else + echo " ✗ No valid triggers found" + TRIGGER_ERROR=1 + fi + + echo "✓ Trigger validation completed for: $file" + fi + done + + if [ $TRIGGER_ERROR -eq 1 ]; then + echo "❌ Trigger validation failed" + exit 1 + else + echo "✅ All workflows have valid triggers" + fi + + - name: Generate workflow summary + run: | + echo "=== Workflow Validation Summary ===" + + total_workflows=$(find .github/workflows -name "*.yml" -o -name "*.yaml" | wc -l) + echo "Total workflows found: $total_workflows" + + echo "" + echo "Workflow files:" + for file in .github/workflows/*.yml .github/workflows/*.yaml; do + if [[ -f "$file" ]]; then + workflow_name=$(yq eval '.name' "$file" 2>/dev/null || echo "Unknown") + echo " - $(basename "$file"): $workflow_name" + fi + done + + echo "" + echo "✅ All GitHub Actions workflows are valid and properly configured" \ No newline at end of file diff --git a/.gitignore b/.gitignore index b495778b..cf7df5f8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ inputs/ resources/ inputs-evaluated/ workflows-evaluated/ +test-repo/ diff --git a/src/domain/models/mermaid/workflow_diagram/workflow_diagram_model.ts b/src/domain/models/mermaid/workflow_diagram/workflow_diagram_model.ts new file mode 100644 index 00000000..cecf953d --- /dev/null +++ b/src/domain/models/mermaid/workflow_diagram/workflow_diagram_model.ts @@ -0,0 +1,335 @@ +import { z } from "zod"; +import { ModelType } from "../../model_type.ts"; +import { ModelResource } from "../../model_resource.ts"; +import { computeChecksum, ModelFile } from "../../model_file.ts"; +import { + defineModel, + type MethodContext, + type MethodResult, + type ModelDefinition, +} from "../../model.ts"; +import type { ModelInput } from "../../model_input.ts"; + +/** + * Schema for workflow execution data that will be converted to Mermaid diagram. + */ +export const WorkflowExecutionSchema = z.object({ + workflowName: z.string(), + status: z.enum(["succeeded", "failed", "cancelled", "skipped"]), + jobs: z.array(z.object({ + name: z.string(), + status: z.enum(["succeeded", "failed", "cancelled", "skipped"]), + dependsOn: z.array(z.object({ + job: z.string(), + condition: z.object({ + type: z.string(), + jobName: z.string(), + }), + })).optional(), + steps: z.array(z.object({ + name: z.string(), + status: z.enum(["succeeded", "failed", "cancelled", "skipped"]), + task: z.object({ + type: z.enum(["model_method", "shell"]), + modelIdOrName: z.string().optional(), + methodName: z.string().optional(), + command: z.string().optional(), + }), + dependsOn: z.array(z.object({ + step: z.string(), + condition: z.object({ + type: z.string(), + stepName: z.string(), + }), + })).optional(), + })), + })), +}); + +/** + * Schema for mermaid diagram model input attributes. + */ +export const MermaidWorkflowInputAttributesSchema = z.object({ + /** The workflow execution data to convert to Mermaid */ + workflowExecution: WorkflowExecutionSchema, + /** Optional title for the diagram */ + title: z.string().optional(), + /** Include step details in the diagram */ + includeSteps: z.boolean().default(false), + /** Color scheme for different statuses */ + colorScheme: z.object({ + succeeded: z.string().default("#90EE90"), + failed: z.string().default("#FFB6C1"), + cancelled: z.string().default("#D3D3D3"), + skipped: z.string().default("#FFFFE0"), + }).default({ + succeeded: "#90EE90", + failed: "#FFB6C1", + cancelled: "#D3D3D3", + skipped: "#FFFFE0", + }), +}); + +/** + * Type for mermaid diagram model input attributes. + */ +export type MermaidWorkflowInputAttributes = z.infer; + +/** + * Schema for mermaid diagram model resource attributes. + */ +export const MermaidWorkflowResourceAttributesSchema = z.object({ + /** The original workflow name */ + workflowName: z.string(), + /** Number of jobs in the workflow */ + jobCount: z.number().int().nonnegative(), + /** Number of steps across all jobs */ + stepCount: z.number().int().nonnegative(), + /** Overall workflow status */ + workflowStatus: z.enum(["succeeded", "failed", "cancelled", "skipped"]), + /** Timestamp when diagram was generated */ + generatedAt: z.string().datetime(), + /** Reference to the Mermaid diagram file */ + diagramFileId: z.string().uuid(), +}); + +/** + * Type for mermaid diagram model resource attributes. + */ +export type MermaidWorkflowResourceAttributes = z.infer< + typeof MermaidWorkflowResourceAttributesSchema +>; + +/** + * The mermaid workflow diagram model type identifier. + */ +export const MERMAID_WORKFLOW_MODEL_TYPE = ModelType.create("mermaid/workflow-diagram"); + +/** + * Generates a node ID safe for Mermaid syntax. + */ +function generateNodeId(name: string): string { + return name.replace(/[^a-zA-Z0-9]/g, "_"); +} + +/** + * Gets the appropriate color for a status. + */ +function getStatusColor(status: string, colorScheme: Record): string { + return colorScheme[status] || "#FFFFFF"; +} + +/** + * Generates Mermaid diagram syntax from workflow execution data. + */ +function generateMermaidDiagram( + execution: z.infer, + options: { + title?: string; + includeSteps: boolean; + colorScheme: Record; + } +): string { + const lines: string[] = []; + + // Add diagram type and direction + lines.push("graph TD"); + + // Add title if provided + if (options.title) { + lines.push(` %% ${options.title}`); + } + + // Add workflow start node + const startId = "START"; + lines.push(` ${startId}[Workflow: ${execution.workflowName}]`); + + // Track nodes and connections + const jobNodes: string[] = []; + const connections: string[] = []; + const styling: string[] = []; + + // Create job nodes + for (const job of execution.jobs) { + const jobId = generateNodeId(`job_${job.name}`); + jobNodes.push(jobId); + + if (options.includeSteps && job.steps.length > 0) { + // Create subgraph for job with steps + lines.push(` subgraph ${jobId}_sg["Job: ${job.name}"]`); + + const stepNodes: string[] = []; + for (const step of job.steps) { + const stepId = generateNodeId(`${job.name}_${step.name}`); + stepNodes.push(stepId); + + let taskInfo = ""; + if (step.task.type === "model_method") { + taskInfo = `${step.task.modelIdOrName}.${step.task.methodName}`; + } else if (step.task.type === "shell") { + taskInfo = `shell: ${step.task.command?.substring(0, 20)}...`; + } + + lines.push(` ${stepId}["${step.name}
${taskInfo}"]`); + styling.push(` classDef ${stepId}_class fill:${getStatusColor(step.status, options.colorScheme)}`); + styling.push(` class ${stepId} ${stepId}_class`); + + // Add step dependencies + if (step.dependsOn) { + for (const dep of step.dependsOn) { + const depStepId = generateNodeId(`${job.name}_${dep.step}`); + connections.push(` ${depStepId} --> ${stepId}`); + } + } + } + + // Connect steps in sequence if no explicit dependencies + for (let i = 0; i < stepNodes.length - 1; i++) { + const hasExplicitDeps = job.steps[i + 1].dependsOn && job.steps[i + 1].dependsOn!.length > 0; + if (!hasExplicitDeps) { + connections.push(` ${stepNodes[i]} --> ${stepNodes[i + 1]}`); + } + } + + lines.push(" end"); + + // Create main job node that represents the subgraph + lines.push(` ${jobId}["Job: ${job.name}
Status: ${job.status}"]`); + } else { + // Simple job node without steps + lines.push(` ${jobId}["Job: ${job.name}
Status: ${job.status}"]`); + } + + // Add job styling + styling.push(` classDef ${jobId}_class fill:${getStatusColor(job.status, options.colorScheme)}`); + styling.push(` class ${jobId} ${jobId}_class`); + + // Connect start to job if it has no dependencies + if (!job.dependsOn || job.dependsOn.length === 0) { + connections.push(` ${startId} --> ${jobId}`); + } + } + + // Add job dependencies + for (const job of execution.jobs) { + if (job.dependsOn) { + const jobId = generateNodeId(`job_${job.name}`); + for (const dep of job.dependsOn) { + const depJobId = generateNodeId(`job_${dep.job}`); + connections.push(` ${depJobId} --> ${jobId}`); + } + } + } + + // Add workflow end node + const endId = "END"; + lines.push(` ${endId}[End: ${execution.status}]`); + styling.push(` classDef ${endId}_class fill:${getStatusColor(execution.status, options.colorScheme)}`); + styling.push(` class ${endId} ${endId}_class`); + + // Connect final jobs to end + const finalJobs = execution.jobs.filter(job => + !execution.jobs.some(otherJob => + otherJob.dependsOn?.some(dep => dep.job === job.name) + ) + ); + + for (const finalJob of finalJobs) { + const finalJobId = generateNodeId(`job_${finalJob.name}`); + connections.push(` ${finalJobId} --> ${endId}`); + } + + // Add all connections + lines.push(" %% Connections"); + lines.push(...connections); + + // Add all styling + lines.push(" %% Styling"); + lines.push(...styling); + + return lines.join("\n"); +} + +/** + * Executes the "generate" method for the mermaid workflow diagram model. + */ +async function executeGenerate( + input: ModelInput, + _context: MethodContext, +): Promise { + const attrs = MermaidWorkflowInputAttributesSchema.parse(input.attributes); + + // Generate the Mermaid diagram + const diagramContent = generateMermaidDiagram(attrs.workflowExecution, { + title: attrs.title, + includeSteps: attrs.includeSteps, + colorScheme: attrs.colorScheme, + }); + + // Convert to bytes + const content = new TextEncoder().encode(diagramContent); + const checksum = await computeChecksum(content); + + // Create file artifact + const fileId = crypto.randomUUID(); + const file = ModelFile.create({ + id: fileId, + filename: `workflow-${attrs.workflowExecution.workflowName}-diagram.mmd`, + contentType: "text/plain", + size: content.length, + checksum, + }); + + // Count jobs and steps + const jobCount = attrs.workflowExecution.jobs.length; + const stepCount = attrs.workflowExecution.jobs.reduce( + (total, job) => total + job.steps.length, + 0 + ); + + // Create the resource + const resource = ModelResource.create({ + id: input.id, + attributes: { + workflowName: attrs.workflowExecution.workflowName, + jobCount, + stepCount, + workflowStatus: attrs.workflowExecution.status, + generatedAt: new Date().toISOString(), + diagramFileId: fileId, + }, + }); + + return { + resource, + file: { + metadata: file, + content, + }, + }; +} + +/** + * The mermaid workflow diagram model definition. + * + * A model that converts workflow execution data into Mermaid diagram format. + * Creates visual representations of workflow structure, dependencies, and execution status. + * + * Self-registers with the global model registry when this module is imported. + */ +export const mermaidWorkflowModel: ModelDefinition< + typeof MermaidWorkflowInputAttributesSchema, + typeof MermaidWorkflowResourceAttributesSchema +> = defineModel({ + type: MERMAID_WORKFLOW_MODEL_TYPE, + version: 1, + inputAttributesSchema: MermaidWorkflowInputAttributesSchema, + resourceAttributesSchema: MermaidWorkflowResourceAttributesSchema, + methods: { + generate: { + description: "Generate a Mermaid diagram from workflow execution data", + inputAttributesSchema: MermaidWorkflowInputAttributesSchema, + execute: executeGenerate, + }, + }, +}); \ No newline at end of file diff --git a/src/domain/models/mermaid/workflow_diagram/workflow_diagram_model_test.ts b/src/domain/models/mermaid/workflow_diagram/workflow_diagram_model_test.ts new file mode 100644 index 00000000..9c645a94 --- /dev/null +++ b/src/domain/models/mermaid/workflow_diagram/workflow_diagram_model_test.ts @@ -0,0 +1,246 @@ +import { assertEquals } from "@std/assert"; +import { ModelInput } from "../../model_input.ts"; +import { + MERMAID_WORKFLOW_MODEL_TYPE, + mermaidWorkflowModel, + type MermaidWorkflowInputAttributes, +} from "./workflow_diagram_model.ts"; + +Deno.test("mermaidWorkflowModel: generate creates Mermaid diagram for simple workflow", async () => { + const workflowExecution = { + workflowName: "test-workflow", + status: "succeeded" as const, + jobs: [ + { + name: "build", + status: "succeeded" as const, + steps: [ + { + name: "compile", + status: "succeeded" as const, + task: { + type: "shell" as const, + command: "make build", + }, + }, + ], + }, + { + name: "test", + status: "succeeded" as const, + dependsOn: [ + { + job: "build", + condition: { + type: "succeeded", + jobName: "build", + }, + }, + ], + steps: [ + { + name: "unit-tests", + status: "succeeded" as const, + task: { + type: "model_method" as const, + modelIdOrName: "test-runner", + methodName: "run", + }, + }, + ], + }, + ], + }; + + const inputAttributes: MermaidWorkflowInputAttributes = { + workflowExecution, + title: "Test Workflow Diagram", + includeSteps: true, + }; + + const input = ModelInput.create({ + name: "test-diagram", + attributes: inputAttributes, + }); + + const result = await mermaidWorkflowModel.methods.generate.execute( + input, + { repoPath: "/tmp" }, + ); + + assertEquals(result.resource !== undefined, true); + assertEquals(result.file !== undefined, true); + + // Verify resource attributes + const resource = result.resource!; + assertEquals(resource.attributes.workflowName, "test-workflow"); + assertEquals(resource.attributes.jobCount, 2); + assertEquals(resource.attributes.stepCount, 2); + assertEquals(resource.attributes.workflowStatus, "succeeded"); + + // Verify file content contains Mermaid syntax + const diagramContent = new TextDecoder().decode(result.file!.content); + assertEquals(diagramContent.includes("graph TD"), true); + assertEquals(diagramContent.includes("Workflow: test-workflow"), true); + assertEquals(diagramContent.includes("Job: build"), true); + assertEquals(diagramContent.includes("Job: test"), true); + assertEquals(diagramContent.includes("compile"), true); + assertEquals(diagramContent.includes("unit-tests"), true); +}); + +Deno.test("mermaidWorkflowModel: generate creates simple diagram without steps", async () => { + const workflowExecution = { + workflowName: "simple-workflow", + status: "failed" as const, + jobs: [ + { + name: "deploy", + status: "failed" as const, + steps: [ + { + name: "deploy-step", + status: "failed" as const, + task: { + type: "shell" as const, + command: "deploy.sh", + }, + }, + ], + }, + ], + }; + + const inputAttributes: MermaidWorkflowInputAttributes = { + workflowExecution, + includeSteps: false, // Don't include step details + }; + + const input = ModelInput.create({ + name: "simple-diagram", + attributes: inputAttributes, + }); + + const result = await mermaidWorkflowModel.methods.generate.execute( + input, + { repoPath: "/tmp" }, + ); + + // Verify file content is simpler without steps + const diagramContent = new TextDecoder().decode(result.file!.content); + assertEquals(diagramContent.includes("graph TD"), true); + assertEquals(diagramContent.includes("Job: deploy"), true); + assertEquals(diagramContent.includes("Status: failed"), true); + // Should not include step details + assertEquals(diagramContent.includes("deploy-step"), false); + assertEquals(diagramContent.includes("subgraph"), false); +}); + +Deno.test("mermaidWorkflowModel: generate handles complex workflow with multiple dependencies", async () => { + const workflowExecution = { + workflowName: "complex-workflow", + status: "succeeded" as const, + jobs: [ + { + name: "setup", + status: "succeeded" as const, + steps: [ + { + name: "init", + status: "succeeded" as const, + task: { type: "shell" as const, command: "setup.sh" }, + }, + ], + }, + { + name: "build-frontend", + status: "succeeded" as const, + dependsOn: [ + { + job: "setup", + condition: { type: "succeeded", jobName: "setup" }, + }, + ], + steps: [ + { + name: "build-ui", + status: "succeeded" as const, + task: { type: "shell" as const, command: "npm run build" }, + }, + ], + }, + { + name: "build-backend", + status: "succeeded" as const, + dependsOn: [ + { + job: "setup", + condition: { type: "succeeded", jobName: "setup" }, + }, + ], + steps: [ + { + name: "build-api", + status: "succeeded" as const, + task: { type: "shell" as const, command: "go build" }, + }, + ], + }, + { + name: "integration-test", + status: "succeeded" as const, + dependsOn: [ + { + job: "build-frontend", + condition: { type: "succeeded", jobName: "build-frontend" }, + }, + { + job: "build-backend", + condition: { type: "succeeded", jobName: "build-backend" }, + }, + ], + steps: [ + { + name: "test-integration", + status: "succeeded" as const, + task: { type: "shell" as const, command: "run-tests.sh" }, + }, + ], + }, + ], + }; + + const inputAttributes: MermaidWorkflowInputAttributes = { + workflowExecution, + includeSteps: false, + }; + + const input = ModelInput.create({ + name: "complex-diagram", + attributes: inputAttributes, + }); + + const result = await mermaidWorkflowModel.methods.generate.execute( + input, + { repoPath: "/tmp" }, + ); + + const diagramContent = new TextDecoder().decode(result.file!.content); + + // Verify all jobs are present + assertEquals(diagramContent.includes("Job: setup"), true); + assertEquals(diagramContent.includes("Job: build-frontend"), true); + assertEquals(diagramContent.includes("Job: build-backend"), true); + assertEquals(diagramContent.includes("Job: integration-test"), true); + + // Verify dependency arrows are present + assertEquals(diagramContent.includes("job_setup --> job_build_frontend"), true); + assertEquals(diagramContent.includes("job_setup --> job_build_backend"), true); + assertEquals(diagramContent.includes("job_build_frontend --> job_integration_test"), true); + assertEquals(diagramContent.includes("job_build_backend --> job_integration_test"), true); +}); + +Deno.test("mermaidWorkflowModel: model type is correctly defined", () => { + assertEquals(MERMAID_WORKFLOW_MODEL_TYPE.value, "mermaid/workflow-diagram"); + assertEquals(mermaidWorkflowModel.type, MERMAID_WORKFLOW_MODEL_TYPE); + assertEquals(mermaidWorkflowModel.version, 1); +}); \ No newline at end of file diff --git a/src/domain/models/models.ts b/src/domain/models/models.ts index d1267717..ab37585e 100644 --- a/src/domain/models/models.ts +++ b/src/domain/models/models.ts @@ -19,6 +19,7 @@ import "./aws/ec2/vpc/ec2_vpc_model.ts"; import "./keeb/shell/shell_model.ts"; import "./systemd/journalctl/journalctl_model.ts"; import "./command/curl/curl_model.ts"; +import "./mermaid/workflow_diagram/workflow_diagram_model.ts"; // Re-export the registry for convenient access export { modelRegistry } from "./model.ts";