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
+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})
}