bootstrap: iniciar bitácora operativa

This commit is contained in:
2026-08-13 00:43:15 -06:00
commit 1ecfb022b1
85 changed files with 7967 additions and 0 deletions
@@ -0,0 +1,91 @@
package clusterlog
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func runJSONCommand(t *testing.T, root, keyPath, stdin string, now time.Time, args ...string) ResultEnvelope {
t.Helper()
var stdout bytes.Buffer
var stderr bytes.Buffer
app := New(Options{
Root: root, JSON: true, SSHKey: keyPath, Stdout: &stdout, Stderr: &stderr,
Stdin: strings.NewReader(stdin), Now: func() time.Time { return now }, Executable: "clusterlog",
})
if err := app.Run(args); err != nil {
t.Fatalf("command %v failed: %v\nstderr: %s", args, err, stderr.String())
}
var envelope ResultEnvelope
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("invalid command JSON for %v: %v\n%s", args, err, stdout.String())
}
if !envelope.OK {
t.Fatalf("command returned non-ok envelope: %#v", envelope)
}
return envelope
}
func envelopeDataMap(t *testing.T, envelope ResultEnvelope) map[string]any {
t.Helper()
data, ok := envelope.Data.(map[string]any)
if !ok {
t.Fatalf("unexpected data type: %T", envelope.Data)
}
return data
}
func TestCLIAdminMemoryReviewFlow(t *testing.T) {
root := testProjectRoot(t)
keyPath := filepath.Join(root, "ana.pub")
if err := os.WriteFile(keyPath, []byte(testOpenSSHPublicKey(9, "ana@test")+"\n"), 0o644); err != nil {
t.Fatal(err)
}
now := time.Date(2026, 8, 12, 14, 0, 0, 123, time.UTC)
runJSONCommand(t, root, keyPath, "", now,
"admin", "add", "--id", "ana", "--name", "Ana Operadora", "--email", "ana@example.test")
memoryJSON := `{
"title": "Reiniciar controlador de almacenamiento",
"description": "Corrección controlada de un controlador degradado",
"risk": "high",
"tags": ["storage", "mantenimiento"],
"systems": ["ceph"],
"changes": ["Se drenó el nodo", "Se reinició el controlador"],
"validation": ["El clúster volvió a HEALTH_OK"],
"rollback": "Reintegrar el controlador anterior",
"agent": "claude-code"
}`
envelope := runJSONCommand(t, root, keyPath, memoryJSON, now.Add(time.Minute),
"memory", "create", "--from-json", "-")
data := envelopeDataMap(t, envelope)
entryID, ok := data["entry_id"].(string)
if !ok || entryID == "" {
t.Fatalf("missing entry id: %#v", data)
}
state, err := New(Options{}).BuildState(root)
if err != nil {
t.Fatal(err)
}
if len(state.ReviewQueue) != 1 || !contains(state.ReviewQueue[0].MissingReviewers, "ana") {
t.Fatalf("unexpected review queue: %#v", state.ReviewQueue)
}
runJSONCommand(t, root, keyPath, "", now.Add(2*time.Minute), "review", "mark", entryID, "--note", "Validado")
state, err = New(Options{}).BuildState(root)
if err != nil {
t.Fatal(err)
}
if len(state.ReviewQueue) != 0 {
t.Fatalf("reviewed item remained pending: %#v", state.ReviewQueue)
}
files, err := reviewEventFiles(root)
if err != nil || len(files) != 1 {
t.Fatalf("expected one independent review event file, got %v, err=%v", files, err)
}
}
+427
View File
@@ -0,0 +1,427 @@
package clusterlog
import (
"flag"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
func (a *App) Run(args []string) error {
if len(args) == 0 {
return NewError(ExitUsage, "missing_command", usageText(a.Options.Executable), nil)
}
command := args[0]
if command == "help" || command == "--help" || command == "-h" {
return a.printResult("help", usageText(a.Options.Executable))
}
if command == "version" {
return a.printResult("version", map[string]any{"name": "clusterlog", "version": Version})
}
if command == "schema" {
return a.runSchema(args[1:])
}
root, err := a.ResolveRoot()
if err != nil {
return err
}
switch command {
case "identity":
return a.runIdentity(root, args[1:])
case "admin":
return a.runAdmin(root, args[1:])
case "memory":
return a.runMemory(root, args[1:])
case "review":
return a.runReview(root, args[1:])
case "task":
return a.runTask(root, args[1:])
case "project":
return a.runProject(root, args[1:])
case "content":
return a.runContent(root, args[1:])
case "sync":
return a.runSync(root, args[1:])
case "validate":
return a.runValidate(root, args[1:])
case "verify":
return a.runVerify(root, args[1:])
default:
return NewError(ExitUsage, "unknown_command", "comando desconocido: "+command, usageText(a.Options.Executable))
}
}
func usageText(executable string) string {
return fmt.Sprintf(`%s [opciones globales] <comando>
Opciones globales (antes del comando):
--root DIR raíz del proyecto
--json salida estable para agentes
--ssh-key PATH llave pública SSH o ruta base
--commit crea un commit Git SSH firmado con sólo los archivos modificados
--dry-run valida y muestra el resultado sin escribir
Comandos:
identity
admin add | admin list
memory create
review list | review mark ID
task create | start ID | stop ID | adjust ID | status ID | done ID | list
project create | list
content create
sync [--check]
validate
verify commit [SHA]
schema [memory|task|project|content]
`, executable)
}
func newFlagSet(name string) *flag.FlagSet {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.SetOutput(os.Stderr)
return fs
}
func wasSet(fs *flag.FlagSet, name string) bool {
found := false
fs.Visit(func(f *flag.Flag) {
if f.Name == name {
found = true
}
})
return found
}
func (a *App) runIdentity(root string, args []string) error {
if len(args) != 0 {
return NewError(ExitUsage, "unexpected_arguments", "identity no acepta argumentos", args)
}
identity, err := a.ResolveIdentity(root)
if err != nil {
return err
}
return a.printResult("identity", identity)
}
func (a *App) runAdmin(root string, args []string) error {
if len(args) == 0 {
return NewError(ExitUsage, "admin_usage", "uso: admin add ... | admin list [--active]", nil)
}
if args[0] == "list" {
fs := newFlagSet("admin list")
activeOnly := fs.Bool("active", false, "muestra sólo administradores activos")
if err := fs.Parse(args[1:]); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
registry, err := loadAdmins(root)
if err != nil {
return err
}
admins := []Admin{}
for _, admin := range registry.Admins {
if !*activeOnly || admin.Active {
admins = append(admins, admin)
}
}
return a.printResult("admin.list", map[string]any{
"count": len(admins), "admins": admins, "review_policy": registry.ReviewPolicy,
})
}
if args[0] != "add" {
return NewError(ExitUsage, "admin_usage", "uso: admin add ... | admin list [--active]", args[0])
}
fs := newFlagSet("admin add")
id := fs.String("id", "", "identificador estable")
name := fs.String("name", "", "nombre")
email := fs.String("email", "", "correo")
keyPath := fs.String("key", "", "llave pública")
rolesCSV := fs.String("roles", "admin", "roles separados por coma")
if err := fs.Parse(args[1:]); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
adminID := slugify(*id)
if adminID == "" || strings.TrimSpace(*name) == "" || !strings.Contains(*email, "@") {
return NewError(ExitUsage, "invalid_admin", "id, name y un email válido son obligatorios", nil)
}
path := firstNonEmpty(*keyPath, a.Options.SSHKey, os.Getenv("CLUSTERLOG_SSH_KEY"))
if path == "" {
return NewError(ExitUsage, "missing_ssh_key", "indique --key o --ssh-key para registrar la llave pública", nil)
}
canonical, fingerprint, publicPath, signingPath, err := readPublicKey(path)
if err != nil {
return NewError(ExitIdentity, "invalid_ssh_key", err.Error(), path)
}
registry, err := loadAdmins(root)
if err != nil {
return err
}
var actor Identity
if len(registry.Admins) > 0 {
actor, err = a.ResolveIdentity(root)
if err != nil {
return err
}
if !hasRole(actor.Admin, "owner") {
return NewError(ExitIdentity, "not_authorized", "sólo un administrador con rol owner puede agregar administradores", actor.Admin.ID)
}
}
for _, admin := range registry.Admins {
if strings.EqualFold(admin.ID, adminID) || strings.EqualFold(admin.Email, *email) {
return NewError(ExitConflict, "admin_exists", "ya existe un administrador con ese id o correo", admin.ID)
}
for _, key := range admin.SSHKeys {
if key.Fingerprint == fingerprint {
return NewError(ExitConflict, "key_exists", "la llave SSH ya pertenece a otro administrador", admin.ID)
}
}
}
roles := splitCSV(*rolesCSV)
if len(registry.Admins) == 0 && !contains(roles, "owner") {
roles = append(roles, "owner")
}
roles = uniqueSorted(roles)
newAdmin := Admin{
ID: adminID, Name: strings.TrimSpace(*name), Email: strings.TrimSpace(*email),
Active: true, Roles: roles,
SSHKeys: []SSHKey{{Fingerprint: fingerprint, PublicKey: canonical, AddedAt: a.Options.Now().UTC().Format(time.RFC3339Nano)}},
}
registry.Admins = append(registry.Admins, newAdmin)
sort.Slice(registry.Admins, func(i, j int) bool { return registry.Admins[i].ID < registry.Admins[j].ID })
if a.Options.DryRun {
return a.printResult("admin.add", map[string]any{"admin": newAdmin, "dry_run": true})
}
if err := saveAdmins(root, registry); err != nil {
return err
}
state, changed, statePath, err := a.Sync(root, false)
if err != nil {
return err
}
commitIdentity := actor
if len(registry.Admins) == 1 {
commitIdentity = Identity{Admin: newAdmin, Fingerprint: fingerprint, PublicKey: canonical, PublicPath: publicPath, SigningKey: signingPath}
}
paths := []string{filepath.Join(root, "data", "admins.json"), filepath.Join(root, "config", "allowed_signers")}
if changed {
paths = append(paths, statePath)
}
sha, err := a.commitPaths(root, commitIdentity, "admin: add "+newAdmin.ID, paths)
if err != nil {
return err
}
warnings := []string{}
if !a.Options.Commit {
warnings = append(warnings, "proteja data/admins.json y exija commits SSH firmados antes de usarlo en producción")
}
return a.printResult("admin.add", map[string]any{"admin": newAdmin, "state": state.Stats, "commit": sha}, warnings...)
}
func (a *App) runMemory(root string, args []string) error {
if len(args) == 0 || args[0] != "create" {
return NewError(ExitUsage, "memory_usage", "uso: memory create --title ... [--from-json ARCHIVO|-]", nil)
}
fs := newFlagSet("memory create")
title := fs.String("title", "", "título")
description := fs.String("description", "", "resumen")
tags := fs.String("tags", "", "tags separados por coma")
systems := fs.String("systems", "", "sistemas separados por coma")
risk := fs.String("risk", "", "low|medium|high|critical")
ticket := fs.String("ticket", "", "ticket relacionado")
review := fs.Bool("review", true, "requiere revisión")
reviewPolicy := fs.String("review-policy", "", "all-active|all-active-except-author")
bodyFile := fs.String("body-file", "", "Markdown o -")
fromJSON := fs.String("from-json", "", "JSON o -")
duration := fs.String("duration", "", "minutos, 90m, 2h")
agent := fs.String("agent", "", "agente que preparó el texto")
if err := fs.Parse(args[1:]); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
var input MemoryInput
if *fromJSON != "" {
if err := decodeJSONInput(*fromJSON, a.Options.Stdin, &input); err != nil {
return err
}
}
if wasSet(fs, "title") {
input.Title = *title
}
if wasSet(fs, "description") {
input.Description = *description
}
if wasSet(fs, "tags") {
input.Tags = splitCSV(*tags)
}
if wasSet(fs, "systems") {
input.Systems = splitCSV(*systems)
}
if wasSet(fs, "risk") {
input.Risk = *risk
}
if wasSet(fs, "ticket") {
input.Ticket = *ticket
}
if wasSet(fs, "review") {
input.ReviewRequired = review
}
if wasSet(fs, "review-policy") {
input.ReviewPolicy = *reviewPolicy
}
if wasSet(fs, "agent") {
input.Agent = *agent
}
if *duration != "" {
minutes, err := parseDurationMinutes(*duration)
if err != nil {
return NewError(ExitUsage, "invalid_duration", err.Error(), nil)
}
input.DurationMinutes = minutes
}
if *bodyFile != "" {
data, err := readAll(*bodyFile, a.Options.Stdin)
if err != nil {
return err
}
input.BodyMarkdown = string(data)
}
identity, err := a.ResolveIdentity(root)
if err != nil {
return err
}
result, warnings, err := a.createMemory(root, identity, input)
if err != nil {
return err
}
if a.Options.DryRun {
return a.printResult("memory.create", result, warnings...)
}
state, changed, statePath, err := a.Sync(root, false)
if err != nil {
return err
}
createdPath := filepath.FromSlash(result["path"].(string))
paths := []string{createdPath}
if changed {
paths = append(paths, statePath)
}
sha, err := a.commitPaths(root, identity, fmt.Sprintf("memory(%s): %s", result["entry_id"], input.Title), paths)
if err != nil {
return err
}
result["commit"] = sha
result["pending_reviews"] = state.Stats.PendingReviews
return a.printResult("memory.create", result, warnings...)
}
func (a *App) runReview(root string, args []string) error {
if len(args) == 0 {
return NewError(ExitUsage, "review_usage", "uso: review list | review mark ID [--note TEXTO]", nil)
}
switch args[0] {
case "list":
fs := newFlagSet("review list")
mine := fs.Bool("mine", false, "sólo pendientes del administrador actual")
if err := fs.Parse(args[1:]); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
state, err := a.BuildState(root)
if err != nil {
return err
}
queue := state.ReviewQueue
if *mine {
identity, err := a.ResolveIdentity(root)
if err != nil {
return err
}
filtered := []ReviewQueueItem{}
for _, item := range queue {
if contains(item.MissingReviewers, identity.Admin.ID) {
filtered = append(filtered, item)
}
}
queue = filtered
}
return a.printResult("review.list", map[string]any{"count": len(queue), "items": queue})
case "mark":
if len(args) < 2 {
return NewError(ExitUsage, "missing_entry_id", "indique el ID de la memoria", nil)
}
entryID := args[1]
fs := newFlagSet("review mark")
note := fs.String("note", "", "nota breve")
if err := fs.Parse(args[2:]); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
identity, err := a.ResolveIdentity(root)
if err != nil {
return err
}
memory, _, err := findMemory(root, entryID)
if err != nil {
return err
}
if !memory.ReviewRequired {
return NewError(ExitValidation, "review_not_required", "la memoria no requiere revisión", memory.EntryID)
}
registry, err := loadAdmins(root)
if err != nil {
return err
}
if memory.ReviewerSnapshot {
if !contains(memory.Reviewers, identity.Admin.ID) {
return NewError(ExitValidation, "review_not_required_for_admin", "el administrador no forma parte del conjunto de revisores capturado para esta memoria", map[string]any{
"admin_id": identity.Admin.ID, "reviewers": memory.Reviewers,
})
}
} else {
policy := firstNonEmpty(memory.ReviewPolicy, registry.ReviewPolicy.Default)
if (policy == "all-active-except-author" || (policy == "all-active" && !registry.ReviewPolicy.IncludeAuthor)) && identity.Admin.ID == memory.AuthorID {
return NewError(ExitValidation, "review_not_required_for_author", "la política excluye al autor de la revisión", identity.Admin.ID)
}
}
log, err := loadReviewLog(root)
if err != nil {
return err
}
for _, event := range log.Events {
if normalizeID(event.EntryID) == memory.EntryID && event.AdminID == identity.Admin.ID && event.ContentSHA256 == memory.ContentSHA256 {
state, err := a.BuildState(root)
if err != nil {
return err
}
return a.printResult("review.mark", map[string]any{"entry_id": memory.EntryID, "admin_id": identity.Admin.ID, "already_reviewed": true, "pending_reviews": state.Stats.PendingReviews})
}
}
eventID, err := generateID("REV", a.Options.Now())
if err != nil {
return err
}
event := ReviewEvent{ID: eventID, EntryID: memory.EntryID, AdminID: identity.Admin.ID, Fingerprint: identity.Fingerprint, ReviewedAt: a.Options.Now().UTC().Format(time.RFC3339Nano), ContentSHA256: memory.ContentSHA256, Note: strings.TrimSpace(*note)}
if a.Options.DryRun {
return a.printResult("review.mark", map[string]any{"event": event, "dry_run": true})
}
eventPath, err := writeReviewEvent(root, event)
if err != nil {
return err
}
state, changed, statePath, err := a.Sync(root, false)
if err != nil {
return err
}
paths := []string{eventPath}
if changed {
paths = append(paths, statePath)
}
sha, err := a.commitPaths(root, identity, fmt.Sprintf("review(%s): %s", memory.EntryID, identity.Admin.ID), paths)
if err != nil {
return err
}
return a.printResult("review.mark", map[string]any{"event": event, "pending_reviews": state.Stats.PendingReviews, "commit": sha})
default:
return NewError(ExitUsage, "review_usage", "uso: review list | review mark ID", args[0])
}
}
+227
View File
@@ -0,0 +1,227 @@
package clusterlog
import (
"fmt"
"path/filepath"
)
func (a *App) runProject(root string, args []string) error {
if len(args) == 0 {
return NewError(ExitUsage, "project_usage", "uso: project create | list", nil)
}
switch args[0] {
case "create":
fs := newFlagSet("project create")
title := fs.String("title", "", "título")
description := fs.String("description", "", "resumen")
objective := fs.String("objective", "", "objetivo")
owners := fs.String("owners", "", "administradores separados por coma")
targetDate := fs.String("target-date", "", "fecha YYYY-MM-DD")
status := fs.String("status", "", "planned|active|blocked|done|cancelled")
tags := fs.String("tags", "", "tags separados por coma")
systems := fs.String("systems", "", "sistemas separados por coma")
scope := fs.String("scope", "", "elementos de alcance separados por coma")
outOfScope := fs.String("out-of-scope", "", "elementos fuera de alcance separados por coma")
milestones := fs.String("milestones", "", "hitos separados por coma")
success := fs.String("success", "", "criterios de éxito separados por coma")
bodyFile := fs.String("body-file", "", "Markdown o -")
fromJSON := fs.String("from-json", "", "JSON o -")
if err := fs.Parse(args[1:]); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
var input ProjectInput
if *fromJSON != "" {
if err := decodeJSONInput(*fromJSON, a.Options.Stdin, &input); err != nil {
return err
}
}
if wasSet(fs, "title") {
input.Title = *title
}
if wasSet(fs, "description") {
input.Description = *description
}
if wasSet(fs, "objective") {
input.Objective = *objective
}
if wasSet(fs, "owners") {
input.Owners = splitCSV(*owners)
}
if wasSet(fs, "target-date") {
input.TargetDate = *targetDate
}
if wasSet(fs, "status") {
input.Status = *status
}
if wasSet(fs, "tags") {
input.Tags = splitCSV(*tags)
}
if wasSet(fs, "systems") {
input.Systems = splitCSV(*systems)
}
if wasSet(fs, "scope") {
input.Scope = splitCSV(*scope)
}
if wasSet(fs, "out-of-scope") {
input.OutOfScope = splitCSV(*outOfScope)
}
if wasSet(fs, "milestones") {
input.Milestones = splitCSV(*milestones)
}
if wasSet(fs, "success") {
input.Success = splitCSV(*success)
}
if *bodyFile != "" {
data, err := readAll(*bodyFile, a.Options.Stdin)
if err != nil {
return err
}
input.BodyMarkdown = string(data)
}
identity, err := a.ResolveIdentity(root)
if err != nil {
return err
}
result, warnings, err := a.createProject(root, identity, input)
if err != nil {
return err
}
if a.Options.DryRun {
return a.printResult("project.create", result, warnings...)
}
state, changed, statePath, err := a.Sync(root, false)
if err != nil {
return err
}
paths := []string{filepath.FromSlash(result["path"].(string))}
if changed {
paths = append(paths, statePath)
}
sha, err := a.commitPaths(root, identity, fmt.Sprintf("project(%s): %s", result["project_id"], input.Title), paths)
if err != nil {
return err
}
result["commit"] = sha
result["active_projects"] = state.Stats.ActiveProjects
return a.printResult("project.create", result, warnings...)
case "list":
fs := newFlagSet("project list")
status := fs.String("status", "", "filtrar por estado")
if err := fs.Parse(args[1:]); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
state, err := a.BuildState(root)
if err != nil {
return err
}
items := []ProjectState{}
for _, item := range state.Projects {
if *status == "" || item.Status == *status {
items = append(items, item)
}
}
return a.printResult("project.list", map[string]any{"count": len(items), "items": items})
default:
return NewError(ExitUsage, "project_usage", "uso: project create | list", args[0])
}
}
func (a *App) runContent(root string, args []string) error {
if len(args) == 0 || args[0] != "create" {
return NewError(ExitUsage, "content_usage", "uso: content create --section documentacion|manuales --title ... --body-file ...", nil)
}
fs := newFlagSet("content create")
section := fs.String("section", "", "documentacion|manuales")
title := fs.String("title", "", "título")
description := fs.String("description", "", "resumen")
tags := fs.String("tags", "", "tags separados por coma")
systems := fs.String("systems", "", "sistemas separados por coma")
bodyFile := fs.String("body-file", "", "Markdown o -")
weight := fs.Int("weight", 0, "orden opcional")
fromJSON := fs.String("from-json", "", "JSON o -")
if err := fs.Parse(args[1:]); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
var input ContentInput
if *fromJSON != "" {
if err := decodeJSONInput(*fromJSON, a.Options.Stdin, &input); err != nil {
return err
}
}
if wasSet(fs, "section") {
input.Section = *section
}
if wasSet(fs, "title") {
input.Title = *title
}
if wasSet(fs, "description") {
input.Description = *description
}
if wasSet(fs, "tags") {
input.Tags = splitCSV(*tags)
}
if wasSet(fs, "systems") {
input.Systems = splitCSV(*systems)
}
if wasSet(fs, "weight") {
input.Weight = *weight
}
if *bodyFile != "" {
data, err := readAll(*bodyFile, a.Options.Stdin)
if err != nil {
return err
}
input.BodyMarkdown = string(data)
}
identity, err := a.ResolveIdentity(root)
if err != nil {
return err
}
result, warnings, err := a.createContent(root, identity, input)
if err != nil {
return err
}
if a.Options.DryRun {
return a.printResult("content.create", result, warnings...)
}
createdPath := filepath.FromSlash(result["path"].(string))
sha, err := a.commitPaths(root, identity, fmt.Sprintf("%s(%s): %s", input.Section, result["content_id"], input.Title), []string{createdPath})
if err != nil {
return err
}
result["commit"] = sha
return a.printResult("content.create", result, warnings...)
}
func (a *App) runSync(root string, args []string) error {
fs := newFlagSet("sync")
check := fs.Bool("check", false, "no escribe; falla si el estado generado está desactualizado")
if err := fs.Parse(args); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
state, changed, path, err := a.Sync(root, *check)
if err != nil {
return err
}
return a.printResult("sync", map[string]any{
"path": filepath.ToSlash(path), "changed": changed, "check": *check,
"source_sha256": state.SourceSHA256, "stats": state.Stats,
})
}
func (a *App) runVerify(root string, args []string) error {
if len(args) == 0 || args[0] != "commit" {
return NewError(ExitUsage, "verify_usage", "uso: verify commit [SHA]", nil)
}
sha := "HEAD"
if len(args) > 1 {
sha = args[1]
}
if len(args) > 2 {
return NewError(ExitUsage, "unexpected_arguments", "uso: verify commit [SHA]", args[2:])
}
if err := verifyCommit(root, sha); err != nil {
return err
}
return a.printResult("verify.commit", map[string]any{"commit": sha, "valid": true})
}
+527
View File
@@ -0,0 +1,527 @@
package clusterlog
import (
"fmt"
"path/filepath"
"strings"
"time"
)
func (a *App) runTask(root string, args []string) error {
if len(args) == 0 {
return NewError(ExitUsage, "task_usage", "uso: task create | start ID | stop ID | adjust ID | status ID | done ID | list", nil)
}
switch args[0] {
case "create":
return a.runTaskCreate(root, args[1:])
case "start":
return a.runTaskStart(root, args[1:])
case "stop":
return a.runTaskStop(root, args[1:])
case "adjust":
return a.runTaskAdjust(root, args[1:])
case "status":
return a.runTaskStatus(root, args[1:])
case "done":
return a.runTaskDone(root, args[1:])
case "list":
return a.runTaskList(root, args[1:])
default:
return NewError(ExitUsage, "task_usage", "uso: task create | start ID | stop ID | adjust ID | status ID | done ID | list", args[0])
}
}
func (a *App) runTaskCreate(root string, args []string) error {
fs := newFlagSet("task create")
title := fs.String("title", "", "título")
description := fs.String("description", "", "resumen")
projectID := fs.String("project", "", "ID de proyecto")
priority := fs.String("priority", "", "low|medium|high|urgent")
assignees := fs.String("assignees", "", "administradores separados por coma")
estimate := fs.String("estimate", "", "minutos, 90m, 2h")
due := fs.String("due", "", "fecha YYYY-MM-DD")
tags := fs.String("tags", "", "tags separados por coma")
systems := fs.String("systems", "", "sistemas separados por coma")
objective := fs.String("objective", "", "objetivo")
acceptance := fs.String("acceptance", "", "criterios separados por coma")
notes := fs.String("notes", "", "notas")
bodyFile := fs.String("body-file", "", "Markdown o -")
fromJSON := fs.String("from-json", "", "JSON o -")
if err := fs.Parse(args); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
var input TaskInput
if *fromJSON != "" {
if err := decodeJSONInput(*fromJSON, a.Options.Stdin, &input); err != nil {
return err
}
}
if wasSet(fs, "title") {
input.Title = *title
}
if wasSet(fs, "description") {
input.Description = *description
}
if wasSet(fs, "project") {
input.ProjectID = *projectID
}
if wasSet(fs, "priority") {
input.Priority = *priority
}
if wasSet(fs, "assignees") {
input.Assignees = splitCSV(*assignees)
}
if wasSet(fs, "due") {
input.Due = *due
}
if wasSet(fs, "tags") {
input.Tags = splitCSV(*tags)
}
if wasSet(fs, "systems") {
input.Systems = splitCSV(*systems)
}
if wasSet(fs, "objective") {
input.Objective = *objective
}
if wasSet(fs, "acceptance") {
input.Acceptance = splitCSV(*acceptance)
}
if wasSet(fs, "notes") {
input.Notes = *notes
}
if *estimate != "" {
minutes, err := parseDurationMinutes(*estimate)
if err != nil {
return NewError(ExitUsage, "invalid_estimate", err.Error(), nil)
}
input.EstimateMinutes = minutes
}
if *bodyFile != "" {
data, err := readAll(*bodyFile, a.Options.Stdin)
if err != nil {
return err
}
input.BodyMarkdown = string(data)
}
identity, err := a.ResolveIdentity(root)
if err != nil {
return err
}
result, warnings, err := a.createTask(root, identity, input)
if err != nil {
return err
}
if a.Options.DryRun {
return a.printResult("task.create", result, warnings...)
}
state, changed, statePath, err := a.Sync(root, false)
if err != nil {
return err
}
createdPath := filepath.FromSlash(result["path"].(string))
paths := []string{createdPath}
if changed {
paths = append(paths, statePath)
}
sha, err := a.commitPaths(root, identity, fmt.Sprintf("task(%s): %s", result["task_id"], input.Title), paths)
if err != nil {
return err
}
result["commit"] = sha
result["open_tasks"] = state.Stats.OpenTasks
return a.printResult("task.create", result, warnings...)
}
func (a *App) runTaskStart(root string, args []string) error {
if len(args) < 1 {
return NewError(ExitUsage, "missing_task_id", "uso: task start ID [--note TEXTO]", nil)
}
taskID := normalizeID(args[0])
fs := newFlagSet("task start")
note := fs.String("note", "", "nota breve")
if err := fs.Parse(args[1:]); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
identity, err := a.ResolveIdentity(root)
if err != nil {
return err
}
task, _, err := findTask(root, taskID)
if err != nil {
return err
}
state, err := a.BuildState(root)
if err != nil {
return err
}
currentStatus := task.Status
for _, current := range state.Tasks {
if current.TaskID == task.TaskID {
currentStatus = current.Status
break
}
}
if statusIsClosed(currentStatus) {
return NewError(ExitConflict, "task_closed", "no se puede iniciar una tarea cerrada", map[string]any{
"task_id": task.TaskID, "status": currentStatus,
})
}
log, err := loadTaskLog(root)
if err != nil {
return err
}
if active, ok := taskActiveStart(log, identity.Admin.ID); ok {
if normalizeID(active.TaskID) == task.TaskID {
return a.printResult("task.start", map[string]any{
"task_id": task.TaskID, "admin_id": identity.Admin.ID,
"already_running": true, "started_at": active.At,
})
}
return NewError(ExitConflict, "timer_already_running", "el administrador ya tiene otra tarea activa", map[string]any{
"active_task_id": active.TaskID,
"started_at": active.At,
"hint": "ejecute task stop para la tarea activa",
})
}
eventID, err := generateID("EVT", a.Options.Now())
if err != nil {
return err
}
event := TaskEvent{
ID: eventID, TaskID: task.TaskID, Type: "start", AdminID: identity.Admin.ID,
Fingerprint: identity.Fingerprint, At: a.Options.Now().UTC().Format(time.RFC3339Nano), Note: strings.TrimSpace(*note),
}
if a.Options.DryRun {
return a.printResult("task.start", map[string]any{"event": event, "dry_run": true})
}
return a.persistTaskEvent(root, identity, event, "start")
}
func (a *App) runTaskStop(root string, args []string) error {
if len(args) < 1 {
return NewError(ExitUsage, "missing_task_id", "uso: task stop ID [--note TEXTO]", nil)
}
taskID := normalizeID(args[0])
fs := newFlagSet("task stop")
note := fs.String("note", "", "nota breve")
if err := fs.Parse(args[1:]); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
identity, err := a.ResolveIdentity(root)
if err != nil {
return err
}
task, _, err := findTask(root, taskID)
if err != nil {
return err
}
log, err := loadTaskLog(root)
if err != nil {
return err
}
started, running := taskIsRunning(log, task.TaskID, identity.Admin.ID)
if !running {
return NewError(ExitConflict, "timer_not_running", "no hay un temporizador activo para esta tarea y administrador", map[string]any{
"task_id": task.TaskID, "admin_id": identity.Admin.ID,
})
}
startedAt, err := time.Parse(time.RFC3339, started.At)
if err != nil {
return NewError(ExitValidation, "invalid_start_event", "el evento start tiene una fecha inválida", started)
}
now := a.Options.Now().UTC()
if now.Before(startedAt) {
return NewError(ExitValidation, "clock_before_start", "la hora actual es anterior al inicio registrado", started)
}
elapsed := now.Sub(startedAt)
minutes := int((elapsed + time.Minute - 1) / time.Minute)
if minutes < 1 {
minutes = 1
}
eventID, err := generateID("EVT", now)
if err != nil {
return err
}
event := TaskEvent{
ID: eventID, TaskID: task.TaskID, Type: "stop", AdminID: identity.Admin.ID,
Fingerprint: identity.Fingerprint, At: now.Format(time.RFC3339Nano), Minutes: minutes, Note: strings.TrimSpace(*note),
}
if a.Options.DryRun {
return a.printResult("task.stop", map[string]any{"event": event, "elapsed": formatMinutes(minutes), "dry_run": true})
}
return a.persistTaskEvent(root, identity, event, "stop")
}
func (a *App) runTaskAdjust(root string, args []string) error {
if len(args) < 1 {
return NewError(ExitUsage, "missing_task_id", "uso: task adjust ID --minutes N [--note TEXTO]", nil)
}
taskID := normalizeID(args[0])
fs := newFlagSet("task adjust")
minutes := fs.Int("minutes", 0, "minutos positivos o negativos")
note := fs.String("note", "", "justificación del ajuste")
if err := fs.Parse(args[1:]); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
if !wasSet(fs, "minutes") || *minutes == 0 {
return NewError(ExitUsage, "invalid_adjustment", "--minutes debe ser distinto de cero", nil)
}
identity, err := a.ResolveIdentity(root)
if err != nil {
return err
}
task, _, err := findTask(root, taskID)
if err != nil {
return err
}
state, err := a.BuildState(root)
if err != nil {
return err
}
for _, current := range state.Tasks {
if current.TaskID == task.TaskID && current.ActualMinutes+*minutes < 0 {
return NewError(ExitValidation, "negative_actual_time", "el ajuste dejaría el tiempo real por debajo de cero", map[string]any{
"task_id": task.TaskID, "actual_minutes": current.ActualMinutes, "adjustment": *minutes,
})
}
}
now := a.Options.Now().UTC()
eventID, err := generateID("EVT", now)
if err != nil {
return err
}
event := TaskEvent{
ID: eventID, TaskID: task.TaskID, Type: "adjust", AdminID: identity.Admin.ID,
Fingerprint: identity.Fingerprint, At: now.Format(time.RFC3339Nano), Minutes: *minutes, Note: strings.TrimSpace(*note),
}
if a.Options.DryRun {
return a.printResult("task.adjust", map[string]any{"event": event, "dry_run": true})
}
return a.persistTaskEvent(root, identity, event, "adjust")
}
func (a *App) runTaskStatus(root string, args []string) error {
if len(args) < 1 {
return NewError(ExitUsage, "missing_task_id", "uso: task status ID --status backlog|todo|in_progress|blocked|cancelled [--note TEXTO]", nil)
}
taskID := normalizeID(args[0])
fs := newFlagSet("task status")
status := fs.String("status", "", "backlog|todo|in_progress|blocked|cancelled")
note := fs.String("note", "", "nota breve")
if err := fs.Parse(args[1:]); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
newStatus := strings.ToLower(strings.TrimSpace(*status))
allowed := map[string]bool{"backlog": true, "todo": true, "in_progress": true, "blocked": true, "cancelled": true}
if !allowed[newStatus] {
return NewError(ExitUsage, "invalid_task_status", "use backlog, todo, in_progress, blocked o cancelled; para completar use task done", newStatus)
}
identity, err := a.ResolveIdentity(root)
if err != nil {
return err
}
task, _, err := findTask(root, taskID)
if err != nil {
return err
}
state, err := a.BuildState(root)
if err != nil {
return err
}
for _, current := range state.Tasks {
if current.TaskID != task.TaskID {
continue
}
if current.Status == newStatus {
return a.printResult("task.status", map[string]any{"task_id": task.TaskID, "status": newStatus, "already_set": true})
}
if statusIsClosed(newStatus) && len(current.RunningBy) > 0 {
return NewError(ExitConflict, "task_has_running_timers", "detenga todos los temporizadores antes de cerrar la tarea", map[string]any{
"task_id": task.TaskID, "running_by": current.RunningBy,
})
}
}
now := a.Options.Now().UTC()
eventID, err := generateID("EVT", now)
if err != nil {
return err
}
event := TaskEvent{
ID: eventID, TaskID: task.TaskID, Type: "status", AdminID: identity.Admin.ID,
Fingerprint: identity.Fingerprint, At: now.Format(time.RFC3339Nano), Status: newStatus, Note: strings.TrimSpace(*note),
}
if a.Options.DryRun {
return a.printResult("task.status", map[string]any{"event": event, "dry_run": true})
}
return a.persistTaskEvent(root, identity, event, "status")
}
func (a *App) runTaskDone(root string, args []string) error {
if len(args) < 1 {
return NewError(ExitUsage, "missing_task_id", "uso: task done ID [--note TEXTO]", nil)
}
taskID := normalizeID(args[0])
fs := newFlagSet("task done")
note := fs.String("note", "", "nota breve")
if err := fs.Parse(args[1:]); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
identity, err := a.ResolveIdentity(root)
if err != nil {
return err
}
task, _, err := findTask(root, taskID)
if err != nil {
return err
}
stateBefore, err := a.BuildState(root)
if err != nil {
return err
}
for _, current := range stateBefore.Tasks {
if current.TaskID != task.TaskID {
continue
}
if current.Status == "done" {
return a.printResult("task.done", map[string]any{"task_id": task.TaskID, "already_done": true})
}
otherRunners := []string{}
for _, adminID := range current.RunningBy {
if adminID != identity.Admin.ID {
otherRunners = append(otherRunners, adminID)
}
}
if len(otherRunners) > 0 {
return NewError(ExitConflict, "task_has_other_running_timers", "otros administradores aún registran tiempo en esta tarea", map[string]any{
"task_id": task.TaskID, "running_by": otherRunners,
})
}
}
log, err := loadTaskLog(root)
if err != nil {
return err
}
now := a.Options.Now().UTC()
newEvents := []TaskEvent{}
if started, running := taskIsRunning(log, task.TaskID, identity.Admin.ID); running {
startedAt, parseErr := time.Parse(time.RFC3339, started.At)
if parseErr != nil {
return NewError(ExitValidation, "invalid_start_event", "el evento start tiene una fecha inválida", started)
}
if now.Before(startedAt) {
return NewError(ExitValidation, "clock_before_start", "la hora actual es anterior al inicio registrado", started)
}
minutes := int((now.Sub(startedAt) + time.Minute - 1) / time.Minute)
if minutes < 1 {
minutes = 1
}
id, idErr := generateID("EVT", now)
if idErr != nil {
return idErr
}
newEvents = append(newEvents, TaskEvent{
ID: id, TaskID: task.TaskID, Type: "stop", AdminID: identity.Admin.ID,
Fingerprint: identity.Fingerprint, At: now.Format(time.RFC3339Nano), Minutes: minutes,
Note: "temporizador cerrado al completar la tarea",
})
}
id, err := generateID("EVT", now)
if err != nil {
return err
}
statusEvent := TaskEvent{
ID: id, TaskID: task.TaskID, Type: "status", AdminID: identity.Admin.ID,
Fingerprint: identity.Fingerprint, At: now.Format(time.RFC3339Nano), Status: "done", Note: strings.TrimSpace(*note),
}
newEvents = append(newEvents, statusEvent)
if a.Options.DryRun {
return a.printResult("task.done", map[string]any{"events": newEvents, "dry_run": true})
}
eventPaths, err := writeTaskEvents(root, newEvents)
if err != nil {
return err
}
state, changed, statePath, err := a.Sync(root, false)
if err != nil {
return err
}
paths := append([]string(nil), eventPaths...)
if changed {
paths = append(paths, statePath)
}
sha, err := a.commitPaths(root, identity, fmt.Sprintf("task(%s): done", task.TaskID), paths)
if err != nil {
return err
}
var final TaskState
for _, item := range state.Tasks {
if item.TaskID == task.TaskID {
final = item
break
}
}
return a.printResult("task.done", map[string]any{"task": final, "events": newEvents, "commit": sha})
}
func (a *App) runTaskList(root string, args []string) error {
fs := newFlagSet("task list")
status := fs.String("status", "", "filtrar por estado")
mine := fs.Bool("mine", false, "sólo tareas asignadas o ejecutadas por el administrador actual")
projectID := fs.String("project", "", "filtrar por proyecto")
if err := fs.Parse(args); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
state, err := a.BuildState(root)
if err != nil {
return err
}
var adminID string
if *mine {
identity, err := a.ResolveIdentity(root)
if err != nil {
return err
}
adminID = identity.Admin.ID
}
items := []TaskState{}
for _, item := range state.Tasks {
if *status != "" && item.Status != strings.ToLower(*status) {
continue
}
if *projectID != "" && item.ProjectID != normalizeID(*projectID) {
continue
}
if *mine && !contains(item.Assignees, adminID) && !contains(item.RunningBy, adminID) {
continue
}
items = append(items, item)
}
return a.printResult("task.list", map[string]any{"count": len(items), "items": items})
}
func (a *App) persistTaskEvent(root string, identity Identity, event TaskEvent, action string) error {
eventPaths, err := writeTaskEvents(root, []TaskEvent{event})
if err != nil {
return err
}
state, changed, statePath, err := a.Sync(root, false)
if err != nil {
return err
}
paths := append([]string(nil), eventPaths...)
if changed {
paths = append(paths, statePath)
}
sha, err := a.commitPaths(root, identity, fmt.Sprintf("task(%s): %s", event.TaskID, action), paths)
if err != nil {
return err
}
var task TaskState
for _, item := range state.Tasks {
if item.TaskID == normalizeID(event.TaskID) {
task = item
break
}
}
return a.printResult("task."+action, map[string]any{"event": event, "task": task, "commit": sha})
}
+434
View File
@@ -0,0 +1,434 @@
package clusterlog
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
)
type MemoryInput struct {
Title string `json:"title"`
Description string `json:"description"`
Tags []string `json:"tags"`
Systems []string `json:"systems"`
Risk string `json:"risk"`
Ticket string `json:"ticket,omitempty"`
ReviewRequired *bool `json:"review_required,omitempty"`
ReviewPolicy string `json:"review_policy,omitempty"`
BodyMarkdown string `json:"body_markdown,omitempty"`
Context string `json:"context,omitempty"`
Changes []string `json:"changes,omitempty"`
Commands []string `json:"commands,omitempty"`
Validation []string `json:"validation,omitempty"`
Rollback string `json:"rollback,omitempty"`
StartedAt string `json:"started_at,omitempty"`
FinishedAt string `json:"finished_at,omitempty"`
DurationMinutes int `json:"duration_minutes,omitempty"`
Agent string `json:"agent,omitempty"`
}
type TaskInput struct {
Title string `json:"title"`
Description string `json:"description"`
ProjectID string `json:"project_id,omitempty"`
Priority string `json:"priority,omitempty"`
Assignees []string `json:"assignees,omitempty"`
EstimateMinutes int `json:"estimate_minutes,omitempty"`
Due string `json:"due,omitempty"`
Tags []string `json:"tags,omitempty"`
Systems []string `json:"systems,omitempty"`
BodyMarkdown string `json:"body_markdown,omitempty"`
Objective string `json:"objective,omitempty"`
Acceptance []string `json:"acceptance_criteria,omitempty"`
Notes string `json:"notes,omitempty"`
}
type ProjectInput struct {
Title string `json:"title"`
Description string `json:"description"`
Objective string `json:"objective"`
Owners []string `json:"owners,omitempty"`
TargetDate string `json:"target_date,omitempty"`
Status string `json:"status,omitempty"`
Tags []string `json:"tags,omitempty"`
Systems []string `json:"systems,omitempty"`
BodyMarkdown string `json:"body_markdown,omitempty"`
Scope []string `json:"scope,omitempty"`
OutOfScope []string `json:"out_of_scope,omitempty"`
Milestones []string `json:"milestones,omitempty"`
Success []string `json:"success_criteria,omitempty"`
}
type ContentInput struct {
Section string `json:"section"`
Title string `json:"title"`
Description string `json:"description"`
Tags []string `json:"tags,omitempty"`
Systems []string `json:"systems,omitempty"`
BodyMarkdown string `json:"body_markdown"`
Weight int `json:"weight,omitempty"`
}
func decodeJSONInput(path string, stdinReader io.Reader, dst any) error {
data, err := readAll(path, stdinReader)
if err != nil {
return err
}
decoder := json.NewDecoder(strings.NewReader(string(data)))
decoder.DisallowUnknownFields()
if err := decoder.Decode(dst); err != nil {
return NewError(ExitUsage, "invalid_json", "el documento de entrada no es JSON válido o contiene campos desconocidos", err.Error())
}
return nil
}
func renderMemoryBody(input MemoryInput) string {
if strings.TrimSpace(input.BodyMarkdown) != "" {
return normalizeMarkdownBody(input.BodyMarkdown)
}
var b strings.Builder
b.WriteString("## Contexto\n\n")
b.WriteString(firstNonEmpty(input.Context, "_No especificado._"))
b.WriteString("\n\n## Cambios realizados\n\n")
b.WriteString(bullets(input.Changes))
b.WriteString("\n## Comandos relevantes (secretos redactados)\n\n")
b.WriteString(codeBlock(input.Commands))
b.WriteString("\n## Validación\n\n")
b.WriteString(bullets(input.Validation))
b.WriteString("\n## Riesgo y reversión\n\n")
if input.Rollback == "" {
b.WriteString("_No se documentó un procedimiento de reversión._\n")
} else {
b.WriteString(input.Rollback + "\n")
}
if input.Ticket != "" {
b.WriteString("\n## Referencias\n\n- Ticket: `" + input.Ticket + "`\n")
}
return normalizeMarkdownBody(b.String())
}
func renderTaskBody(input TaskInput) string {
if strings.TrimSpace(input.BodyMarkdown) != "" {
return normalizeMarkdownBody(input.BodyMarkdown)
}
var b strings.Builder
b.WriteString("## Objetivo\n\n")
b.WriteString(firstNonEmpty(input.Objective, input.Description, "_No especificado._"))
b.WriteString("\n\n## Criterios de aceptación\n\n")
b.WriteString(bullets(input.Acceptance))
if input.Notes != "" {
b.WriteString("\n## Notas\n\n" + input.Notes + "\n")
}
return normalizeMarkdownBody(b.String())
}
func renderProjectBody(input ProjectInput) string {
if strings.TrimSpace(input.BodyMarkdown) != "" {
return normalizeMarkdownBody(input.BodyMarkdown)
}
var b strings.Builder
b.WriteString("## Objetivo\n\n")
b.WriteString(firstNonEmpty(input.Objective, input.Description, "_No especificado._"))
b.WriteString("\n\n## Alcance\n\n")
b.WriteString(bullets(input.Scope))
b.WriteString("\n## Fuera de alcance\n\n")
b.WriteString(bullets(input.OutOfScope))
b.WriteString("\n## Hitos\n\n")
b.WriteString(bullets(input.Milestones))
b.WriteString("\n## Criterios de éxito\n\n")
b.WriteString(bullets(input.Success))
return normalizeMarkdownBody(b.String())
}
func (a *App) createMemory(root string, identity Identity, input MemoryInput) (map[string]any, []string, error) {
input.Title = strings.TrimSpace(input.Title)
input.Description = firstNonEmpty(input.Description, input.Title)
input.Risk = strings.ToLower(firstNonEmpty(input.Risk, "low"))
validRisk := map[string]bool{"low": true, "medium": true, "high": true, "critical": true}
if input.Title == "" {
return nil, nil, NewError(ExitUsage, "missing_title", "--title o title en JSON es obligatorio", nil)
}
if !validRisk[input.Risk] {
return nil, nil, NewError(ExitUsage, "invalid_risk", "risk debe ser low, medium, high o critical", input.Risk)
}
reviewRequired := true
if input.ReviewRequired != nil {
reviewRequired = *input.ReviewRequired
}
registry, err := loadAdmins(root)
if err != nil {
return nil, nil, err
}
input.ReviewPolicy = firstNonEmpty(input.ReviewPolicy, registry.ReviewPolicy.Default, "all-active")
if input.ReviewPolicy != "all-active" && input.ReviewPolicy != "all-active-except-author" {
return nil, nil, NewError(ExitUsage, "invalid_review_policy", "review_policy debe ser all-active o all-active-except-author", input.ReviewPolicy)
}
reviewers := []string{}
if reviewRequired {
reviewers = requiredReviewers(registry, input.ReviewPolicy, identity.Admin.ID)
}
now := a.Options.Now().UTC()
id, err := generateID("MEM", now)
if err != nil {
return nil, nil, err
}
slug := slugify(id + "-" + input.Title)
body := renderMemoryBody(input)
front := strings.Builder{}
front.WriteString("+++\n")
fmt.Fprintf(&front, "title = %s\n", tomlString(input.Title))
fmt.Fprintf(&front, "description = %s\n", tomlString(input.Description))
fmt.Fprintf(&front, "date = %s\n", now.Format(time.RFC3339))
fmt.Fprintf(&front, "authors = %s\n", tomlStringArray([]string{identity.Admin.ID}))
fmt.Fprintf(&front, "slug = %s\n", tomlString(slug))
front.WriteString("template = \"memory.html\"\n\n")
front.WriteString("[taxonomies]\n")
front.WriteString("categories = [\"memorias\"]\n")
fmt.Fprintf(&front, "tags = %s\n", tomlStringArray(uniqueSorted(input.Tags)))
fmt.Fprintf(&front, "systems = %s\n\n", tomlStringArray(uniqueSorted(input.Systems)))
front.WriteString("[extra]\n")
fmt.Fprintf(&front, "id = %s\n", tomlString(id))
front.WriteString("kind = \"memory\"\n")
fmt.Fprintf(&front, "author_id = %s\n", tomlString(identity.Admin.ID))
fmt.Fprintf(&front, "author_fingerprint = %s\n", tomlString(identity.Fingerprint))
fmt.Fprintf(&front, "risk = %s\n", tomlString(input.Risk))
fmt.Fprintf(&front, "ticket = %s\n", tomlString(input.Ticket))
fmt.Fprintf(&front, "review_required = %t\n", reviewRequired)
fmt.Fprintf(&front, "review_policy = %s\n", tomlString(input.ReviewPolicy))
front.WriteString("reviewer_snapshot = true\n")
fmt.Fprintf(&front, "reviewers = %s\n", tomlStringArray(reviewers))
fmt.Fprintf(&front, "started_at = %s\n", tomlString(input.StartedAt))
fmt.Fprintf(&front, "finished_at = %s\n", tomlString(input.FinishedAt))
fmt.Fprintf(&front, "duration_minutes = %d\n", input.DurationMinutes)
fmt.Fprintf(&front, "agent = %s\n", tomlString(input.Agent))
front.WriteString("+++\n\n")
content := front.String() + body
if findings := scanSecrets(content); len(findings) > 0 {
return nil, nil, NewError(ExitValidation, "possible_secret", "la memoria parece contener secretos; redacte antes de registrarla", findings)
}
filename := now.Format("2006-01-02") + "-" + slug + ".md"
path := filepath.Join(root, "content", "memorias", filename)
if _, err := os.Stat(path); err == nil {
return nil, nil, NewError(ExitConflict, "file_exists", "ya existe el archivo de memoria", path)
}
if !a.Options.DryRun {
if err := writeFileAtomic(path, []byte(content), 0o644); err != nil {
return nil, nil, err
}
}
warnings := []string{}
if !a.Options.Commit {
warnings = append(warnings, "la atribución local usa la huella SSH, pero la prueba criptográfica exige un commit firmado; repita con --commit o firme el commit manualmente")
}
return map[string]any{
"entry_id": id, "path": filepath.ToSlash(path), "slug": slug,
"review_required": reviewRequired, "reviewers": reviewers, "content_sha256": bytesSHA256([]byte(content)),
"dry_run": a.Options.DryRun,
}, warnings, nil
}
func (a *App) createTask(root string, identity Identity, input TaskInput) (map[string]any, []string, error) {
input.Title = strings.TrimSpace(input.Title)
input.Description = firstNonEmpty(input.Description, input.Title)
input.Priority = strings.ToLower(firstNonEmpty(input.Priority, "medium"))
if input.Title == "" {
return nil, nil, NewError(ExitUsage, "missing_title", "el título de la tarea es obligatorio", nil)
}
if !map[string]bool{"low": true, "medium": true, "high": true, "urgent": true}[input.Priority] {
return nil, nil, NewError(ExitUsage, "invalid_priority", "priority debe ser low, medium, high o urgent", input.Priority)
}
if err := validateDate(input.Due); err != nil {
return nil, nil, NewError(ExitUsage, "invalid_due", err.Error(), nil)
}
if err := ensureProjectExists(root, input.ProjectID); err != nil {
return nil, nil, err
}
registry, err := loadAdmins(root)
if err != nil {
return nil, nil, err
}
for _, assignee := range input.Assignees {
admin, ok := adminByID(registry, assignee)
if !ok || !admin.Active {
return nil, nil, NewError(ExitValidation, "invalid_assignee", "la tarea contiene un responsable inexistente o inactivo", assignee)
}
}
now := a.Options.Now().UTC()
id, err := generateID("TSK", now)
if err != nil {
return nil, nil, err
}
slug := slugify(id + "-" + input.Title)
body := renderTaskBody(input)
var front strings.Builder
front.WriteString("+++\n")
fmt.Fprintf(&front, "title = %s\n", tomlString(input.Title))
fmt.Fprintf(&front, "description = %s\n", tomlString(input.Description))
fmt.Fprintf(&front, "date = %s\n", now.Format(time.RFC3339))
fmt.Fprintf(&front, "authors = %s\n", tomlStringArray([]string{identity.Admin.ID}))
fmt.Fprintf(&front, "slug = %s\n", tomlString(slug))
front.WriteString("template = \"task.html\"\n\n")
front.WriteString("[taxonomies]\n")
front.WriteString("categories = [\"tareas\"]\n")
fmt.Fprintf(&front, "tags = %s\n", tomlStringArray(uniqueSorted(input.Tags)))
fmt.Fprintf(&front, "systems = %s\n\n", tomlStringArray(uniqueSorted(input.Systems)))
front.WriteString("[extra]\n")
fmt.Fprintf(&front, "id = %s\n", tomlString(id))
front.WriteString("kind = \"task\"\nstatus = \"todo\"\n")
fmt.Fprintf(&front, "priority = %s\n", tomlString(input.Priority))
fmt.Fprintf(&front, "project_id = %s\n", tomlString(normalizeID(input.ProjectID)))
fmt.Fprintf(&front, "assignees = %s\n", tomlStringArray(uniqueSorted(input.Assignees)))
fmt.Fprintf(&front, "estimate_minutes = %d\n", input.EstimateMinutes)
fmt.Fprintf(&front, "due = %s\n", tomlString(input.Due))
fmt.Fprintf(&front, "created_by = %s\n", tomlString(identity.Admin.ID))
fmt.Fprintf(&front, "author_fingerprint = %s\n", tomlString(identity.Fingerprint))
front.WriteString("+++\n\n")
content := front.String() + body
if findings := scanSecrets(content); len(findings) > 0 {
return nil, nil, NewError(ExitValidation, "possible_secret", "la tarea parece contener secretos; redacte antes de registrarla", findings)
}
path := filepath.Join(root, "content", "tareas", now.Format("2006-01-02")+"-"+slug+".md")
if !a.Options.DryRun {
if err := writeFileAtomic(path, []byte(content), 0o644); err != nil {
return nil, nil, err
}
}
warnings := []string{}
if !a.Options.Commit {
warnings = append(warnings, "use --commit para dejar la operación en un commit SSH firmado")
}
return map[string]any{"task_id": id, "path": filepath.ToSlash(path), "slug": slug, "dry_run": a.Options.DryRun}, warnings, nil
}
func (a *App) createProject(root string, identity Identity, input ProjectInput) (map[string]any, []string, error) {
input.Title = strings.TrimSpace(input.Title)
input.Description = firstNonEmpty(input.Description, input.Objective, input.Title)
input.Status = strings.ToLower(firstNonEmpty(input.Status, "planned"))
if input.Title == "" || strings.TrimSpace(input.Objective) == "" {
return nil, nil, NewError(ExitUsage, "missing_project_fields", "title y objective son obligatorios", nil)
}
if !map[string]bool{"planned": true, "active": true, "blocked": true, "done": true, "cancelled": true, "archived": true}[input.Status] {
return nil, nil, NewError(ExitUsage, "invalid_project_status", "status debe ser planned, active, blocked, done, cancelled o archived", input.Status)
}
if err := validateDate(input.TargetDate); err != nil {
return nil, nil, NewError(ExitUsage, "invalid_target_date", err.Error(), nil)
}
registry, err := loadAdmins(root)
if err != nil {
return nil, nil, err
}
for _, owner := range input.Owners {
admin, ok := adminByID(registry, owner)
if !ok || !admin.Active {
return nil, nil, NewError(ExitValidation, "invalid_owner", "el proyecto contiene un responsable inexistente o inactivo", owner)
}
}
if len(input.Owners) == 0 {
input.Owners = []string{identity.Admin.ID}
}
now := a.Options.Now().UTC()
id, err := generateID("PRJ", now)
if err != nil {
return nil, nil, err
}
slug := slugify(id + "-" + input.Title)
body := renderProjectBody(input)
var front strings.Builder
front.WriteString("+++\n")
fmt.Fprintf(&front, "title = %s\n", tomlString(input.Title))
fmt.Fprintf(&front, "description = %s\n", tomlString(input.Description))
fmt.Fprintf(&front, "date = %s\n", now.Format(time.RFC3339))
fmt.Fprintf(&front, "authors = %s\n", tomlStringArray([]string{identity.Admin.ID}))
fmt.Fprintf(&front, "slug = %s\n", tomlString(slug))
front.WriteString("template = \"project.html\"\n\n")
front.WriteString("[taxonomies]\n")
front.WriteString("categories = [\"proyectos\"]\n")
fmt.Fprintf(&front, "tags = %s\n", tomlStringArray(uniqueSorted(input.Tags)))
fmt.Fprintf(&front, "systems = %s\n\n", tomlStringArray(uniqueSorted(input.Systems)))
front.WriteString("[extra]\n")
fmt.Fprintf(&front, "id = %s\n", tomlString(id))
front.WriteString("kind = \"project\"\n")
fmt.Fprintf(&front, "status = %s\n", tomlString(input.Status))
fmt.Fprintf(&front, "owners = %s\n", tomlStringArray(uniqueSorted(input.Owners)))
fmt.Fprintf(&front, "target_date = %s\n", tomlString(input.TargetDate))
fmt.Fprintf(&front, "created_by = %s\n", tomlString(identity.Admin.ID))
fmt.Fprintf(&front, "author_fingerprint = %s\n", tomlString(identity.Fingerprint))
front.WriteString("+++\n\n")
content := front.String() + body
if findings := scanSecrets(content); len(findings) > 0 {
return nil, nil, NewError(ExitValidation, "possible_secret", "el proyecto parece contener secretos; redacte antes de registrarlo", findings)
}
path := filepath.Join(root, "content", "proyectos", now.Format("2006-01-02")+"-"+slug+".md")
if !a.Options.DryRun {
if err := writeFileAtomic(path, []byte(content), 0o644); err != nil {
return nil, nil, err
}
}
warnings := []string{}
if !a.Options.Commit {
warnings = append(warnings, "use --commit para dejar la operación en un commit SSH firmado")
}
return map[string]any{"project_id": id, "path": filepath.ToSlash(path), "slug": slug, "dry_run": a.Options.DryRun}, warnings, nil
}
func (a *App) createContent(root string, identity Identity, input ContentInput) (map[string]any, []string, error) {
input.Section = strings.ToLower(strings.TrimSpace(input.Section))
if input.Section != "documentacion" && input.Section != "manuales" {
return nil, nil, NewError(ExitUsage, "invalid_section", "section debe ser documentacion o manuales", input.Section)
}
input.Title = strings.TrimSpace(input.Title)
input.Description = firstNonEmpty(input.Description, input.Title)
if input.Title == "" || strings.TrimSpace(input.BodyMarkdown) == "" {
return nil, nil, NewError(ExitUsage, "missing_content_fields", "title y body_markdown son obligatorios", nil)
}
now := a.Options.Now().UTC()
idPrefix := "DOC"
if input.Section == "manuales" {
idPrefix = "RUN"
}
id, err := generateID(idPrefix, now)
if err != nil {
return nil, nil, err
}
slug := slugify(id + "-" + input.Title)
var front strings.Builder
front.WriteString("+++\n")
fmt.Fprintf(&front, "title = %s\n", tomlString(input.Title))
fmt.Fprintf(&front, "description = %s\n", tomlString(input.Description))
fmt.Fprintf(&front, "date = %s\n", now.Format(time.RFC3339))
fmt.Fprintf(&front, "authors = %s\n", tomlStringArray([]string{identity.Admin.ID}))
fmt.Fprintf(&front, "slug = %s\n", tomlString(slug))
front.WriteString("template = \"page.html\"\n")
if input.Weight != 0 {
fmt.Fprintf(&front, "weight = %d\n", input.Weight)
}
front.WriteString("\n[taxonomies]\n")
fmt.Fprintf(&front, "categories = %s\n", tomlStringArray([]string{input.Section}))
fmt.Fprintf(&front, "tags = %s\n", tomlStringArray(uniqueSorted(input.Tags)))
fmt.Fprintf(&front, "systems = %s\n\n", tomlStringArray(uniqueSorted(input.Systems)))
front.WriteString("[extra]\n")
fmt.Fprintf(&front, "id = %s\n", tomlString(id))
fmt.Fprintf(&front, "kind = %s\n", tomlString(input.Section))
fmt.Fprintf(&front, "author_id = %s\n", tomlString(identity.Admin.ID))
fmt.Fprintf(&front, "author_fingerprint = %s\n", tomlString(identity.Fingerprint))
front.WriteString("+++\n\n")
content := front.String() + normalizeMarkdownBody(input.BodyMarkdown)
if findings := scanSecrets(content); len(findings) > 0 {
return nil, nil, NewError(ExitValidation, "possible_secret", "el contenido parece incluir secretos; redacte antes de registrarlo", findings)
}
path := filepath.Join(root, "content", input.Section, now.Format("2006-01-02")+"-"+slug+".md")
if !a.Options.DryRun {
if err := writeFileAtomic(path, []byte(content), 0o644); err != nil {
return nil, nil, err
}
}
warnings := []string{}
if !a.Options.Commit {
warnings = append(warnings, "use --commit para dejar la operación en un commit SSH firmado")
}
return map[string]any{"content_id": id, "section": input.Section, "path": filepath.ToSlash(path), "slug": slug, "dry_run": a.Options.DryRun}, warnings, nil
}
+114
View File
@@ -0,0 +1,114 @@
package clusterlog
import (
"errors"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
const (
reviewEventsDirName = "review-events"
taskEventsDirName = "task-events"
)
func listJSONFiles(dir string) ([]string, error) {
files := []string{}
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return files, nil
}
return nil, err
}
for _, entry := range entries {
if entry.IsDir() || strings.ToLower(filepath.Ext(entry.Name())) != ".json" {
continue
}
files = append(files, filepath.Join(dir, entry.Name()))
}
sort.Strings(files)
return files, nil
}
func reviewEventFiles(root string) ([]string, error) {
return listJSONFiles(filepath.Join(root, "data", reviewEventsDirName))
}
func taskEventFiles(root string) ([]string, error) {
return listJSONFiles(filepath.Join(root, "data", taskEventsDirName))
}
func eventFilePath(root, dirName, id string) string {
return filepath.Join(root, "data", dirName, strings.ToLower(id)+".json")
}
func writeEventFile(path string, value any) error {
if _, err := os.Stat(path); err == nil {
return NewError(ExitConflict, "event_exists", "ya existe un evento con el mismo identificador", filepath.ToSlash(path))
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
return writeJSONAtomic(path, value)
}
func writeReviewEvent(root string, event ReviewEvent) (string, error) {
path := eventFilePath(root, reviewEventsDirName, event.ID)
return path, writeEventFile(path, event)
}
func writeTaskEvents(root string, events []TaskEvent) ([]string, error) {
paths := make([]string, 0, len(events))
for _, event := range events {
path := eventFilePath(root, taskEventsDirName, event.ID)
if err := writeEventFile(path, event); err != nil {
return paths, err
}
paths = append(paths, path)
}
return paths, nil
}
func parseEventTime(value string) time.Time {
parsed, err := time.Parse(time.RFC3339Nano, value)
if err != nil {
return time.Time{}
}
return parsed
}
func sortReviewEvents(events []ReviewEvent) {
sort.SliceStable(events, func(i, j int) bool {
ti := parseEventTime(events[i].ReviewedAt)
tj := parseEventTime(events[j].ReviewedAt)
if ti.Equal(tj) {
return events[i].ID < events[j].ID
}
return ti.Before(tj)
})
}
func sortTaskEvents(events []TaskEvent) {
typeOrder := map[string]int{"start": 0, "stop": 1, "adjust": 2, "status": 3}
sort.SliceStable(events, func(i, j int) bool {
ti := parseEventTime(events[i].At)
tj := parseEventTime(events[j].At)
if ti.Equal(tj) {
oi, iOK := typeOrder[events[i].Type]
oj, jOK := typeOrder[events[j].Type]
if !iOK {
oi = 9
}
if !jOK {
oj = 9
}
if oi == oj {
return events[i].ID < events[j].ID
}
return oi < oj
}
return ti.Before(tj)
})
}
+88
View File
@@ -0,0 +1,88 @@
package clusterlog
import (
"bytes"
"fmt"
"os/exec"
"path/filepath"
"strings"
)
func gitOutput(root string, args ...string) (string, error) {
full := append([]string{"-C", root}, args...)
cmd := exec.Command("git", full...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
output, err := cmd.Output()
if err != nil {
message := strings.TrimSpace(stderr.String())
if message == "" {
message = err.Error()
}
return "", fmt.Errorf("git %s: %s", strings.Join(args, " "), message)
}
return strings.TrimSpace(string(output)), nil
}
func isGitRepo(root string) bool {
output, err := gitOutput(root, "rev-parse", "--is-inside-work-tree")
return err == nil && output == "true"
}
func (a *App) commitPaths(root string, identity Identity, message string, paths []string) (string, error) {
if !a.Options.Commit {
return "", nil
}
if !isGitRepo(root) {
return "", NewError(ExitGit, "not_git_repo", "--commit requiere que el proyecto sea un repositorio Git", root)
}
rel, err := relativePaths(root, paths)
if err != nil {
return "", err
}
addArgs := append([]string{"add", "--"}, rel...)
if _, err := gitOutput(root, addArgs...); err != nil {
return "", NewError(ExitGit, "git_add_failed", err.Error(), rel)
}
args := []string{
"-c", "user.name=" + identity.Admin.Name,
"-c", "user.email=" + identity.Admin.Email,
"-c", "gpg.format=ssh",
"-c", "user.signingkey=" + identity.SigningKey,
"commit", "-S", "-m", message, "--",
}
args = append(args, rel...)
if _, err := gitOutput(root, args...); err != nil {
return "", NewError(ExitGit, "git_commit_failed",
"no se pudo crear el commit SSH firmado", map[string]any{
"cause": err.Error(),
"signing_key": identity.SigningKey,
"hint": "compruebe OpenSSH/ssh-keygen y que la llave privada esté disponible o cargada en ssh-agent",
})
}
sha, err := gitOutput(root, "rev-parse", "HEAD")
if err != nil {
return "", err
}
return sha, nil
}
func verifyCommit(root, sha string) error {
if sha == "" {
sha = "HEAD"
}
allowed := filepath.Join(root, "config", "allowed_signers")
_, err := gitOutput(root,
"-c", "gpg.format=ssh",
"-c", "gpg.ssh.allowedSignersFile="+allowed,
"verify-commit", sha,
)
if err != nil {
return NewError(ExitValidation, "invalid_commit_signature", "el commit no tiene una firma SSH válida y autorizada", map[string]any{
"commit": sha,
"cause": err.Error(),
})
}
return nil
}
+226
View File
@@ -0,0 +1,226 @@
package clusterlog
import (
"crypto/sha256"
"encoding/base64"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
)
func loadAdmins(root string) (AdminRegistry, error) {
var registry AdminRegistry
if err := loadJSON(filepath.Join(root, "data", "admins.json"), &registry); err != nil {
return registry, err
}
if registry.Version == 0 {
registry.Version = 1
}
if registry.ReviewPolicy.Default == "" {
registry.ReviewPolicy.Default = "all-active"
registry.ReviewPolicy.IncludeAuthor = true
}
return registry, nil
}
func saveAdmins(root string, registry AdminRegistry) error {
if err := writeJSONAtomic(filepath.Join(root, "data", "admins.json"), registry); err != nil {
return err
}
return writeAllowedSigners(root, registry)
}
func writeAllowedSigners(root string, registry AdminRegistry) error {
lines := []string{
"# Archivo generado por clusterlog. Contiene únicamente llaves públicas.",
"# principal namespaces=\"git\" tipo base64",
}
seen := map[string]bool{}
for _, admin := range registry.Admins {
if !admin.Active {
continue
}
for _, key := range admin.SSHKeys {
fields := strings.Fields(key.PublicKey)
if len(fields) < 2 {
continue
}
line := fmt.Sprintf("%s namespaces=\"git\" %s %s", admin.Email, fields[0], fields[1])
if !seen[line] {
lines = append(lines, line)
seen[line] = true
}
}
}
sort.Strings(lines[2:])
return writeFileAtomic(filepath.Join(root, "config", "allowed_signers"), []byte(strings.Join(lines, "\n")+"\n"), 0o644)
}
func parsePublicKeyLine(line string) (canonical, fingerprint string, err error) {
fields := strings.Fields(strings.TrimSpace(line))
if len(fields) < 2 {
return "", "", fmt.Errorf("la llave pública debe tener formato OpenSSH: tipo base64 [comentario]")
}
decoded, err := base64.StdEncoding.DecodeString(fields[1])
if err != nil {
return "", "", fmt.Errorf("base64 inválido en llave pública: %w", err)
}
sum := sha256.Sum256(decoded)
fingerprint = "SHA256:" + base64.RawStdEncoding.EncodeToString(sum[:])
canonical = fields[0] + " " + fields[1]
if len(fields) > 2 {
canonical += " " + strings.Join(fields[2:], " ")
}
return canonical, fingerprint, nil
}
func readPublicKey(path string) (canonical, fingerprint, publicPath, signingPath string, err error) {
path = strings.TrimSpace(path)
if path == "" {
return "", "", "", "", fmt.Errorf("ruta de llave vacía")
}
expanded, err := expandPath(path)
if err != nil {
return "", "", "", "", err
}
original := expanded
// user.signingkey suele apuntar a la llave privada; para identificar al
// administrador siempre leemos la contraparte pública cuando existe.
if !strings.HasSuffix(expanded, ".pub") {
if _, pubErr := os.Stat(expanded + ".pub"); pubErr == nil {
expanded += ".pub"
}
}
info, statErr := os.Stat(expanded)
if statErr != nil {
return "", "", "", "", statErr
}
if info.IsDir() {
return "", "", "", "", fmt.Errorf("%s es un directorio", expanded)
}
data, err := os.ReadFile(expanded)
if err != nil {
return "", "", "", "", err
}
canonical, fingerprint, err = parsePublicKeyLine(string(data))
if err != nil {
return "", "", "", "", err
}
publicPath = expanded
signingPath = expanded
if original != expanded {
signingPath = original
} else if strings.HasSuffix(expanded, ".pub") {
privateCandidate := strings.TrimSuffix(expanded, ".pub")
if _, err := os.Stat(privateCandidate); err == nil {
signingPath = privateCandidate
}
}
return canonical, fingerprint, publicPath, signingPath, nil
}
func expandPath(path string) (string, error) {
if path == "~" || strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
if path == "~" {
path = home
} else {
path = filepath.Join(home, strings.TrimPrefix(path, "~/"))
}
}
return filepath.Abs(path)
}
func candidateKeyPaths(explicit string) []string {
candidates := []string{}
if explicit != "" {
candidates = append(candidates, explicit)
}
if env := os.Getenv("CLUSTERLOG_SSH_KEY"); env != "" {
candidates = append(candidates, env)
}
if output, err := exec.Command("git", "config", "--get", "user.signingkey").Output(); err == nil {
value := strings.TrimSpace(string(output))
if strings.HasPrefix(value, "key::") {
// Una llave inline se identifica, pero no sirve como ruta para firmar.
} else if value != "" {
candidates = append(candidates, value)
}
}
if home, err := os.UserHomeDir(); err == nil {
for _, name := range []string{"id_ed25519.pub", "id_ecdsa.pub", "id_rsa.pub"} {
candidates = append(candidates, filepath.Join(home, ".ssh", name))
}
}
seen := map[string]bool{}
out := []string{}
for _, candidate := range candidates {
if candidate == "" || seen[candidate] {
continue
}
seen[candidate] = true
out = append(out, candidate)
}
return out
}
func (a *App) ResolveIdentity(root string) (Identity, error) {
registry, err := loadAdmins(root)
if err != nil {
return Identity{}, err
}
attempted := []string{}
for _, path := range candidateKeyPaths(a.Options.SSHKey) {
canonical, fingerprint, publicPath, signingPath, err := readPublicKey(path)
if err != nil {
attempted = append(attempted, fmt.Sprintf("%s: %v", path, err))
continue
}
for _, admin := range registry.Admins {
if !admin.Active {
continue
}
for _, key := range admin.SSHKeys {
if key.Fingerprint == fingerprint {
return Identity{
Admin: admin, Fingerprint: fingerprint, PublicKey: canonical,
PublicPath: publicPath, SigningKey: signingPath,
}, nil
}
}
}
attempted = append(attempted, fmt.Sprintf("%s: %s no está registrada", path, fingerprint))
}
return Identity{}, NewError(ExitIdentity, "identity_not_found",
"ninguna llave SSH local coincide con un administrador activo", map[string]any{
"attempted": attempted,
"hint": "use --ssh-key ~/.ssh/id_ed25519.pub o CLUSTERLOG_SSH_KEY",
})
}
func adminByID(registry AdminRegistry, id string) (Admin, bool) {
id = normalizeID(id)
for _, admin := range registry.Admins {
if normalizeID(admin.ID) == id {
return admin, true
}
}
return Admin{}, false
}
func activeAdminIDs(registry AdminRegistry) []string {
ids := []string{}
for _, admin := range registry.Admins {
if admin.Active {
ids = append(ids, admin.ID)
}
}
sort.Strings(ids)
return ids
}
+209
View File
@@ -0,0 +1,209 @@
package clusterlog
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)
func parseMarkdown(path string) (PageMeta, error) {
data, err := os.ReadFile(path)
if err != nil {
return PageMeta{}, err
}
text := strings.ReplaceAll(string(data), "\r\n", "\n")
if !strings.HasPrefix(text, "+++\n") {
return PageMeta{}, fmt.Errorf("%s no tiene front matter TOML delimitado por +++", path)
}
rest := strings.TrimPrefix(text, "+++\n")
idx := strings.Index(rest, "\n+++\n")
if idx < 0 {
if strings.HasSuffix(rest, "\n+++") {
idx = len(rest) - len("\n+++")
} else {
return PageMeta{}, fmt.Errorf("%s tiene front matter sin cierre +++", path)
}
}
rawFront := rest[:idx]
bodyStart := idx + len("\n+++\n")
body := ""
if bodyStart <= len(rest) {
body = rest[bodyStart:]
}
meta := PageMeta{
Path: path,
Body: body,
Root: map[string]any{},
Taxonomies: map[string][]string{},
Extra: map[string]any{},
RawFront: rawFront,
ContentHash: bytesSHA256(data),
}
section := "root"
scanner := bufio.NewScanner(strings.NewReader(rawFront))
scanner.Buffer(make([]byte, 1024), 1024*1024)
lineNo := 0
for scanner.Scan() {
lineNo++
line := strings.TrimSpace(stripTOMLComment(scanner.Text()))
if line == "" {
continue
}
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
section = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(line, "["), "]"))
continue
}
key, raw, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
raw = strings.TrimSpace(raw)
value, err := parseTOMLValue(raw)
if err != nil {
return PageMeta{}, fmt.Errorf("%s:%d: %w", path, lineNo, err)
}
switch section {
case "root":
meta.Root[key] = value
case "taxonomies":
values, ok := value.([]string)
if ok {
meta.Taxonomies[key] = values
}
case "extra":
meta.Extra[key] = value
default:
// El parser intencionalmente ignora secciones no usadas por clusterlog.
}
}
if err := scanner.Err(); err != nil {
return PageMeta{}, err
}
return meta, nil
}
func stripTOMLComment(line string) string {
inString := false
escaped := false
for i, r := range line {
if escaped {
escaped = false
continue
}
if r == '\\' && inString {
escaped = true
continue
}
if r == '"' {
inString = !inString
continue
}
if r == '#' && !inString {
return line[:i]
}
}
return line
}
func parseTOMLValue(raw string) (any, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", nil
}
if strings.HasPrefix(raw, "\"") {
value, err := strconv.Unquote(raw)
if err != nil {
return nil, fmt.Errorf("cadena TOML inválida: %w", err)
}
return value, nil
}
if raw == "true" {
return true, nil
}
if raw == "false" {
return false, nil
}
if strings.HasPrefix(raw, "[") && strings.HasSuffix(raw, "]") {
return parseTOMLStringArray(raw)
}
if n, err := strconv.Atoi(raw); err == nil {
return n, nil
}
// Fechas TOML y otros escalares simples se conservan como texto.
return raw, nil
}
func parseTOMLStringArray(raw string) ([]string, error) {
content := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(raw, "["), "]"))
if content == "" {
return []string{}, nil
}
var values []string
var current strings.Builder
inString := false
escaped := false
flush := func() error {
item := strings.TrimSpace(current.String())
current.Reset()
if item == "" {
return nil
}
value, err := strconv.Unquote(item)
if err != nil {
return fmt.Errorf("arreglo TOML inválido: %w", err)
}
values = append(values, value)
return nil
}
for _, r := range content {
if escaped {
current.WriteRune(r)
escaped = false
continue
}
if r == '\\' && inString {
current.WriteRune(r)
escaped = true
continue
}
if r == '"' {
inString = !inString
current.WriteRune(r)
continue
}
if r == ',' && !inString {
if err := flush(); err != nil {
return nil, err
}
continue
}
current.WriteRune(r)
}
if inString {
return nil, fmt.Errorf("arreglo TOML con cadena sin cerrar")
}
if err := flush(); err != nil {
return nil, err
}
return values, nil
}
func pageSlug(meta PageMeta) string {
if value := meta.String("slug"); value != "" {
return value
}
return slugify(strings.TrimSuffix(filepathBase(meta.Path), ".md"))
}
func filepathBase(path string) string {
path = strings.ReplaceAll(path, "\\", "/")
if idx := strings.LastIndex(path, "/"); idx >= 0 {
return path[idx+1:]
}
return path
}
+57
View File
@@ -0,0 +1,57 @@
package clusterlog
import (
"path/filepath"
"testing"
)
func TestParseMarkdownFrontMatter(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "page.md")
mustWriteTestFile(t, path, `+++
title = "Prueba # literal"
description = "Página de prueba"
date = 2026-08-12T08:30:00Z
authors = ["ana", "bob"]
slug = "pagina-prueba"
[taxonomies]
tags = ["red", "cambio"]
systems = ["spine-01"]
[extra]
id = "MEM-TEST-001"
review_required = true
duration_minutes = 45
reviewers = ["ana", "bob"] # comentario
+++
## Cuerpo
Contenido.
`)
meta, err := parseMarkdown(path)
if err != nil {
t.Fatal(err)
}
if meta.String("title") != "Prueba # literal" {
t.Fatalf("unexpected title: %q", meta.String("title"))
}
if meta.ExtraString("id") != "MEM-TEST-001" || !meta.ExtraBool("review_required") || meta.ExtraInt("duration_minutes") != 45 {
t.Fatalf("unexpected extra metadata: %#v", meta.Extra)
}
if got := meta.Taxonomies["tags"]; len(got) != 2 || got[0] != "red" || got[1] != "cambio" {
t.Fatalf("unexpected tags: %#v", got)
}
if meta.ContentHash == "" || meta.Body == "" {
t.Fatal("expected content hash and body")
}
}
func TestParseMarkdownRejectsUnclosedFrontMatter(t *testing.T) {
path := filepath.Join(t.TempDir(), "bad.md")
mustWriteTestFile(t, path, "+++\ntitle = \"sin cierre\"\n")
if _, err := parseMarkdown(path); err == nil {
t.Fatal("expected invalid front matter to fail")
}
}
+164
View File
@@ -0,0 +1,164 @@
package clusterlog
import (
"encoding/json"
"strings"
)
type inputSchema struct {
Schema string `json:"$schema"`
ID string `json:"$id"`
Title string `json:"title"`
Description string `json:"description"`
Type string `json:"type"`
Required []string `json:"required,omitempty"`
Properties map[string]map[string]any `json:"properties"`
Additional bool `json:"additionalProperties"`
Examples []any `json:"examples,omitempty"`
}
func property(kind, description string) map[string]any {
return map[string]any{"type": kind, "description": description}
}
func stringArray(description string) map[string]any {
return map[string]any{
"type": "array", "description": description,
"items": map[string]any{"type": "string"}, "uniqueItems": true,
}
}
func enumProperty(description string, values ...string) map[string]any {
items := make([]any, len(values))
for i, value := range values {
items[i] = value
}
return map[string]any{"type": "string", "description": description, "enum": items}
}
func schemaFor(name string) (inputSchema, bool) {
base := inputSchema{
Schema: "https://json-schema.org/draft/2020-12/schema",
ID: "https://clusterlog.local/schemas/" + name + ".json",
Type: "object", Additional: false,
}
switch name {
case "memory":
base.Title = "Entrada de memoria operativa"
base.Description = "Contrato de entrada para clusterlog memory create --from-json. Nunca incluya secretos sin redactar."
base.Required = []string{"title"}
base.Properties = map[string]map[string]any{
"title": property("string", "Título breve y accionable"),
"description": property("string", "Resumen para listados"),
"tags": stringArray("Etiquetas temáticas"),
"systems": stringArray("Sistemas o componentes afectados"),
"risk": enumProperty("Nivel de riesgo", "low", "medium", "high", "critical"),
"ticket": property("string", "Ticket, incidente o cambio relacionado"),
"review_required": property("boolean", "Incluye la entrada en la cola de revisión"),
"review_policy": enumProperty("Quién debe revisar", "all-active", "all-active-except-author"),
"body_markdown": property("string", "Cuerpo Markdown completo; sustituye las secciones estructuradas"),
"context": property("string", "Contexto de la intervención"),
"changes": stringArray("Cambios realizados"),
"commands": stringArray("Comandos relevantes con secretos redactados"),
"validation": stringArray("Comprobaciones ejecutadas"),
"rollback": property("string", "Procedimiento de reversión"),
"started_at": property("string", "Inicio RFC3339"),
"finished_at": property("string", "Fin RFC3339"),
"duration_minutes": property("integer", "Duración total en minutos"),
"agent": property("string", "Agente que preparó el borrador, por ejemplo claude-code"),
}
base.Examples = []any{MemoryInput{
Title: "Actualizar firmware del switch spine-01", Description: "Cambio controlado y validación de enlaces",
Tags: []string{"red", "firmware"}, Systems: []string{"spine-01"}, Risk: "high", Ticket: "CHG-1042",
Changes: []string{"Respaldé la configuración", "Instalé la versión aprobada"},
Commands: []string{"show version", "show interfaces status"},
Validation: []string{"Todos los uplinks quedaron up", "Sin errores nuevos en logs"},
Rollback: "Arrancar la imagen anterior y restaurar la configuración respaldada.", Agent: "claude-code",
}}
case "task":
base.Title = "Tarea compartida"
base.Description = "Contrato de entrada para clusterlog task create --from-json."
base.Required = []string{"title"}
base.Properties = map[string]map[string]any{
"title": property("string", "Título de la tarea"),
"description": property("string", "Resumen"),
"project_id": property("string", "Proyecto relacionado, si existe"),
"priority": enumProperty("Prioridad", "low", "medium", "high", "urgent"),
"assignees": stringArray("IDs de administradores responsables"),
"estimate_minutes": property("integer", "Estimación en minutos"),
"due": property("string", "Fecha límite YYYY-MM-DD"),
"tags": stringArray("Etiquetas"),
"systems": stringArray("Sistemas afectados"),
"body_markdown": property("string", "Cuerpo Markdown completo"),
"objective": property("string", "Resultado esperado"),
"acceptance_criteria": stringArray("Criterios verificables"),
"notes": property("string", "Notas de ejecución"),
}
base.Examples = []any{TaskInput{Title: "Probar restauración de etcd", Priority: "high", EstimateMinutes: 120, Due: "2026-09-15", Objective: "Validar el RTO del plano de control", Acceptance: []string{"Restauración documentada", "RTO menor a 60 minutos"}}}
case "project":
base.Title = "Proyecto de operación"
base.Description = "Contrato de entrada para clusterlog project create --from-json."
base.Required = []string{"title", "objective"}
base.Properties = map[string]map[string]any{
"title": property("string", "Nombre del proyecto"),
"description": property("string", "Resumen ejecutivo"),
"objective": property("string", "Objetivo medible"),
"owners": stringArray("IDs de administradores responsables"),
"target_date": property("string", "Fecha objetivo YYYY-MM-DD"),
"status": enumProperty("Estado", "planned", "active", "blocked", "done", "cancelled", "archived"),
"tags": stringArray("Etiquetas"),
"systems": stringArray("Sistemas incluidos"),
"body_markdown": property("string", "Cuerpo Markdown completo"),
"scope": stringArray("Elementos dentro del alcance"),
"out_of_scope": stringArray("Elementos fuera del alcance"),
"milestones": stringArray("Hitos"),
"success_criteria": stringArray("Criterios de éxito"),
}
base.Examples = []any{ProjectInput{Title: "Estandarizar observabilidad", Objective: "Unificar métricas, logs y alertas del clúster", Status: "planned", Scope: []string{"Inventario de alertas", "Dashboards base"}, Success: []string{"100% de nodos reportando métricas"}}}
case "content":
base.Title = "Documento o manual"
base.Description = "Contrato de entrada para clusterlog content create --from-json."
base.Required = []string{"section", "title", "body_markdown"}
base.Properties = map[string]map[string]any{
"section": enumProperty("Sección principal", "documentacion", "manuales"),
"title": property("string", "Título"),
"description": property("string", "Resumen"),
"tags": stringArray("Etiquetas"),
"systems": stringArray("Sistemas relacionados"),
"body_markdown": property("string", "Contenido Markdown"),
"weight": property("integer", "Orden opcional dentro de la sección"),
}
base.Examples = []any{ContentInput{Section: "manuales", Title: "Reemplazo seguro de un nodo de cómputo", Tags: []string{"runbook"}, BodyMarkdown: "## Prerrequisitos\n\n...\n"}}
default:
return inputSchema{}, false
}
return base, true
}
func (a *App) runSchema(args []string) error {
if len(args) == 0 {
return a.printResult("schema", map[string]any{
"available": []string{"memory", "task", "project", "content"},
"usage": a.Options.Executable + " --json schema memory",
"stdin_example": "cat input.json | " + a.Options.Executable + " --json --commit memory create --from-json -",
})
}
if len(args) != 1 {
return NewError(ExitUsage, "schema_usage", "uso: schema [memory|task|project|content]", args)
}
name := strings.ToLower(args[0])
schema, ok := schemaFor(name)
if !ok {
return NewError(ExitUsage, "unknown_schema", "esquema desconocido", name)
}
// Convertir a un mapa preserva el documento JSON Schema como dato del sobre de salida.
data, err := json.Marshal(schema)
if err != nil {
return err
}
var document map[string]any
if err := json.Unmarshal(data, &document); err != nil {
return err
}
return a.printResult("schema."+name, document)
}
+539
View File
@@ -0,0 +1,539 @@
package clusterlog
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
const siteStateVersion = 2
func loadReviewLog(root string) (ReviewLog, error) {
log := ReviewLog{Version: 1, Events: []ReviewEvent{}}
files, err := reviewEventFiles(root)
if err != nil {
return log, err
}
for _, path := range files {
var event ReviewEvent
if err := loadJSON(path, &event); err != nil {
return log, err
}
log.Events = append(log.Events, event)
}
sortReviewEvents(log.Events)
return log, nil
}
func loadTaskLog(root string) (TaskLog, error) {
log := TaskLog{Version: 1, Events: []TaskEvent{}}
files, err := taskEventFiles(root)
if err != nil {
return log, err
}
for _, path := range files {
var event TaskEvent
if err := loadJSON(path, &event); err != nil {
return log, err
}
log.Events = append(log.Events, event)
}
sortTaskEvents(log.Events)
return log, nil
}
func requiredReviewers(registry AdminRegistry, policy, authorID string) []string {
policy = firstNonEmpty(policy, registry.ReviewPolicy.Default)
required := activeAdminIDs(registry)
excludeAuthor := policy == "all-active-except-author" ||
(policy == "all-active" && !registry.ReviewPolicy.IncludeAuthor)
if !excludeAuthor || authorID == "" {
return required
}
filtered := make([]string, 0, len(required))
for _, id := range required {
if id != authorID {
filtered = append(filtered, id)
}
}
return filtered
}
func (a *App) BuildState(root string) (SiteState, error) {
registry, err := loadAdmins(root)
if err != nil {
return SiteState{}, err
}
reviews, err := loadReviewLog(root)
if err != nil {
return SiteState{}, err
}
tasksLog, err := loadTaskLog(root)
if err != nil {
return SiteState{}, err
}
memoryFiles, err := listMarkdownFiles(root, "memorias")
if err != nil {
return SiteState{}, err
}
taskFiles, err := listMarkdownFiles(root, "tareas")
if err != nil {
return SiteState{}, err
}
projectFiles, err := listMarkdownFiles(root, "proyectos")
if err != nil {
return SiteState{}, err
}
reviewFiles, err := reviewEventFiles(root)
if err != nil {
return SiteState{}, err
}
taskEventPaths, err := taskEventFiles(root)
if err != nil {
return SiteState{}, err
}
sourcePaths := []string{filepath.Join(root, "data", "admins.json")}
sourcePaths = append(sourcePaths, reviewFiles...)
sourcePaths = append(sourcePaths, taskEventPaths...)
sourcePaths = append(sourcePaths, memoryFiles...)
sourcePaths = append(sourcePaths, taskFiles...)
sourcePaths = append(sourcePaths, projectFiles...)
digest, err := sourceDigest(sourcePaths)
if err != nil {
return SiteState{}, err
}
memories := make([]MemoryRecord, 0, len(memoryFiles))
for _, path := range memoryFiles {
meta, err := parseMarkdown(path)
if err != nil {
return SiteState{}, err
}
id := normalizeID(meta.ExtraString("id"))
if id == "" {
continue
}
rel, _ := filepath.Rel(root, path)
record := MemoryRecord{
EntryID: id,
Title: meta.String("title"),
Description: meta.String("description"),
Date: meta.String("date"),
AuthorID: meta.ExtraString("author_id"),
AuthorFingerprint: meta.ExtraString("author_fingerprint"),
Risk: firstNonEmpty(meta.ExtraString("risk"), "low"),
Ticket: meta.ExtraString("ticket"),
ReviewRequired: meta.ExtraBool("review_required"),
ReviewPolicy: meta.ExtraString("review_policy"),
ReviewerSnapshot: meta.ExtraBool("reviewer_snapshot"),
Reviewers: uniqueSorted(meta.ExtraStrings("reviewers")),
Tags: uniqueSorted(meta.Taxonomies["tags"]),
Systems: uniqueSorted(meta.Taxonomies["systems"]),
Slug: pageSlug(meta),
SourcePath: filepath.ToSlash(rel),
ContentSHA256: meta.ContentHash,
}
record.Permalink = "/memorias/" + record.Slug + "/"
if record.ReviewPolicy == "" {
record.ReviewPolicy = registry.ReviewPolicy.Default
}
memories = append(memories, record)
}
sort.Slice(memories, func(i, j int) bool {
if memories[i].Date == memories[j].Date {
return memories[i].EntryID > memories[j].EntryID
}
return memories[i].Date > memories[j].Date
})
activeIDs := activeAdminIDs(registry)
queue := []ReviewQueueItem{}
for _, memory := range memories {
if !memory.ReviewRequired {
continue
}
var required []string
if memory.ReviewerSnapshot {
required = append([]string(nil), memory.Reviewers...)
} else {
required = requiredReviewers(registry, memory.ReviewPolicy, memory.AuthorID)
}
reviewedSet := map[string]bool{}
for _, event := range reviews.Events {
if normalizeID(event.EntryID) != memory.EntryID || event.ContentSHA256 != memory.ContentSHA256 {
continue
}
if contains(required, event.AdminID) {
reviewedSet[event.AdminID] = true
}
}
reviewed := []string{}
missing := []string{}
for _, id := range required {
if reviewedSet[id] {
reviewed = append(reviewed, id)
} else {
missing = append(missing, id)
}
}
item := ReviewQueueItem{
EntryID: memory.EntryID, Title: memory.Title, Description: memory.Description,
Date: memory.Date, AuthorID: memory.AuthorID, Risk: memory.Risk,
Tags: memory.Tags, Systems: memory.Systems, Permalink: memory.Permalink,
ContentSHA256: memory.ContentSHA256, ReviewedBy: reviewed,
MissingReviewers: missing, RequiredCount: len(required), ReviewedCount: len(reviewed),
}
if !memory.ReviewerSnapshot && len(activeIDs) == 0 {
item.ConfigurationError = "No hay administradores activos; registre al menos uno antes de cerrar revisiones."
}
if item.ConfigurationError != "" || len(missing) > 0 {
queue = append(queue, item)
}
}
taskStates := make([]TaskState, 0, len(taskFiles))
taskIndex := map[string]int{}
for _, path := range taskFiles {
meta, err := parseMarkdown(path)
if err != nil {
return SiteState{}, err
}
id := normalizeID(meta.ExtraString("id"))
if id == "" {
continue
}
rel, _ := filepath.Rel(root, path)
state := TaskState{
TaskID: id,
RunningBy: []string{},
Title: meta.String("title"),
Description: meta.String("description"),
Status: firstNonEmpty(meta.ExtraString("status"), "todo"),
Priority: firstNonEmpty(meta.ExtraString("priority"), "medium"),
ProjectID: normalizeID(meta.ExtraString("project_id")),
Assignees: uniqueSorted(meta.ExtraStrings("assignees")),
EstimateMinutes: meta.ExtraInt("estimate_minutes"),
Due: meta.ExtraString("due"),
Slug: pageSlug(meta),
SourcePath: filepath.ToSlash(rel),
}
state.Permalink = "/tareas/" + state.Slug + "/"
taskIndex[id] = len(taskStates)
taskStates = append(taskStates, state)
}
running := map[string]map[string]bool{}
for _, event := range tasksLog.Events {
id := normalizeID(event.TaskID)
idx, ok := taskIndex[id]
if !ok {
continue
}
state := &taskStates[idx]
actor := event.AdminID
switch event.Type {
case "start":
if running[id] == nil {
running[id] = map[string]bool{}
}
running[id][actor] = true
if state.Status == "todo" || state.Status == "backlog" {
state.Status = "in_progress"
}
case "stop":
state.ActualMinutes += event.Minutes
if running[id] != nil {
delete(running[id], actor)
}
case "adjust":
state.ActualMinutes += event.Minutes
case "status":
if event.Status != "" {
state.Status = event.Status
}
}
}
for id, actors := range running {
idx, ok := taskIndex[id]
if !ok {
continue
}
for actor, isRunning := range actors {
if isRunning {
taskStates[idx].RunningBy = append(taskStates[idx].RunningBy, actor)
}
}
sort.Strings(taskStates[idx].RunningBy)
}
sort.Slice(taskStates, func(i, j int) bool {
order := map[string]int{"in_progress": 0, "blocked": 1, "todo": 2, "backlog": 3, "done": 4, "cancelled": 5}
oi, ok := order[taskStates[i].Status]
if !ok {
oi = 9
}
oj, ok := order[taskStates[j].Status]
if !ok {
oj = 9
}
if oi == oj {
if taskStates[i].Due == taskStates[j].Due {
return taskStates[i].TaskID < taskStates[j].TaskID
}
if taskStates[i].Due == "" {
return false
}
if taskStates[j].Due == "" {
return true
}
return taskStates[i].Due < taskStates[j].Due
}
return oi < oj
})
projects := make([]ProjectState, 0, len(projectFiles))
for _, path := range projectFiles {
meta, err := parseMarkdown(path)
if err != nil {
return SiteState{}, err
}
id := normalizeID(meta.ExtraString("id"))
if id == "" {
continue
}
rel, _ := filepath.Rel(root, path)
project := ProjectState{
ProjectID: id,
Title: meta.String("title"),
Description: meta.String("description"),
Status: firstNonEmpty(meta.ExtraString("status"), "planned"),
Owners: uniqueSorted(meta.ExtraStrings("owners")),
TargetDate: meta.ExtraString("target_date"),
Slug: pageSlug(meta),
SourcePath: filepath.ToSlash(rel),
}
project.Permalink = "/proyectos/" + project.Slug + "/"
projects = append(projects, project)
}
sort.Slice(projects, func(i, j int) bool {
return projects[i].ProjectID < projects[j].ProjectID
})
openTasks := 0
for _, task := range taskStates {
if task.Status != "done" && task.Status != "cancelled" {
openTasks++
}
}
activeProjects := 0
for _, project := range projects {
switch project.Status {
case "done", "completed", "cancelled", "archived":
default:
activeProjects++
}
}
recent := memories
if len(recent) > 30 {
recent = recent[:30]
}
return SiteState{
Version: siteStateVersion,
SourceSHA256: digest,
Stats: SiteStats{
ActiveAdmins: len(activeIDs), PendingReviews: len(queue),
OpenTasks: openTasks, ActiveProjects: activeProjects,
},
ReviewQueue: queue,
RecentMemories: recent,
Tasks: taskStates,
Projects: projects,
}, nil
}
func (a *App) Sync(root string, check bool) (SiteState, bool, string, error) {
state, err := a.BuildState(root)
if err != nil {
return SiteState{}, false, "", err
}
path := filepath.Join(root, "data", "generated", "site_state.json")
var old SiteState
oldErr := loadJSON(path, &old)
upToDate := oldErr == nil && old.Version == state.Version && old.SourceSHA256 == state.SourceSHA256
if check {
if !upToDate {
return state, false, path, NewError(ExitValidation, "generated_state_stale",
"data/generated/site_state.json no corresponde a las fuentes actuales", map[string]any{
"expected_source_sha256": state.SourceSHA256,
"actual_source_sha256": old.SourceSHA256,
"hint": "ejecute clusterlog sync",
})
}
return old, false, path, nil
}
if upToDate {
return old, false, path, nil
}
state.GeneratedAt = a.Options.Now().UTC().Format("2006-01-02T15:04:05Z")
if a.Options.DryRun {
return state, true, path, nil
}
if err := writeJSONAtomic(path, state); err != nil {
return SiteState{}, false, path, err
}
return state, true, path, nil
}
func findMemory(root, id string) (MemoryRecord, PageMeta, error) {
id = normalizeID(id)
files, err := listMarkdownFiles(root, "memorias")
if err != nil {
return MemoryRecord{}, PageMeta{}, err
}
for _, path := range files {
meta, err := parseMarkdown(path)
if err != nil {
return MemoryRecord{}, PageMeta{}, err
}
if normalizeID(meta.ExtraString("id")) == id {
rel, _ := filepath.Rel(root, path)
record := MemoryRecord{
EntryID: id,
Title: meta.String("title"), Description: meta.String("description"),
Date: meta.String("date"), AuthorID: meta.ExtraString("author_id"),
AuthorFingerprint: meta.ExtraString("author_fingerprint"), Risk: meta.ExtraString("risk"),
ReviewRequired: meta.ExtraBool("review_required"), ReviewPolicy: meta.ExtraString("review_policy"),
ReviewerSnapshot: meta.ExtraBool("reviewer_snapshot"), Reviewers: uniqueSorted(meta.ExtraStrings("reviewers")),
Tags: meta.Taxonomies["tags"], Systems: meta.Taxonomies["systems"],
Slug: pageSlug(meta), SourcePath: filepath.ToSlash(rel), ContentSHA256: meta.ContentHash,
}
record.Permalink = "/memorias/" + record.Slug + "/"
return record, meta, nil
}
}
return MemoryRecord{}, PageMeta{}, NewError(ExitUsage, "memory_not_found", "no existe la memoria indicada", id)
}
func findTask(root, id string) (TaskState, PageMeta, error) {
id = normalizeID(id)
files, err := listMarkdownFiles(root, "tareas")
if err != nil {
return TaskState{}, PageMeta{}, err
}
for _, path := range files {
meta, err := parseMarkdown(path)
if err != nil {
return TaskState{}, PageMeta{}, err
}
if normalizeID(meta.ExtraString("id")) == id {
rel, _ := filepath.Rel(root, path)
state := TaskState{
TaskID: id, Title: meta.String("title"), Description: meta.String("description"),
Status: firstNonEmpty(meta.ExtraString("status"), "todo"), Priority: meta.ExtraString("priority"),
ProjectID: normalizeID(meta.ExtraString("project_id")), Assignees: meta.ExtraStrings("assignees"),
EstimateMinutes: meta.ExtraInt("estimate_minutes"), Due: meta.ExtraString("due"),
Slug: pageSlug(meta), SourcePath: filepath.ToSlash(rel),
}
state.Permalink = "/tareas/" + state.Slug + "/"
return state, meta, nil
}
}
return TaskState{}, PageMeta{}, NewError(ExitUsage, "task_not_found", "no existe la tarea indicada", id)
}
func taskActiveStart(log TaskLog, adminID string) (TaskEvent, bool) {
active := map[string]TaskEvent{}
for _, event := range log.Events {
if event.AdminID != adminID {
continue
}
id := normalizeID(event.TaskID)
switch event.Type {
case "start":
active[id] = event
case "stop":
delete(active, id)
}
}
if len(active) == 0 {
return TaskEvent{}, false
}
keys := make([]string, 0, len(active))
for id := range active {
keys = append(keys, id)
}
sort.Strings(keys)
return active[keys[0]], true
}
func taskIsRunning(log TaskLog, taskID, adminID string) (TaskEvent, bool) {
taskID = normalizeID(taskID)
var current TaskEvent
running := false
for _, event := range log.Events {
if normalizeID(event.TaskID) != taskID || event.AdminID != adminID {
continue
}
switch event.Type {
case "start":
current = event
running = true
case "stop":
running = false
}
}
return current, running
}
func ensureProjectExists(root, id string) error {
if strings.TrimSpace(id) == "" {
return nil
}
id = normalizeID(id)
files, err := listMarkdownFiles(root, "proyectos")
if err != nil {
return err
}
for _, path := range files {
meta, err := parseMarkdown(path)
if err != nil {
return err
}
if normalizeID(meta.ExtraString("id")) == id {
return nil
}
}
return NewError(ExitValidation, "project_not_found", "la tarea referencia un proyecto inexistente", id)
}
func generatedStateExists(root string) bool {
_, err := os.Stat(filepath.Join(root, "data", "generated", "site_state.json"))
return err == nil
}
func statusIsClosed(status string) bool {
switch strings.ToLower(status) {
case "done", "completed", "cancelled", "archived":
return true
default:
return false
}
}
func pageIDDuplicates(records []string) []string {
counts := map[string]int{}
for _, id := range records {
counts[id]++
}
out := []string{}
for id, count := range counts {
if id != "" && count > 1 {
out = append(out, fmt.Sprintf("%s (%d)", id, count))
}
}
sort.Strings(out)
return out
}
+193
View File
@@ -0,0 +1,193 @@
package clusterlog
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestReviewQueueUsesContentHashAndReviewerSnapshot(t *testing.T) {
root := testProjectRoot(t)
ana := testAdmin(t, "ana", 1, "owner", "admin")
bob := testAdmin(t, "bob", 2, "admin")
registry := AdminRegistry{
Version: 1,
ReviewPolicy: ReviewPolicy{Default: "all-active", IncludeAuthor: true},
Admins: []Admin{ana, bob},
}
if err := saveAdmins(root, registry); err != nil {
t.Fatal(err)
}
memoryPath := filepath.Join(root, "content", "memorias", "memory.md")
mustWriteTestFile(t, memoryPath, `+++
title = "Cambio de red"
description = "Ajuste controlado"
date = 2026-08-12T10:00:00Z
authors = ["ana"]
slug = "cambio-red"
[taxonomies]
tags = ["red"]
systems = ["spine-01"]
[extra]
id = "MEM-TEST-001"
author_id = "ana"
author_fingerprint = "`+ana.SSHKeys[0].Fingerprint+`"
risk = "high"
review_required = true
review_policy = "all-active"
reviewer_snapshot = true
reviewers = ["ana", "bob"]
+++
## Cambio
Versión uno.
`)
app := New(Options{Root: root, Now: func() time.Time { return time.Date(2026, 8, 12, 12, 0, 0, 0, time.UTC) }})
state, err := app.BuildState(root)
if err != nil {
t.Fatal(err)
}
if len(state.ReviewQueue) != 1 || len(state.ReviewQueue[0].MissingReviewers) != 2 {
t.Fatalf("unexpected initial queue: %#v", state.ReviewQueue)
}
memory, _, err := findMemory(root, "MEM-TEST-001")
if err != nil {
t.Fatal(err)
}
event := ReviewEvent{
ID: "REV-TEST-001", EntryID: memory.EntryID, AdminID: "ana",
Fingerprint: ana.SSHKeys[0].Fingerprint, ReviewedAt: "2026-08-12T12:01:00Z",
ContentSHA256: memory.ContentSHA256,
}
if _, err := writeReviewEvent(root, event); err != nil {
t.Fatal(err)
}
state, err = app.BuildState(root)
if err != nil {
t.Fatal(err)
}
if got := state.ReviewQueue[0].MissingReviewers; len(got) != 1 || got[0] != "bob" {
t.Fatalf("unexpected missing reviewers after review: %#v", got)
}
// Adding a new administrator must not reopen old entries because the
// required reviewer set is captured when the memory is created.
carol := testAdmin(t, "carol", 3, "admin")
registry.Admins = append(registry.Admins, carol)
if err := saveAdmins(root, registry); err != nil {
t.Fatal(err)
}
state, err = app.BuildState(root)
if err != nil {
t.Fatal(err)
}
if state.ReviewQueue[0].RequiredCount != 2 || contains(state.ReviewQueue[0].MissingReviewers, "carol") {
t.Fatalf("snapshot unexpectedly changed: %#v", state.ReviewQueue[0])
}
// Any edit changes the hash, invalidating the old review automatically.
data, err := os.ReadFile(memoryPath)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(memoryPath, append(data, []byte("\nCorrección posterior.\n")...), 0o644); err != nil {
t.Fatal(err)
}
state, err = app.BuildState(root)
if err != nil {
t.Fatal(err)
}
if got := state.ReviewQueue[0].MissingReviewers; len(got) != 2 || !contains(got, "ana") || !contains(got, "bob") {
t.Fatalf("old reviews were not invalidated: %#v", got)
}
}
func TestTaskProjectionFromIndependentEventFiles(t *testing.T) {
root := testProjectRoot(t)
ana := testAdmin(t, "ana", 1, "owner", "admin")
if err := saveAdmins(root, AdminRegistry{Version: 1, ReviewPolicy: ReviewPolicy{Default: "all-active", IncludeAuthor: true}, Admins: []Admin{ana}}); err != nil {
t.Fatal(err)
}
mustWriteTestFile(t, filepath.Join(root, "content", "tareas", "task.md"), `+++
title = "Probar respaldo"
description = "Prueba de tarea"
date = 2026-08-12T10:00:00Z
authors = ["ana"]
slug = "probar-respaldo"
[taxonomies]
tags = ["backup"]
systems = ["etcd"]
[extra]
id = "TSK-TEST-001"
status = "todo"
priority = "high"
project_id = ""
assignees = ["ana"]
estimate_minutes = 60
due = "2026-08-20"
+++
## Objetivo
Validar el respaldo.
`)
events := []TaskEvent{
{ID: "EVT-3", TaskID: "TSK-TEST-001", Type: "status", AdminID: "ana", Fingerprint: ana.SSHKeys[0].Fingerprint, At: "2026-08-12T10:30:00Z", Status: "blocked"},
{ID: "EVT-1", TaskID: "TSK-TEST-001", Type: "start", AdminID: "ana", Fingerprint: ana.SSHKeys[0].Fingerprint, At: "2026-08-12T10:00:00Z"},
{ID: "EVT-2", TaskID: "TSK-TEST-001", Type: "stop", AdminID: "ana", Fingerprint: ana.SSHKeys[0].Fingerprint, At: "2026-08-12T10:25:00Z", Minutes: 25},
{ID: "EVT-4", TaskID: "TSK-TEST-001", Type: "adjust", AdminID: "ana", Fingerprint: ana.SSHKeys[0].Fingerprint, At: "2026-08-12T10:31:00Z", Minutes: 5},
}
// Intentionally write out of chronological order: loading must sort by At.
if _, err := writeTaskEvents(root, events); err != nil {
t.Fatal(err)
}
state, err := New(Options{}).BuildState(root)
if err != nil {
t.Fatal(err)
}
if len(state.Tasks) != 1 {
t.Fatalf("unexpected tasks: %#v", state.Tasks)
}
got := state.Tasks[0]
if got.ActualMinutes != 30 || got.Status != "blocked" || len(got.RunningBy) != 0 || got.RunningBy == nil {
t.Fatalf("unexpected task projection: %#v", got)
}
}
func TestSyncRegeneratesWhenProjectionVersionChanges(t *testing.T) {
root := testProjectRoot(t)
app := New(Options{Root: root, Now: func() time.Time { return time.Date(2026, 8, 12, 15, 0, 0, 0, time.UTC) }})
state, changed, path, err := app.Sync(root, false)
if err != nil {
t.Fatal(err)
}
if !changed || state.Version != siteStateVersion {
t.Fatalf("unexpected initial sync: changed=%v state=%#v", changed, state)
}
var stale SiteState
if err := loadJSON(path, &stale); err != nil {
t.Fatal(err)
}
stale.Version = siteStateVersion - 1
if err := writeJSONAtomic(path, stale); err != nil {
t.Fatal(err)
}
if _, _, _, err := app.Sync(root, true); err == nil {
t.Fatal("sync --check accepted a stale projection version")
}
state, changed, _, err = app.Sync(root, false)
if err != nil {
t.Fatal(err)
}
if !changed || state.Version != siteStateVersion {
t.Fatalf("projection was not regenerated: changed=%v state=%#v", changed, state)
}
}
+361
View File
@@ -0,0 +1,361 @@
package clusterlog
import (
"bufio"
"bytes"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
"unicode"
)
func loadJSON(path string, dst any) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
if err := json.Unmarshal(data, dst); err != nil {
return fmt.Errorf("JSON inválido en %s: %w", path, err)
}
return nil
}
func writeJSONAtomic(path string, value any) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
return writeFileAtomic(path, data, 0o644)
}
func writeFileAtomic(path string, data []byte, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".clusterlog-*.tmp")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Chmod(mode); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, path)
}
func readAll(path string, stdin io.Reader) ([]byte, error) {
if path == "" {
return nil, nil
}
if path == "-" {
return io.ReadAll(stdin)
}
return os.ReadFile(path)
}
func fileSHA256(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:]), nil
}
func bytesSHA256(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func generateID(prefix string, now time.Time) (string, error) {
random := make([]byte, 3)
if _, err := rand.Read(random); err != nil {
return "", err
}
return fmt.Sprintf("%s-%s-%s", strings.ToUpper(prefix), now.UTC().Format("20060102"), strings.ToUpper(hex.EncodeToString(random))), nil
}
func normalizeID(value string) string {
return strings.ToUpper(strings.TrimSpace(value))
}
func slugify(value string) string {
replacements := map[rune]rune{
'á': 'a', 'à': 'a', 'ä': 'a', 'â': 'a', 'Á': 'a', 'À': 'a', 'Ä': 'a', 'Â': 'a',
'é': 'e', 'è': 'e', 'ë': 'e', 'ê': 'e', 'É': 'e', 'È': 'e', 'Ë': 'e', 'Ê': 'e',
'í': 'i', 'ì': 'i', 'ï': 'i', 'î': 'i', 'Í': 'i', 'Ì': 'i', 'Ï': 'i', 'Î': 'i',
'ó': 'o', 'ò': 'o', 'ö': 'o', 'ô': 'o', 'Ó': 'o', 'Ò': 'o', 'Ö': 'o', 'Ô': 'o',
'ú': 'u', 'ù': 'u', 'ü': 'u', 'û': 'u', 'Ú': 'u', 'Ù': 'u', 'Ü': 'u', 'Û': 'u',
'ñ': 'n', 'Ñ': 'n', 'ç': 'c', 'Ç': 'c',
}
var b strings.Builder
lastDash := false
for _, r := range value {
if replacement, ok := replacements[r]; ok {
r = replacement
}
r = unicode.ToLower(r)
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
lastDash = false
continue
}
if !lastDash && b.Len() > 0 {
b.WriteByte('-')
lastDash = true
}
}
return strings.Trim(b.String(), "-")
}
func splitCSV(value string) []string {
if strings.TrimSpace(value) == "" {
return nil
}
seen := map[string]bool{}
out := []string{}
for _, item := range strings.Split(value, ",") {
item = strings.TrimSpace(item)
if item == "" || seen[item] {
continue
}
seen[item] = true
out = append(out, item)
}
return out
}
func parseDurationMinutes(value string) (int, error) {
value = strings.TrimSpace(value)
if value == "" {
return 0, nil
}
if n, err := strconv.Atoi(value); err == nil {
if n < 0 {
return 0, fmt.Errorf("la duración no puede ser negativa")
}
return n, nil
}
duration, err := time.ParseDuration(value)
if err != nil {
return 0, fmt.Errorf("duración inválida %q; use minutos, 90m, 2h o 1h30m", value)
}
if duration < 0 {
return 0, fmt.Errorf("la duración no puede ser negativa")
}
return int(duration.Round(time.Minute) / time.Minute), nil
}
func formatMinutes(minutes int) string {
if minutes <= 0 {
return "0m"
}
hours := minutes / 60
rest := minutes % 60
if hours == 0 {
return fmt.Sprintf("%dm", rest)
}
if rest == 0 {
return fmt.Sprintf("%dh", hours)
}
return fmt.Sprintf("%dh%dm", hours, rest)
}
func listMarkdownFiles(root, section string) ([]string, error) {
base := filepath.Join(root, "content", section)
entries := []string{}
err := filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if filepath.Ext(path) != ".md" || filepath.Base(path) == "_index.md" {
return nil
}
entries = append(entries, path)
return nil
})
if os.IsNotExist(err) {
return nil, nil
}
sort.Strings(entries)
return entries, err
}
func sourceDigest(paths []string) (string, error) {
sort.Strings(paths)
h := sha256.New()
for _, path := range paths {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
_, _ = h.Write([]byte(filepath.ToSlash(path)))
_, _ = h.Write([]byte{0})
_, _ = h.Write(data)
_, _ = h.Write([]byte{0})
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func uniqueSorted(values []string) []string {
seen := map[string]bool{}
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
seen[value] = true
}
}
out := make([]string, 0, len(seen))
for value := range seen {
out = append(out, value)
}
sort.Strings(out)
return out
}
func contains(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func hasRole(admin Admin, role string) bool {
return contains(admin.Roles, role)
}
func relativePaths(root string, paths []string) ([]string, error) {
out := make([]string, 0, len(paths))
for _, path := range paths {
rel, err := filepath.Rel(root, path)
if err != nil {
return nil, err
}
out = append(out, filepath.ToSlash(rel))
}
return out, nil
}
func validateDate(value string) error {
if value == "" {
return nil
}
if _, err := time.Parse("2006-01-02", value); err != nil {
return fmt.Errorf("fecha inválida %q; use YYYY-MM-DD", value)
}
return nil
}
var secretPatterns = []struct {
Name string
Pattern *regexp.Regexp
}{
{"llave privada OpenSSH", regexp.MustCompile(`(?i)BEGIN[ ]+OPENSSH[ ]+PRIVATE[ ]+KEY`)},
{"llave privada PEM", regexp.MustCompile(`(?i)BEGIN[ ]+(RSA|EC|DSA)?[ ]*PRIVATE[ ]+KEY`)},
{"token GitHub", regexp.MustCompile(`\b(ghp|github_pat)_[A-Za-z0-9_]{20,}\b`)},
{"access key AWS", regexp.MustCompile(`\bAKIA[0-9A-Z]{16}\b`)},
{"token Slack", regexp.MustCompile(`\bxox[baprs]-[A-Za-z0-9-]{20,}\b`)},
{"contraseña explícita", regexp.MustCompile(`(?im)\b(password|passwd|contrase(?:ñ|n)a)\s*[:=]\s*[^<\s][^\n]{3,}`)},
}
func scanSecrets(text string) []string {
findings := []string{}
for _, item := range secretPatterns {
if item.Pattern.MatchString(text) {
findings = append(findings, item.Name)
}
}
return uniqueSorted(findings)
}
func tomlString(value string) string { return strconv.Quote(value) }
func tomlStringArray(values []string) string {
parts := make([]string, 0, len(values))
for _, value := range values {
parts = append(parts, tomlString(value))
}
return "[" + strings.Join(parts, ", ") + "]"
}
func bullets(values []string) string {
if len(values) == 0 {
return "_No especificado._\n"
}
var b strings.Builder
for _, value := range values {
fmt.Fprintf(&b, "- %s\n", value)
}
return b.String()
}
func codeBlock(values []string) string {
if len(values) == 0 {
return "_No se registraron comandos._\n"
}
return "```console\n" + strings.Join(values, "\n") + "\n```\n"
}
func normalizeMarkdownBody(body string) string {
body = strings.ReplaceAll(body, "\r\n", "\n")
return strings.TrimSpace(body) + "\n"
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func readLines(r io.Reader) ([]string, error) {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 1024), 1024*1024)
var lines []string
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
return lines, scanner.Err()
}
func canonicalJSON(value any) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if err := enc.Encode(value); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
+58
View File
@@ -0,0 +1,58 @@
package clusterlog
import "testing"
func TestSlugifySpanishText(t *testing.T) {
got := slugify(" Actualización del Clúster: Niño / ÁRBOL ")
want := "actualizacion-del-cluster-nino-arbol"
if got != want {
t.Fatalf("slugify() = %q, want %q", got, want)
}
}
func TestParseDurationMinutes(t *testing.T) {
tests := map[string]int{
"90": 90,
"90m": 90,
"2h": 120,
"1h30m": 90,
}
for input, want := range tests {
got, err := parseDurationMinutes(input)
if err != nil {
t.Fatalf("parseDurationMinutes(%q): %v", input, err)
}
if got != want {
t.Errorf("parseDurationMinutes(%q) = %d, want %d", input, got, want)
}
}
if _, err := parseDurationMinutes("-5m"); err == nil {
t.Fatal("expected negative duration to fail")
}
}
func TestScanSecrets(t *testing.T) {
text := "password = supersecreto\n-----BEGIN OPENSSH PRIVATE KEY-----"
findings := scanSecrets(text)
if !contains(findings, "contraseña explícita") || !contains(findings, "llave privada OpenSSH") {
t.Fatalf("unexpected findings: %#v", findings)
}
if findings := scanSecrets("kubectl get nodes\nshow version"); len(findings) != 0 {
t.Fatalf("false positive findings: %#v", findings)
}
}
func TestPublicKeyFingerprintIsStable(t *testing.T) {
key := testOpenSSHPublicKey(7, "ana@test")
canonical1, fingerprint1, err := parsePublicKeyLine(key)
if err != nil {
t.Fatal(err)
}
canonical2, fingerprint2, err := parsePublicKeyLine(" " + key + " ")
if err != nil {
t.Fatal(err)
}
if canonical1 != canonical2 || fingerprint1 != fingerprint2 {
t.Fatalf("key parsing is not stable: %q/%q vs %q/%q", canonical1, fingerprint1, canonical2, fingerprint2)
}
}
+78
View File
@@ -0,0 +1,78 @@
package clusterlog
import (
"bytes"
"encoding/base64"
"encoding/binary"
"os"
"path/filepath"
"testing"
)
func testProjectRoot(t *testing.T) string {
t.Helper()
root := t.TempDir()
mustWriteTestFile(t, filepath.Join(root, "zola.toml"), "title = \"test\"\n")
for _, section := range []string{"documentacion", "manuales", "memorias", "proyectos", "tareas", "revision"} {
if err := os.MkdirAll(filepath.Join(root, "content", section), 0o755); err != nil {
t.Fatal(err)
}
}
for _, dir := range []string{
filepath.Join(root, "data", reviewEventsDirName),
filepath.Join(root, "data", taskEventsDirName),
filepath.Join(root, "config"),
} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
registry := AdminRegistry{
Version: 1,
ReviewPolicy: ReviewPolicy{Default: "all-active", IncludeAuthor: true},
Admins: []Admin{},
}
if err := saveAdmins(root, registry); err != nil {
t.Fatal(err)
}
return root
}
func mustWriteTestFile(t *testing.T, path, contents string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
t.Fatal(err)
}
}
func testOpenSSHPublicKey(seed byte, comment string) string {
var blob bytes.Buffer
writeSSHString := func(value []byte) {
_ = binary.Write(&blob, binary.BigEndian, uint32(len(value)))
_, _ = blob.Write(value)
}
writeSSHString([]byte("ssh-ed25519"))
key := bytes.Repeat([]byte{seed}, 32)
writeSSHString(key)
encoded := base64.StdEncoding.EncodeToString(blob.Bytes())
if comment == "" {
return "ssh-ed25519 " + encoded
}
return "ssh-ed25519 " + encoded + " " + comment
}
func testAdmin(t *testing.T, id string, seed byte, roles ...string) Admin {
t.Helper()
publicKey := testOpenSSHPublicKey(seed, id+"@test")
_, fingerprint, err := parsePublicKeyLine(publicKey)
if err != nil {
t.Fatal(err)
}
return Admin{
ID: id, Name: id, Email: id + "@example.test", Active: true, Roles: roles,
SSHKeys: []SSHKey{{Fingerprint: fingerprint, PublicKey: publicKey, AddedAt: "2026-08-12T00:00:00Z"}},
}
}
+377
View File
@@ -0,0 +1,377 @@
package clusterlog
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"time"
)
const Version = "0.1.0"
const (
ExitOK = 0
ExitUsage = 2
ExitIdentity = 3
ExitValidation = 4
ExitConflict = 5
ExitGit = 6
ExitInternal = 10
)
type CLIError struct {
Code int
Kind string
Message string
Details any
}
func (e *CLIError) Error() string { return e.Message }
func NewError(code int, kind, message string, details any) *CLIError {
return &CLIError{Code: code, Kind: kind, Message: message, Details: details}
}
type Options struct {
Root string
JSON bool
SSHKey string
Commit bool
DryRun bool
Stdout io.Writer
Stderr io.Writer
Stdin io.Reader
Now func() time.Time
Executable string
}
type App struct {
Options Options
}
func New(options Options) *App {
if options.Stdout == nil {
options.Stdout = os.Stdout
}
if options.Stderr == nil {
options.Stderr = os.Stderr
}
if options.Stdin == nil {
options.Stdin = os.Stdin
}
if options.Now == nil {
options.Now = time.Now
}
if options.Executable == "" {
options.Executable = "clusterlog"
}
return &App{Options: options}
}
type ReviewPolicy struct {
Default string `json:"default"`
IncludeAuthor bool `json:"include_author"`
}
type SSHKey struct {
Fingerprint string `json:"fingerprint"`
PublicKey string `json:"public_key"`
AddedAt string `json:"added_at,omitempty"`
}
type Admin struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Active bool `json:"active"`
Roles []string `json:"roles,omitempty"`
SSHKeys []SSHKey `json:"ssh_keys"`
}
type AdminRegistry struct {
Version int `json:"version"`
ReviewPolicy ReviewPolicy `json:"review_policy"`
Admins []Admin `json:"admins"`
}
type Identity struct {
Admin Admin `json:"admin"`
Fingerprint string `json:"fingerprint"`
PublicKey string `json:"public_key"`
PublicPath string `json:"public_key_path"`
SigningKey string `json:"signing_key_path"`
}
type ReviewEvent struct {
ID string `json:"id"`
EntryID string `json:"entry_id"`
AdminID string `json:"admin_id"`
Fingerprint string `json:"fingerprint"`
ReviewedAt string `json:"reviewed_at"`
ContentSHA256 string `json:"content_sha256"`
Note string `json:"note,omitempty"`
}
type ReviewLog struct {
Version int `json:"version"`
Events []ReviewEvent `json:"events"`
}
type TaskEvent struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
Type string `json:"type"`
AdminID string `json:"admin_id"`
Fingerprint string `json:"fingerprint"`
At string `json:"at"`
Status string `json:"status,omitempty"`
Minutes int `json:"minutes,omitempty"`
Note string `json:"note,omitempty"`
}
type TaskLog struct {
Version int `json:"version"`
Events []TaskEvent `json:"events"`
}
type PageMeta struct {
Path string
Body string
Root map[string]any
Taxonomies map[string][]string
Extra map[string]any
RawFront string
ContentHash string
}
func (p PageMeta) String(key string) string {
if v, ok := p.Root[key]; ok {
return fmt.Sprint(v)
}
return ""
}
func (p PageMeta) ExtraString(key string) string {
if v, ok := p.Extra[key]; ok {
return fmt.Sprint(v)
}
return ""
}
func (p PageMeta) ExtraBool(key string) bool {
v, ok := p.Extra[key]
if !ok {
return false
}
b, _ := v.(bool)
return b
}
func (p PageMeta) ExtraInt(key string) int {
v, ok := p.Extra[key]
if !ok {
return 0
}
switch n := v.(type) {
case int:
return n
case int64:
return int(n)
case float64:
return int(n)
default:
return 0
}
}
func (p PageMeta) ExtraStrings(key string) []string {
v, ok := p.Extra[key]
if !ok {
return nil
}
switch values := v.(type) {
case []string:
return values
case []any:
out := make([]string, 0, len(values))
for _, item := range values {
out = append(out, fmt.Sprint(item))
}
return out
default:
return nil
}
}
type MemoryRecord struct {
EntryID string `json:"entry_id"`
Title string `json:"title"`
Description string `json:"description"`
Date string `json:"date"`
AuthorID string `json:"author_id"`
AuthorFingerprint string `json:"author_fingerprint"`
Risk string `json:"risk"`
Ticket string `json:"ticket,omitempty"`
ReviewRequired bool `json:"review_required"`
ReviewPolicy string `json:"review_policy"`
ReviewerSnapshot bool `json:"reviewer_snapshot"`
Reviewers []string `json:"reviewers"`
Tags []string `json:"tags"`
Systems []string `json:"systems"`
Slug string `json:"slug"`
Permalink string `json:"permalink"`
SourcePath string `json:"source_path"`
ContentSHA256 string `json:"content_sha256"`
}
type ReviewQueueItem struct {
EntryID string `json:"entry_id"`
Title string `json:"title"`
Description string `json:"description"`
Date string `json:"date"`
AuthorID string `json:"author_id"`
Risk string `json:"risk"`
Tags []string `json:"tags"`
Systems []string `json:"systems"`
Permalink string `json:"permalink"`
ContentSHA256 string `json:"content_sha256"`
ReviewedBy []string `json:"reviewed_by"`
MissingReviewers []string `json:"missing_reviewers"`
RequiredCount int `json:"required_count"`
ReviewedCount int `json:"reviewed_count"`
ConfigurationError string `json:"configuration_error,omitempty"`
}
type TaskState struct {
TaskID string `json:"task_id"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
ProjectID string `json:"project_id,omitempty"`
Assignees []string `json:"assignees"`
EstimateMinutes int `json:"estimate_minutes"`
ActualMinutes int `json:"actual_minutes"`
Due string `json:"due,omitempty"`
RunningBy []string `json:"running_by"`
Slug string `json:"slug"`
Permalink string `json:"permalink"`
SourcePath string `json:"source_path"`
}
type ProjectState struct {
ProjectID string `json:"project_id"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
Owners []string `json:"owners"`
TargetDate string `json:"target_date,omitempty"`
Slug string `json:"slug"`
Permalink string `json:"permalink"`
SourcePath string `json:"source_path"`
}
type SiteStats struct {
ActiveAdmins int `json:"active_admins"`
PendingReviews int `json:"pending_reviews"`
OpenTasks int `json:"open_tasks"`
ActiveProjects int `json:"active_projects"`
}
type SiteState struct {
Version int `json:"version"`
GeneratedAt string `json:"generated_at"`
SourceSHA256 string `json:"source_sha256"`
Stats SiteStats `json:"stats"`
ReviewQueue []ReviewQueueItem `json:"review_queue"`
RecentMemories []MemoryRecord `json:"recent_memories"`
Tasks []TaskState `json:"tasks"`
Projects []ProjectState `json:"projects"`
}
type ResultEnvelope struct {
OK bool `json:"ok"`
Command string `json:"command,omitempty"`
Data any `json:"data,omitempty"`
Error any `json:"error,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}
func (a *App) printResult(command string, data any, warnings ...string) error {
if a.Options.JSON {
enc := json.NewEncoder(a.Options.Stdout)
enc.SetIndent("", " ")
return enc.Encode(ResultEnvelope{OK: true, Command: command, Data: data, Warnings: warnings})
}
switch v := data.(type) {
case string:
fmt.Fprintln(a.Options.Stdout, v)
default:
bytes, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
fmt.Fprintln(a.Options.Stdout, string(bytes))
}
for _, warning := range warnings {
fmt.Fprintf(a.Options.Stderr, "ADVERTENCIA: %s\n", warning)
}
return nil
}
func PrintError(w io.Writer, jsonMode bool, err error) int {
var cliErr *CLIError
if !errors.As(err, &cliErr) {
cliErr = NewError(ExitInternal, "internal", err.Error(), nil)
}
if jsonMode {
payload := ResultEnvelope{OK: false, Error: map[string]any{
"kind": cliErr.Kind,
"message": cliErr.Message,
"details": cliErr.Details,
"exit_code": cliErr.Code,
}}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(payload)
} else {
fmt.Fprintf(w, "ERROR [%s]: %s\n", cliErr.Kind, cliErr.Message)
if cliErr.Details != nil {
bytes, _ := json.MarshalIndent(cliErr.Details, "", " ")
fmt.Fprintln(w, string(bytes))
}
}
return cliErr.Code
}
func (a *App) ResolveRoot() (string, error) {
if a.Options.Root != "" {
root, err := filepath.Abs(a.Options.Root)
if err != nil {
return "", err
}
if _, err := os.Stat(filepath.Join(root, "zola.toml")); err != nil {
return "", NewError(ExitUsage, "root_not_found", "el directorio raíz no contiene zola.toml", root)
}
return root, nil
}
cwd, err := os.Getwd()
if err != nil {
return "", err
}
current := cwd
for {
if _, err := os.Stat(filepath.Join(current, "zola.toml")); err == nil {
return current, nil
}
parent := filepath.Dir(current)
if parent == current {
break
}
current = parent
}
return "", NewError(ExitUsage, "root_not_found", "no se encontró zola.toml en el directorio actual ni en sus padres", cwd)
}
+495
View File
@@ -0,0 +1,495 @@
package clusterlog
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
)
type ValidationIssue struct {
Level string `json:"level"`
Code string `json:"code"`
Path string `json:"path,omitempty"`
Message string `json:"message"`
Details any `json:"details,omitempty"`
}
type ValidationReport struct {
Valid bool `json:"valid"`
Errors []ValidationIssue `json:"errors"`
Warnings []ValidationIssue `json:"warnings"`
Checks map[string]int `json:"checks"`
ZolaChecked bool `json:"zola_checked"`
}
type pageRecord struct {
Section string
Meta PageMeta
ID string
}
func (r *ValidationReport) add(level, code, path, message string, details any) {
issue := ValidationIssue{Level: level, Code: code, Path: filepath.ToSlash(path), Message: message, Details: details}
if level == "warning" {
r.Warnings = append(r.Warnings, issue)
} else {
r.Errors = append(r.Errors, issue)
}
}
func valueStrings(value any) []string {
switch v := value.(type) {
case []string:
return v
case []any:
out := make([]string, 0, len(v))
for _, item := range v {
out = append(out, fmt.Sprint(item))
}
return out
case string:
if v != "" {
return []string{v}
}
}
return nil
}
func fingerprintBelongs(registry AdminRegistry, adminID, fingerprint string) bool {
admin, ok := adminByID(registry, adminID)
if !ok {
return false
}
for _, key := range admin.SSHKeys {
if key.Fingerprint == fingerprint {
return true
}
}
return false
}
func validTaskStatus(status string) bool {
switch status {
case "backlog", "todo", "in_progress", "blocked", "done", "cancelled":
return true
default:
return false
}
}
func validProjectStatus(status string) bool {
switch status {
case "planned", "active", "blocked", "done", "completed", "cancelled", "archived":
return true
default:
return false
}
}
func (a *App) runValidate(root string, args []string) error {
fs := newFlagSet("validate")
skipZola := fs.Bool("skip-zola", false, "omite zola check")
requireZola := fs.Bool("require-zola", false, "falla si Zola no está instalado")
if err := fs.Parse(args); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
report := ValidationReport{Checks: map[string]int{}, Errors: []ValidationIssue{}, Warnings: []ValidationIssue{}}
registry, err := loadAdmins(root)
if err != nil {
report.add("error", "admins_invalid", filepath.Join(root, "data", "admins.json"), err.Error(), nil)
} else {
a.validateAdmins(root, registry, &report)
}
pages := a.validatePages(root, registry, &report)
a.validateEvents(root, registry, pages, &report)
if len(report.Errors) == 0 {
if _, _, _, err := a.Sync(root, true); err != nil {
report.add("error", "generated_state_stale", filepath.Join(root, "data", "generated", "site_state.json"), err.Error(), nil)
} else {
report.Checks["generated_state"] = 1
}
}
if !*skipZola {
zolaPath, lookupErr := exec.LookPath("zola")
if lookupErr != nil {
if *requireZola {
report.add("error", "zola_missing", "", "Zola no está instalado o no está en PATH", nil)
} else {
report.add("warning", "zola_missing", "", "Zola no está instalado; se omitió la validación de plantillas y enlaces", nil)
}
} else {
report.ZolaChecked = true
cmd := exec.Command(zolaPath, "check")
cmd.Dir = root
var output bytes.Buffer
cmd.Stdout = &output
cmd.Stderr = &output
if err := cmd.Run(); err != nil {
report.add("error", "zola_check_failed", filepath.Join(root, "zola.toml"), "zola check falló", strings.TrimSpace(output.String()))
} else {
report.Checks["zola"] = 1
}
}
}
report.Valid = len(report.Errors) == 0
if !report.Valid {
return NewError(ExitValidation, "validation_failed", "la validación del proyecto encontró errores", report)
}
warnings := make([]string, 0, len(report.Warnings))
for _, issue := range report.Warnings {
warnings = append(warnings, issue.Message)
}
return a.printResult("validate", report, warnings...)
}
func (a *App) validateAdmins(root string, registry AdminRegistry, report *ValidationReport) {
if registry.ReviewPolicy.Default != "all-active" && registry.ReviewPolicy.Default != "all-active-except-author" {
report.add("error", "invalid_default_review_policy", filepath.Join(root, "data", "admins.json"), "review_policy.default no es válido", registry.ReviewPolicy.Default)
}
ids := map[string]string{}
emails := map[string]string{}
fingerprints := map[string]string{}
ownerCount := 0
for _, admin := range registry.Admins {
report.Checks["admins"]++
idKey := normalizeID(admin.ID)
if admin.ID == "" || slugify(admin.ID) != admin.ID {
report.add("error", "invalid_admin_id", filepath.Join(root, "data", "admins.json"), "el ID del administrador debe ser un slug estable en minúsculas", admin.ID)
}
if prior, ok := ids[idKey]; ok {
report.add("error", "duplicate_admin_id", filepath.Join(root, "data", "admins.json"), "ID de administrador duplicado", []string{prior, admin.ID})
}
ids[idKey] = admin.ID
emailKey := strings.ToLower(strings.TrimSpace(admin.Email))
if emailKey == "" || !strings.Contains(emailKey, "@") {
report.add("error", "invalid_admin_email", filepath.Join(root, "data", "admins.json"), "correo de administrador inválido", admin.ID)
}
if prior, ok := emails[emailKey]; ok {
report.add("error", "duplicate_admin_email", filepath.Join(root, "data", "admins.json"), "correo de administrador duplicado", []string{prior, admin.ID})
}
emails[emailKey] = admin.ID
if hasRole(admin, "owner") && admin.Active {
ownerCount++
}
if admin.Active && len(admin.SSHKeys) == 0 {
report.add("error", "active_admin_without_key", filepath.Join(root, "data", "admins.json"), "un administrador activo debe tener al menos una llave SSH", admin.ID)
}
for _, key := range admin.SSHKeys {
report.Checks["ssh_keys"]++
_, computed, err := parsePublicKeyLine(key.PublicKey)
if err != nil {
report.add("error", "invalid_public_key", filepath.Join(root, "data", "admins.json"), err.Error(), admin.ID)
continue
}
if key.Fingerprint != computed {
report.add("error", "fingerprint_mismatch", filepath.Join(root, "data", "admins.json"), "la huella almacenada no corresponde a la llave pública", map[string]any{"admin_id": admin.ID, "stored": key.Fingerprint, "computed": computed})
}
if prior, ok := fingerprints[key.Fingerprint]; ok {
report.add("error", "duplicate_ssh_key", filepath.Join(root, "data", "admins.json"), "una llave SSH está asignada a más de un administrador", []string{prior, admin.ID})
}
fingerprints[key.Fingerprint] = admin.ID
}
}
if len(registry.Admins) > 0 && ownerCount == 0 {
report.add("error", "missing_owner", filepath.Join(root, "data", "admins.json"), "debe existir al menos un administrador activo con rol owner", nil)
}
allowedPath := filepath.Join(root, "config", "allowed_signers")
allowedData, err := os.ReadFile(allowedPath)
if err != nil {
report.add("error", "allowed_signers_missing", allowedPath, "no se pudo leer el archivo de firmantes autorizados", err.Error())
return
}
allowed := string(allowedData)
for _, admin := range registry.Admins {
if !admin.Active {
continue
}
for _, key := range admin.SSHKeys {
fields := strings.Fields(key.PublicKey)
if len(fields) < 2 {
continue
}
expected := fmt.Sprintf("%s namespaces=\"git\" %s %s", admin.Email, fields[0], fields[1])
if !strings.Contains(allowed, expected) {
report.add("error", "allowed_signer_missing", allowedPath, "falta una llave activa en allowed_signers", map[string]any{"admin_id": admin.ID, "fingerprint": key.Fingerprint})
}
}
}
}
func (a *App) validatePages(root string, registry AdminRegistry, report *ValidationReport) []pageRecord {
sections := []string{"documentacion", "manuales", "memorias", "proyectos", "tareas"}
pages := []pageRecord{}
ids := []string{}
for _, section := range sections {
files, err := listMarkdownFiles(root, section)
if err != nil {
report.add("error", "content_walk_failed", filepath.Join(root, "content", section), err.Error(), nil)
continue
}
for _, path := range files {
report.Checks["content_pages"]++
data, readErr := os.ReadFile(path)
if readErr != nil {
report.add("error", "content_read_failed", path, readErr.Error(), nil)
continue
}
if findings := scanSecrets(string(data)); len(findings) > 0 {
report.add("error", "possible_secret", path, "el contenido parece incluir secretos", findings)
}
meta, parseErr := parseMarkdown(path)
if parseErr != nil {
report.add("error", "front_matter_invalid", path, parseErr.Error(), nil)
continue
}
id := normalizeID(meta.ExtraString("id"))
if id == "" {
report.add("error", "missing_page_id", path, "la página no tiene extra.id", nil)
}
ids = append(ids, id)
pages = append(pages, pageRecord{Section: section, Meta: meta, ID: id})
if strings.TrimSpace(meta.String("title")) == "" {
report.add("error", "missing_title", path, "la página no tiene título", nil)
}
authors := valueStrings(meta.Root["authors"])
for _, author := range authors {
if _, ok := adminByID(registry, author); !ok && author != "sistema" {
report.add("error", "unknown_author", path, "la página referencia un autor no registrado", author)
}
}
}
}
for _, duplicate := range pageIDDuplicates(ids) {
report.add("error", "duplicate_page_id", filepath.Join(root, "content"), "hay IDs de contenido duplicados", duplicate)
}
projectIDs := map[string]bool{}
for _, page := range pages {
if page.Section == "proyectos" {
projectIDs[page.ID] = true
}
}
for _, page := range pages {
path := page.Meta.Path
switch page.Section {
case "memorias":
if !strings.HasPrefix(page.ID, "MEM-") {
report.add("error", "invalid_memory_id", path, "el ID de memoria debe iniciar con MEM-", page.ID)
}
authorID := page.Meta.ExtraString("author_id")
if _, ok := adminByID(registry, authorID); !ok && authorID != "sistema" {
report.add("error", "unknown_memory_author", path, "author_id no corresponde a un administrador", authorID)
}
fingerprint := page.Meta.ExtraString("author_fingerprint")
if authorID != "sistema" && !fingerprintBelongs(registry, authorID, fingerprint) {
report.add("error", "memory_fingerprint_mismatch", path, "author_fingerprint no pertenece al autor", map[string]any{"author_id": authorID, "fingerprint": fingerprint})
}
policy := page.Meta.ExtraString("review_policy")
if policy != "" && policy != "all-active" && policy != "all-active-except-author" {
report.add("error", "invalid_review_policy", path, "review_policy no es válido", policy)
}
if page.Meta.ExtraBool("reviewer_snapshot") {
seenReviewers := map[string]bool{}
for _, reviewer := range page.Meta.ExtraStrings("reviewers") {
if seenReviewers[reviewer] {
report.add("error", "duplicate_reviewer", path, "un revisor aparece más de una vez", reviewer)
}
seenReviewers[reviewer] = true
if _, ok := adminByID(registry, reviewer); !ok {
report.add("error", "unknown_reviewer", path, "el conjunto capturado referencia un administrador inexistente", reviewer)
}
}
}
case "tareas":
if !strings.HasPrefix(page.ID, "TSK-") {
report.add("error", "invalid_task_id", path, "el ID de tarea debe iniciar con TSK-", page.ID)
}
priority := page.Meta.ExtraString("priority")
if priority != "low" && priority != "medium" && priority != "high" && priority != "urgent" {
report.add("error", "invalid_task_priority", path, "priority no es válida", priority)
}
status := firstNonEmpty(page.Meta.ExtraString("status"), "todo")
if !validTaskStatus(status) {
report.add("error", "invalid_task_status", path, "status no es válido", status)
}
if due := page.Meta.ExtraString("due"); validateDate(due) != nil {
report.add("error", "invalid_due", path, "due debe usar YYYY-MM-DD", due)
}
projectID := normalizeID(page.Meta.ExtraString("project_id"))
if projectID != "" && !projectIDs[projectID] {
report.add("error", "unknown_project", path, "project_id no existe", projectID)
}
for _, assignee := range page.Meta.ExtraStrings("assignees") {
admin, ok := adminByID(registry, assignee)
if !ok || !admin.Active {
report.add("error", "invalid_assignee", path, "assignee no existe o está inactivo", assignee)
}
}
case "proyectos":
if !strings.HasPrefix(page.ID, "PRJ-") {
report.add("error", "invalid_project_id", path, "el ID de proyecto debe iniciar con PRJ-", page.ID)
}
status := firstNonEmpty(page.Meta.ExtraString("status"), "planned")
if !validProjectStatus(status) {
report.add("error", "invalid_project_status", path, "status no es válido", status)
}
if target := page.Meta.ExtraString("target_date"); validateDate(target) != nil {
report.add("error", "invalid_target_date", path, "target_date debe usar YYYY-MM-DD", target)
}
for _, owner := range page.Meta.ExtraStrings("owners") {
admin, ok := adminByID(registry, owner)
if !ok || !admin.Active {
report.add("error", "invalid_owner", path, "owner no existe o está inactivo", owner)
}
}
case "documentacion":
if !strings.HasPrefix(page.ID, "DOC-") {
report.add("error", "invalid_document_id", path, "el ID de documentación debe iniciar con DOC-", page.ID)
}
case "manuales":
if !strings.HasPrefix(page.ID, "RUN-") {
report.add("error", "invalid_manual_id", path, "el ID de manual debe iniciar con RUN-", page.ID)
}
}
}
return pages
}
func (a *App) validateEvents(root string, registry AdminRegistry, pages []pageRecord, report *ValidationReport) {
memories := map[string]pageRecord{}
tasks := map[string]pageRecord{}
for _, page := range pages {
if page.Section == "memorias" {
memories[page.ID] = page
}
if page.Section == "tareas" {
tasks[page.ID] = page
}
}
reviews, err := loadReviewLog(root)
if err != nil {
report.add("error", "review_log_invalid", filepath.Join(root, "data", reviewEventsDirName), err.Error(), nil)
} else {
seen := map[string]bool{}
for _, event := range reviews.Events {
report.Checks["review_events"]++
if seen[event.ID] {
report.add("error", "duplicate_review_event", filepath.Join(root, "data", reviewEventsDirName), "ID de evento de revisión duplicado", event.ID)
}
seen[event.ID] = true
if _, ok := memories[normalizeID(event.EntryID)]; !ok {
report.add("warning", "orphan_review_event", filepath.Join(root, "data", reviewEventsDirName), "el evento apunta a una memoria que ya no está en el árbol actual", event.EntryID)
}
if _, ok := adminByID(registry, event.AdminID); !ok {
report.add("error", "unknown_review_admin", filepath.Join(root, "data", reviewEventsDirName), "admin_id de revisión no existe", event.AdminID)
}
if !fingerprintBelongs(registry, event.AdminID, event.Fingerprint) {
report.add("error", "review_fingerprint_mismatch", filepath.Join(root, "data", reviewEventsDirName), "la huella del evento no pertenece al administrador", event.ID)
}
if _, parseErr := time.Parse(time.RFC3339, event.ReviewedAt); parseErr != nil {
report.add("error", "invalid_review_time", filepath.Join(root, "data", reviewEventsDirName), "reviewed_at no es RFC3339", event.ID)
}
if len(event.ContentSHA256) != 64 {
report.add("error", "invalid_review_hash", filepath.Join(root, "data", reviewEventsDirName), "content_sha256 no tiene longitud SHA-256", event.ID)
}
}
}
log, err := loadTaskLog(root)
if err != nil {
report.add("error", "task_log_invalid", filepath.Join(root, "data", taskEventsDirName), err.Error(), nil)
return
}
seen := map[string]bool{}
activeTimer := map[string]TaskEvent{}
totalMinutes := map[string]int{}
for _, event := range log.Events {
report.Checks["task_events"]++
if seen[event.ID] {
report.add("error", "duplicate_task_event", filepath.Join(root, "data", taskEventsDirName), "ID de evento de tarea duplicado", event.ID)
}
seen[event.ID] = true
if _, ok := tasks[normalizeID(event.TaskID)]; !ok {
report.add("error", "unknown_task_event_target", filepath.Join(root, "data", taskEventsDirName), "el evento apunta a una tarea inexistente", event.TaskID)
}
if _, ok := adminByID(registry, event.AdminID); !ok {
report.add("error", "unknown_task_admin", filepath.Join(root, "data", taskEventsDirName), "admin_id de evento no existe", event.AdminID)
}
if !fingerprintBelongs(registry, event.AdminID, event.Fingerprint) {
report.add("error", "task_fingerprint_mismatch", filepath.Join(root, "data", taskEventsDirName), "la huella del evento no pertenece al administrador", event.ID)
}
if _, parseErr := time.Parse(time.RFC3339, event.At); parseErr != nil {
report.add("error", "invalid_task_event_time", filepath.Join(root, "data", taskEventsDirName), "at no es RFC3339", event.ID)
}
switch event.Type {
case "start":
if active, ok := activeTimer[event.AdminID]; ok {
code := "duplicate_task_start"
message := "el administrador inició dos veces la misma tarea sin detenerla"
if normalizeID(active.TaskID) != normalizeID(event.TaskID) {
code = "concurrent_task_timers"
message = "el administrador tiene más de un temporizador activo"
}
report.add("error", code, filepath.Join(root, "data", taskEventsDirName), message, map[string]any{
"admin_id": event.AdminID, "active_event": active.ID, "new_event": event.ID,
})
}
activeTimer[event.AdminID] = event
case "stop":
active, ok := activeTimer[event.AdminID]
if !ok || normalizeID(active.TaskID) != normalizeID(event.TaskID) {
report.add("error", "task_stop_without_start", filepath.Join(root, "data", taskEventsDirName), "el evento stop no corresponde a un temporizador activo", event.ID)
} else {
delete(activeTimer, event.AdminID)
}
if event.Minutes <= 0 {
report.add("error", "invalid_stop_minutes", filepath.Join(root, "data", taskEventsDirName), "un evento stop debe registrar minutos positivos", event.ID)
}
totalMinutes[normalizeID(event.TaskID)] += event.Minutes
case "adjust":
totalMinutes[normalizeID(event.TaskID)] += event.Minutes
if event.Minutes == 0 {
report.add("error", "invalid_adjust_minutes", filepath.Join(root, "data", taskEventsDirName), "un ajuste no puede ser cero", event.ID)
}
case "status":
if !validTaskStatus(event.Status) {
report.add("error", "invalid_status_event", filepath.Join(root, "data", taskEventsDirName), "status no es válido", event.Status)
}
default:
report.add("error", "invalid_task_event_type", filepath.Join(root, "data", taskEventsDirName), "tipo de evento desconocido", event.Type)
}
}
for taskID, minutes := range totalMinutes {
if minutes < 0 {
report.add("error", "negative_task_time", filepath.Join(root, "data", taskEventsDirName), "los eventos dejan el tiempo acumulado por debajo de cero", map[string]any{
"task_id": taskID, "actual_minutes": minutes,
})
}
}
// La lectura estable de errores facilita comparaciones en CI y por agentes.
sort.Slice(report.Errors, func(i, j int) bool {
if report.Errors[i].Path == report.Errors[j].Path {
return report.Errors[i].Code < report.Errors[j].Code
}
return report.Errors[i].Path < report.Errors[j].Path
})
sort.Slice(report.Warnings, func(i, j int) bool {
if report.Warnings[i].Path == report.Warnings[j].Path {
return report.Warnings[i].Code < report.Warnings[j].Code
}
return report.Warnings[i].Path < report.Warnings[j].Path
})
}