chore: auto-commit (286 archivos)
- .claude/agents/fn-orquestador/SKILL.md - .claude/commands/fn_claude.md - .claude/rules/INDEX.md - .claude/rules/cpp_apps.md - .claude/rules/ids_naming.md - CHANGELOG.md - apps/dag_engine/README.md - apps/dag_engine/api.go - apps/dag_engine/dags_migrated/example.yaml - apps/dag_engine/dags_migrated/example_lineage_tracking.yaml - ... Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CronExplain returns a short human-readable description of a cron expression.
|
||||
// Supports 5-field standard cron and @hourly/@daily/@weekly/@monthly shortcuts.
|
||||
// Returns the raw expression if the pattern is not recognized.
|
||||
func CronExplain(expr string) string {
|
||||
expr = strings.TrimSpace(expr)
|
||||
|
||||
// Handle shortcuts
|
||||
switch expr {
|
||||
case "@hourly":
|
||||
return "hourly"
|
||||
case "@daily", "@midnight":
|
||||
return "daily"
|
||||
case "@weekly":
|
||||
return "weekly"
|
||||
case "@monthly":
|
||||
return "monthly"
|
||||
case "@yearly", "@annually":
|
||||
return "yearly"
|
||||
}
|
||||
|
||||
fields := strings.Fields(expr)
|
||||
if len(fields) < 5 {
|
||||
return expr
|
||||
}
|
||||
|
||||
// Use first 5 fields (ignore optional 6th seconds/year field)
|
||||
minute := fields[0]
|
||||
hour := fields[1]
|
||||
dom := fields[2] // day of month
|
||||
month := fields[3]
|
||||
dow := fields[4] // day of week
|
||||
|
||||
// "every N minutes": */N in minute field, rest are *
|
||||
if strings.HasPrefix(minute, "*/") && dom == "*" && month == "*" && dow == "*" {
|
||||
n := minute[2:]
|
||||
if n == "1" {
|
||||
return "every minute"
|
||||
}
|
||||
return fmt.Sprintf("every %s minutes", n)
|
||||
}
|
||||
|
||||
// "every N hours": plain minute "0", */N in hour, rest are *
|
||||
if strings.HasPrefix(hour, "*/") && minute == "0" && dom == "*" && month == "*" && dow == "*" {
|
||||
n := hour[2:]
|
||||
if n == "1" {
|
||||
return "every hour"
|
||||
}
|
||||
return fmt.Sprintf("every %s hours", n)
|
||||
}
|
||||
|
||||
// "daily at HH:MM": plain numbers for minute and hour, rest are *
|
||||
if dom == "*" && month == "*" && dow == "*" {
|
||||
m, errM := strconv.Atoi(minute)
|
||||
h, errH := strconv.Atoi(hour)
|
||||
if errM == nil && errH == nil {
|
||||
return fmt.Sprintf("daily at %02d:%02d", h, m)
|
||||
}
|
||||
}
|
||||
|
||||
// "weekdays at HH:MM": plain minute/hour, dow == "1-5", dom/month are *
|
||||
if dom == "*" && month == "*" && dow == "1-5" {
|
||||
m, errM := strconv.Atoi(minute)
|
||||
h, errH := strconv.Atoi(hour)
|
||||
if errM == nil && errH == nil {
|
||||
return fmt.Sprintf("weekdays at %02d:%02d", h, m)
|
||||
}
|
||||
}
|
||||
|
||||
// Not recognized — return raw expression
|
||||
return expr
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: cron_explain
|
||||
kind: function
|
||||
lang: go
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func CronExplain(expr string) string"
|
||||
description: "Convierte una expresion cron (5 campos o shortcut @daily/@hourly/etc.) en una frase humana corta. Reconoce patrones comunes: every N minutes/hours, daily/weekdays at HH:MM, y shortcuts. Devuelve el expr crudo si no encaja en ningun patron. Sin dependencias externas, solo stdlib."
|
||||
tags: ["cron", "scheduler", "humanize"]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["fmt", "strconv", "strings"]
|
||||
tested: true
|
||||
tests:
|
||||
- "every 15 minutes"
|
||||
- "daily at 00:00"
|
||||
- "weekdays at 09:00"
|
||||
- "every 30 minutes"
|
||||
- "every 2 hours"
|
||||
- "shortcut @hourly"
|
||||
- "shortcut @daily"
|
||||
- "shortcut @weekly"
|
||||
- "shortcut @monthly"
|
||||
- "unknown returns raw"
|
||||
- "invalid too few fields"
|
||||
- "every minute"
|
||||
- "daily at 14:30"
|
||||
- "every hour zero min"
|
||||
- "shortcut @midnight"
|
||||
- "weekdays at 08:00"
|
||||
test_file_path: "functions/core/cron_explain_test.go"
|
||||
file_path: "functions/core/cron_explain.go"
|
||||
params:
|
||||
- name: expr
|
||||
desc: "Expresion cron de 5 campos (min hora dom mes dow) o shortcut (@hourly, @daily, @weekly, @monthly, @yearly). Se ignoran campos extra (6to campo segundos/year)."
|
||||
output: "Frase legible en ingles: 'every N minutes', 'daily at HH:MM', 'weekdays at HH:MM', 'hourly', 'daily', 'weekly', 'monthly'. Devuelve expr sin modificar si el patron no es reconocido."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
fmt.Println(CronExplain("*/15 * * * *")) // every 15 minutes
|
||||
fmt.Println(CronExplain("0 0 * * *")) // daily at 00:00
|
||||
fmt.Println(CronExplain("0 9 * * 1-5")) // weekdays at 09:00
|
||||
fmt.Println(CronExplain("*/30 * * * *")) // every 30 minutes
|
||||
fmt.Println(CronExplain("0 */2 * * *")) // every 2 hours
|
||||
fmt.Println(CronExplain("@hourly")) // hourly
|
||||
fmt.Println(CronExplain("5 4 * * 0")) // 5 4 * * 0 (not recognized, returned raw)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites mostrar al usuario una descripcion legible de un schedule cron en una UI, log o CLI. Antes de renderizar un campo `cron_expr` en un dashboard o TUI.
|
||||
|
||||
## Gotchas
|
||||
|
||||
Funcion pura — nunca falla ni entra en panico. Patrones reconocidos son los comunes; expresiones con listas (`1,3,5`), rangos en horas, o combinaciones complejas devuelven el expr crudo sin error.
|
||||
@@ -0,0 +1,37 @@
|
||||
package core
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCronExplain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"every 15 minutes", "*/15 * * * *", "every 15 minutes"},
|
||||
{"daily at 00:00", "0 0 * * *", "daily at 00:00"},
|
||||
{"weekdays at 09:00", "0 9 * * 1-5", "weekdays at 09:00"},
|
||||
{"every 30 minutes", "*/30 * * * *", "every 30 minutes"},
|
||||
{"every 2 hours", "0 */2 * * *", "every 2 hours"},
|
||||
{"shortcut @hourly", "@hourly", "hourly"},
|
||||
{"shortcut @daily", "@daily", "daily"},
|
||||
{"shortcut @weekly", "@weekly", "weekly"},
|
||||
{"shortcut @monthly", "@monthly", "monthly"},
|
||||
{"unknown returns raw", "5 4 * * 0", "5 4 * * 0"},
|
||||
{"invalid too few fields", "bad", "bad"},
|
||||
{"every minute", "*/1 * * * *", "every minute"},
|
||||
{"daily at 14:30", "30 14 * * *", "daily at 14:30"},
|
||||
{"every hour zero min", "0 */1 * * *", "every hour"},
|
||||
{"shortcut @midnight", "@midnight", "daily"},
|
||||
{"weekdays at 08:00", "0 8 * * 1-5", "weekdays at 08:00"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := CronExplain(tc.input)
|
||||
if got != tc.want {
|
||||
t.Errorf("CronExplain(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func CronMatch(sched CronSchedule, t time.Time) bool"
|
||||
description: "Verifica si un instante de tiempo coincide con un cron schedule. Compara los 5 campos (minuto, hora, dia del mes, mes, dia de la semana) y retorna true si todos coinciden."
|
||||
tags: [cron, scheduling, matching, time, pure, pendiente-usar]
|
||||
tags: [cron, scheduling, matching, time, pure, pendiente-usar, scheduler]
|
||||
uses_functions: []
|
||||
uses_types: [cron_schedule_go_core]
|
||||
returns: []
|
||||
|
||||
@@ -20,6 +20,7 @@ type DagStep struct {
|
||||
Command string
|
||||
Script string
|
||||
Args []string
|
||||
Function string `json:"function,omitempty"` // ID de funcion del registry (ej "audit_capability_groups_go_infra"). Si set, Command/Script se ignoran; el executor construye "fn run <Function> <Args...>".
|
||||
Shell string
|
||||
Dir string
|
||||
Depends []string
|
||||
|
||||
@@ -15,6 +15,7 @@ type rawDagStep struct {
|
||||
Command string `yaml:"command"`
|
||||
Script string `yaml:"script"`
|
||||
Args []string `yaml:"args"`
|
||||
Function string `yaml:"function"`
|
||||
Shell string `yaml:"shell"`
|
||||
Dir string `yaml:"dir"`
|
||||
WorkingDir string `yaml:"working_dir"`
|
||||
@@ -196,6 +197,7 @@ func normalizeStep(rs rawDagStep) (DagStep, error) {
|
||||
Command: rs.Command,
|
||||
Script: rs.Script,
|
||||
Args: rs.Args,
|
||||
Function: rs.Function,
|
||||
Shell: rs.Shell,
|
||||
Dir: dir,
|
||||
Depends: rs.Depends,
|
||||
|
||||
@@ -187,6 +187,30 @@ steps:
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("parsea step con function id y args", func(t *testing.T) {
|
||||
data := []byte(`
|
||||
name: dag-function
|
||||
steps:
|
||||
- name: foo
|
||||
function: audit_capability_groups_go_infra
|
||||
args:
|
||||
- --json
|
||||
`)
|
||||
dag, err := DagParse(data)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(dag.Steps) != 1 {
|
||||
t.Fatalf("Steps: got %d, want 1", len(dag.Steps))
|
||||
}
|
||||
if dag.Steps[0].Function != "audit_capability_groups_go_infra" {
|
||||
t.Errorf("Steps[0].Function: got %q, want %q", dag.Steps[0].Function, "audit_capability_groups_go_infra")
|
||||
}
|
||||
if len(dag.Steps[0].Args) != 1 || dag.Steps[0].Args[0] != "--json" {
|
||||
t.Errorf("Steps[0].Args: got %v, want [--json]", dag.Steps[0].Args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("parsea type graph", func(t *testing.T) {
|
||||
data := []byte(`
|
||||
name: dag-graph
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
package core
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// functionIDPattern matches registry IDs in `<name>_<lang>_<domain>` form.
|
||||
// Compiled once and reused across validations.
|
||||
var functionIDPattern = regexp.MustCompile(`^[a-z0-9_]+_[a-z]+_[a-z]+$`)
|
||||
|
||||
// DagValidate validates a DagDefinition for structural correctness.
|
||||
// Checks: steps have name/ID, no duplicate names/IDs, all depends reference
|
||||
@@ -31,6 +38,19 @@ func DagValidate(dag DagDefinition) DagValidationResult {
|
||||
result.Warnings = append(result.Warnings,
|
||||
fmt.Sprintf("step %q: has both command and script", ref))
|
||||
}
|
||||
|
||||
// Function-step validation.
|
||||
if step.Function != "" {
|
||||
if !functionIDPattern.MatchString(step.Function) {
|
||||
result.Errors = append(result.Errors,
|
||||
fmt.Sprintf("step %s: invalid function id format: %s", ref, step.Function))
|
||||
result.Valid = false
|
||||
}
|
||||
if step.Command != "" || step.Script != "" {
|
||||
result.Warnings = append(result.Warnings,
|
||||
fmt.Sprintf("step %s: function takes precedence; command/script ignored", ref))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !result.Valid {
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func DagValidate(dag DagDefinition) DagValidationResult"
|
||||
description: "Valida un DagDefinition para correcto uso estructural. Verifica que cada step tenga nombre o ID, que no haya duplicados, que todos los depends referencien steps existentes y que no haya ciclos (algoritmo de Kahn). Si el DAG es valido, calcula los niveles topologicos."
|
||||
tags: [dag, validation, workflow, graph, pure]
|
||||
tags: [dag, validation, workflow, graph, pure, validator]
|
||||
uses_functions: [dag_topo_sort_go_core]
|
||||
uses_types: [dag_definition_go_core, dag_validation_result_go_core]
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func NextCronTime(schedule CronSchedule, after time.Time) time.Time"
|
||||
description: "Calcula la proxima ejecucion de un cron schedule despues de un tiempo dado. Avanza minuto a minuto saltando campos no coincidentes. Retorna zero time si no hay match en 366 dias (schedule imposible)."
|
||||
tags: [cron, scheduling, time, next, pure]
|
||||
tags: [cron, scheduling, time, next, pure, scheduler]
|
||||
uses_functions: [parse_cron_expr_go_core]
|
||||
uses_types: [cron_schedule_go_core]
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func ParseCronExpr(expr string) (CronSchedule, error)"
|
||||
description: "Parsea una expresion cron estandar de 5 campos en un CronSchedule con valores expandidos. Soporta *, rangos (1-5), listas (1,3,5), pasos (*/15) y aliases (@hourly, @daily, @weekly, @monthly, @yearly). No soporta segundos ni years estilo Quartz."
|
||||
tags: [cron, scheduling, parsing, time, pure]
|
||||
tags: [cron, scheduling, parsing, time, pure, scheduler]
|
||||
uses_functions: []
|
||||
uses_types: [cron_schedule_go_core]
|
||||
returns: []
|
||||
|
||||
Reference in New Issue
Block a user