Files
kandev/apps/backend/internal/office/shared/cron.go
T
João Salavisa cab7b28ed9 chore: round 4 dead-code cleanup (transport helpers, detect, orphan methods) (#1156)
* chore(agentctl): delete duplicate procattr files in adapter package

setProcGroup and killProcessGroup are defined in both
agentctl/server/adapter/ and agentctl/server/process/. The process/
copies are the live ones (called by process/runner.go, manager.go,
vscode.go); the adapter/ copies have zero callers and are removed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(agentctl): remove orphan helpers from transport/shared

Left over from the ACP refactor. All had zero callers:
- DebugEnabled(): internal debugMode var is checked directly by
  LogRawEvent/LogNormalizedEvent — the exported wrapper was unused.
- GetBool, GetMap, GetSlice: only GetString/GetInt are called in acp/normalize.go.
- NormalizeShellResult, NormalizeReadResult, NormalizeCodeSearchResult,
  NormalizeModifyResult: normalization moved to acp/normalize.go.
- TruncateIfNeeded, MaxContentLength, MaxFileCount: only used by the
  four Normalize* helpers above; removed transitively.
- TraceControlMessage: zero callers in the adapter transport layer.

GenerateUnifiedDiff, SplitLines, DetectLanguage, Tracer, ShutdownTracing,
TraceProtocolEvent, TraceProtocolRequest are alive and kept.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(agentctl): remove unused tool_payload constructors

NewHttpRequest, NewCreateTask, NewSubagentTask, NewShowPlan,
NewManageTodos, and NewMisc have zero callers — ACP normalization
builds NormalizedPayload via struct literals and JSON unmarshaling,
not these factory functions. NewReadFile, NewModifyFile, NewShellExec,
NewCodeSearch, and NewGeneric are kept (live callers in acp/normalize.go).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(agents/detect): remove dead fluent options and OSPaths type

WithFileExists, WithCommandOutput, WithEnvVar, expandHomePath (their
transitive helper), and OSPaths (struct + Resolve + Expanded) have
zero callers. Only WithCommand and WithNpxRunnable are used by agent
IsInstalled implementations. Imports trimmed to context and os/exec.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(office): remove dead channel methods superseded by service/

ListComments, DeleteComment, and UpdateChannelStatus on ChannelService
have zero callers — the live implementations live in
internal/office/service/comments.go and service/channels.go.
CreateComment is kept: it is called by HandleChannelInbound.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: remove scattered orphan helpers

Remove dead single-symbol exports verified to have zero callers:
- ParseCronExpression (office/shared/cron.go): NextCronTime covers
  all live usages; the validator wrapper was unused.
- IsValidPathComponent, MustJSON (office/shared/helpers.go): zero
  callers; GenerateSlug is kept.
- DiscoverUserSkillsAt (office/skills/userhome_discovery.go): exported
  wrapper over the private discoverUserSkillsAt; only the internal
  DiscoverUserSkills method is wired up.
- Manager.Get (agent/loginpty/manager.go): zero callers; GetByID and
  Stop are kept (Stop is tested in manager_test.go).
- DetachedWithValues (common/appctx/appctx.go): zero callers; Detached
  is kept.
- Repository.Close no-ops (task/share, terminal/repository): both are
  documented no-ops with zero callers.
- TaskBranchName (worktree/config.go): zero callers; the deterministic
  TaskBranchNameWithSuffix is kept.

Note: dialect.Now was initially targeted but is tested in
dialect_test.go (same-package call — missed by package-qualified grep).
It remains unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(share): remove stale Close reference from provider.go comment

Repository.Close() was removed as a no-op in the round-4 cleanup.
The comment in provider.go still referenced it by name; updated to
reflect that the repo simply doesn't own its DB connections.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(office/shared): delete helpers.go — GenerateSlug had zero callers

The triage step mistakenly identified skills/service.go as a caller,
but that package defines its own local GenerateSlug (different regex,
adds TrimSpace and a 'skill' fallback default). shared.GenerateSlug
was never called anywhere. helpers.go is now empty; delete the file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(worktree): fix stale TaskBranchName reference in TaskBranchNameWithSuffix doc

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:18:10 +01:00

252 lines
6.7 KiB
Go

package shared
import (
"fmt"
"strconv"
"strings"
"time"
)
// NextCronTime computes the next fire time after `after` for the given cron expression.
// Supports standard 5-field cron: minute hour day-of-month month day-of-week.
// Timezone is optional; defaults to UTC.
func NextCronTime(expression, timezone string, after time.Time) (time.Time, error) {
fields := strings.Fields(expression)
if len(fields) != 5 {
return time.Time{}, fmt.Errorf("expected 5 cron fields, got %d", len(fields))
}
loc := time.UTC
if timezone != "" {
var err error
loc, err = time.LoadLocation(timezone)
if err != nil {
return time.Time{}, fmt.Errorf("invalid timezone %q: %w", timezone, err)
}
}
spec, err := parseCronSpec(fields)
if err != nil {
return time.Time{}, err
}
return findNextMatch(spec, after.In(loc)), nil
}
type cronSpec struct {
minutes []int
hours []int
daysOfMonth []int
months []int
daysOfWeek []int
}
// dayOfWeekNames maps abbreviated day names to their numeric cron value.
// Case-insensitive; lookup is performed after lowercasing the token.
var dayOfWeekNames = map[string]int{
"sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6,
}
// monthNames maps abbreviated month names to their numeric cron value.
// Case-insensitive; lookup is performed after lowercasing the token.
var monthNames = map[string]int{
"jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6,
"jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12,
}
func parseCronSpec(fields []string) (*cronSpec, error) {
minutes, err := parseCronField(fields[0], 0, 59, nil)
if err != nil {
return nil, fmt.Errorf("minute field: %w", err)
}
hours, err := parseCronField(fields[1], 0, 23, nil)
if err != nil {
return nil, fmt.Errorf("hour field: %w", err)
}
dom, err := parseCronField(fields[2], 1, 31, nil)
if err != nil {
return nil, fmt.Errorf("day-of-month field: %w", err)
}
months, err := parseCronField(fields[3], 1, 12, monthNames)
if err != nil {
return nil, fmt.Errorf("month field: %w", err)
}
dow, err := parseCronField(fields[4], 0, 6, dayOfWeekNames)
if err != nil {
return nil, fmt.Errorf("day-of-week field: %w", err)
}
return &cronSpec{minutes, hours, dom, months, dow}, nil
}
// parseCronField parses a single cron field (supports *, N, N-M, */N, N-M/N, comma lists).
// If names is non-nil, alphabetic tokens are resolved to numbers via the map
// before numeric parsing (e.g. MON->1, JAN->1). Lookup is case-insensitive.
func parseCronField(field string, min, max int, names map[string]int) ([]int, error) {
var result []int
for _, part := range strings.Split(field, ",") {
vals, err := parseCronPart(part, min, max, names)
if err != nil {
return nil, err
}
result = append(result, vals...)
}
return dedupSort(result), nil
}
func parseCronPart(part string, min, max int, names map[string]int) ([]int, error) {
step, base, err := extractStep(part)
if err != nil {
return nil, err
}
resolved, err := resolveNames(base, names)
if err != nil {
return nil, err
}
lo, hi, err := parseRange(resolved, min, max)
if err != nil {
return nil, err
}
if lo < min || hi > max || lo > hi {
return nil, fmt.Errorf("value out of range [%d-%d] in %q", min, max, part)
}
var vals []int
for i := lo; i <= hi; i += step {
vals = append(vals, i)
}
return vals, nil
}
// resolveNames translates any alphabetic tokens in a cron part (e.g. "MON",
// "mon-fri") into their numeric equivalents using the provided name map.
// Wildcards are passed through. If names is nil and the part contains
// non-numeric tokens, an error is returned by the downstream numeric parser.
func resolveNames(part string, names map[string]int) (string, error) {
if part == "*" || !containsAlpha(part) {
return part, nil
}
if names == nil {
return "", fmt.Errorf("alphabetic name %q not allowed in this field", part)
}
if idx := strings.Index(part, "-"); idx >= 0 {
lo, err := resolveToken(part[:idx], names)
if err != nil {
return "", err
}
hi, err := resolveToken(part[idx+1:], names)
if err != nil {
return "", err
}
return fmt.Sprintf("%d-%d", lo, hi), nil
}
val, err := resolveToken(part, names)
if err != nil {
return "", err
}
return strconv.Itoa(val), nil
}
func resolveToken(tok string, names map[string]int) (int, error) {
if !containsAlpha(tok) {
v, err := strconv.Atoi(tok)
if err != nil {
return 0, fmt.Errorf("invalid value %q", tok)
}
return v, nil
}
v, ok := names[strings.ToLower(tok)]
if !ok {
return 0, fmt.Errorf("unknown name %q", tok)
}
return v, nil
}
func containsAlpha(s string) bool {
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
return true
}
}
return false
}
func extractStep(part string) (int, string, error) {
idx := strings.Index(part, "/")
if idx < 0 {
return 1, part, nil
}
step, err := strconv.Atoi(part[idx+1:])
if err != nil || step <= 0 {
return 0, "", fmt.Errorf("invalid step in %q", part)
}
return step, part[:idx], nil
}
func parseRange(part string, min, max int) (int, int, error) {
if part == "*" {
return min, max, nil
}
if idx := strings.Index(part, "-"); idx >= 0 {
lo, err := strconv.Atoi(part[:idx])
if err != nil {
return 0, 0, fmt.Errorf("invalid range in %q", part)
}
hi, err := strconv.Atoi(part[idx+1:])
if err != nil {
return 0, 0, fmt.Errorf("invalid range in %q", part)
}
return lo, hi, nil
}
val, err := strconv.Atoi(part)
if err != nil {
return 0, 0, fmt.Errorf("invalid value %q", part)
}
return val, val, nil
}
func dedupSort(vals []int) []int {
seen := make(map[int]bool, len(vals))
var result []int
for _, v := range vals {
if !seen[v] {
seen[v] = true
result = append(result, v)
}
}
// Simple insertion sort for small slices.
for i := 1; i < len(result); i++ {
for j := i; j > 0 && result[j] < result[j-1]; j-- {
result[j], result[j-1] = result[j-1], result[j]
}
}
return result
}
func findNextMatch(spec *cronSpec, after time.Time) time.Time {
// Start from the next minute after `after`.
t := after.Truncate(time.Minute).Add(time.Minute)
// Search up to 366 days ahead.
deadline := after.Add(366 * 24 * time.Hour)
for t.Before(deadline) {
if matchesSpec(spec, t) {
return t.UTC()
}
t = t.Add(time.Minute)
}
// Fallback: return 24h from now.
return after.Add(24 * time.Hour).UTC()
}
func matchesSpec(spec *cronSpec, t time.Time) bool {
return contains(spec.minutes, t.Minute()) &&
contains(spec.hours, t.Hour()) &&
contains(spec.daysOfMonth, t.Day()) &&
contains(spec.months, int(t.Month())) &&
contains(spec.daysOfWeek, int(t.Weekday()))
}
func contains(vals []int, v int) bool {
for _, val := range vals {
if val == v {
return true
}
}
return false
}