diff --git a/docs/frontmatter.md b/docs/frontmatter.md index 0c5ea94728..a633c65777 100644 --- a/docs/frontmatter.md +++ b/docs/frontmatter.md @@ -18,9 +18,10 @@ The YAML frontmatter supports standard GitHub Actions properties plus additional - `steps`: Custom steps for the job **Agentic-Specific Properties:** -- `engine`: AI executor to use (`claude`, `codex`, etc.) -- `tools`: Tools configuration (GitHub tools, Engine-specific tools, MCP servers etc.) -- `stop-time`: Deadline timestamp when workflow should stop running +- `engine`: AI engine configuration (claude/codex) +- `tools`: Available tools and MCP servers for the AI engine +- `stop-time`: Deadline when workflow should stop running (absolute or relative time) +- `alias`: Alias name for the workflow - `ai-reaction`: Emoji reaction to add/remove on triggering GitHub item - `cache`: Cache configuration for workflow dependencies @@ -187,16 +188,31 @@ engine: ### Stop Time (`stop-time:`) -Automatically disable workflow after a deadline: +Automatically disable workflow after a deadline. Supports both absolute timestamps and relative time deltas: +**Absolute time (multiple formats supported):** ```yaml -stop-time: "2025-12-31 23:59:59" ``` -**Behavior:** -1. Checks deadline before each run -2. Disables workflow if deadline passed -3. Allows current run to complete +**Relative time delta (calculated from compilation time):** +```yaml +stop-time: "+25h" # 25 hours from now +``` + +**Supported absolute date formats:** +- Standard: `YYYY-MM-DD HH:MM:SS`, `YYYY-MM-DD` +- US format: `MM/DD/YYYY HH:MM:SS`, `MM/DD/YYYY` +- European: `DD/MM/YYYY HH:MM:SS`, `DD/MM/YYYY` +- Readable: `January 2, 2006`, `2 January 2006`, `Jan 2, 2006` +- Ordinals: `1st June 2025`, `June 1st 2025`, `23rd December 2025` +- ISO 8601: `2006-01-02T15:04:05Z` + +**Supported delta units:** +- `d` - days +- `h` - hours +- `m` - minutes + +Note that if you specify a relative time, it is calculated at the time of workflow compilation, not when the workflow runs. If you re-compile your workflow, e.g. after a change, the effective stop time will be reset. ## Visual Feedback (`ai-reaction:`) diff --git a/docs/security-notes.md b/docs/security-notes.md index 7e31681cf7..7f727a632b 100644 --- a/docs/security-notes.md +++ b/docs/security-notes.md @@ -87,6 +87,16 @@ GitHub Actions workflows are designed to be steps within a larger process. Some - **Plan-apply separation**: Implement a "plan" phase that generates a preview of actions before execution. This allows human reviewers to assess the impact of changes. This is usually done via an output issue or pull request. - **Review and audit**: Regularly review workflow history, permissions, and tool usage to ensure compliance with security policies. +### Limit time of operation + +Use `stop-time:` to limit the time of operation of an agentic workflow. For example, using + +```yaml +stop-time: +7d +``` + +will mean the agentic workflow no longer operates 7 days after time of compilation. + ### MCP Tool Hardening Model Context Protocol tools require strict containment: diff --git a/docs/workflow-structure.md b/docs/workflow-structure.md index e6d4ab4412..9a7552ac04 100644 --- a/docs/workflow-structure.md +++ b/docs/workflow-structure.md @@ -85,7 +85,6 @@ cache: key: node-modules-${{ hashFiles('package-lock.json') }} path: node_modules -stop-time: "2025-12-31 23:59:59" ai-reaction: "eyes" --- diff --git a/pkg/cli/commands.go b/pkg/cli/commands.go index cb7337f63b..ca731ce69b 100644 --- a/pkg/cli/commands.go +++ b/pkg/cli/commands.go @@ -1099,8 +1099,8 @@ func StatusWorkflows(pattern string, verbose bool) error { fmt.Println("Workflow Status:") fmt.Println("================") - fmt.Printf("%-30s %-12s %-12s %-10s\n", "Name", "Installed", "Up-to-date", "Status") - fmt.Printf("%-30s %-12s %-12s %-10s\n", "----", "---------", "----------", "------") + fmt.Printf("%-30s %-12s %-12s %-10s %-20s\n", "Name", "Installed", "Up-to-date", "Status", "Time Remaining") + fmt.Printf("%-30s %-12s %-12s %-10s %-20s\n", "----", "---------", "----------", "------", "--------------") for _, file := range mdFiles { base := filepath.Base(file) @@ -1115,6 +1115,7 @@ func StatusWorkflows(pattern string, verbose bool) error { lockFile := strings.TrimSuffix(file, ".md") + ".lock.yml" compiled := "No" upToDate := "N/A" + timeRemaining := "N/A" if _, err := os.Stat(lockFile); err == nil { compiled = "Yes" @@ -1127,6 +1128,11 @@ func StatusWorkflows(pattern string, verbose bool) error { } else { upToDate = "Yes" } + + // Extract stop-time from lock file + if stopTime := extractStopTimeFromLockFile(lockFile); stopTime != "" { + timeRemaining = calculateTimeRemaining(stopTime) + } } // Get GitHub workflow status @@ -1139,12 +1145,74 @@ func StatusWorkflows(pattern string, verbose bool) error { } } - fmt.Printf("%-30s %-12s %-12s %-10s\n", name, compiled, upToDate, status) + fmt.Printf("%-30s %-12s %-12s %-10s %-20s\n", name, compiled, upToDate, status, timeRemaining) } return nil } +// extractStopTimeFromLockFile extracts the STOP_TIME value from a compiled workflow lock file +func extractStopTimeFromLockFile(lockFilePath string) string { + content, err := os.ReadFile(lockFilePath) + if err != nil { + return "" + } + + // Look for the STOP_TIME line in the safety checks section + // Pattern: STOP_TIME="YYYY-MM-DD HH:MM:SS" + lines := strings.Split(string(content), "\n") + for _, line := range lines { + if strings.Contains(line, "STOP_TIME=") { + // Extract the value between quotes + start := strings.Index(line, `"`) + 1 + end := strings.LastIndex(line, `"`) + if start > 0 && end > start { + return line[start:end] + } + } + } + return "" +} + +// calculateTimeRemaining calculates and formats the time remaining until stop-time +func calculateTimeRemaining(stopTimeStr string) string { + if stopTimeStr == "" { + return "N/A" + } + + // Parse the stop time + stopTime, err := time.Parse("2006-01-02 15:04:05", stopTimeStr) + if err != nil { + return "Invalid" + } + + now := time.Now() + remaining := stopTime.Sub(now) + + // If already past the stop time + if remaining <= 0 { + return "Expired" + } + + // Format the remaining time in a human-readable way + days := int(remaining.Hours() / 24) + hours := int(remaining.Hours()) % 24 + minutes := int(remaining.Minutes()) % 60 + + if days > 0 { + if days == 1 { + return fmt.Sprintf("%dd %dh", days, hours) + } + return fmt.Sprintf("%dd %dh", days, hours) + } else if hours > 0 { + return fmt.Sprintf("%dh %dm", hours, minutes) + } else if minutes > 0 { + return fmt.Sprintf("%dm", minutes) + } else { + return "< 1m" + } +} + // EnableWorkflows enables workflows matching a pattern func EnableWorkflows(pattern string) error { return toggleWorkflows(pattern, true) diff --git a/pkg/cli/commands_test.go b/pkg/cli/commands_test.go index 7f9b7d4d60..7fafb674ff 100644 --- a/pkg/cli/commands_test.go +++ b/pkg/cli/commands_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" "testing" + "time" ) // Test the CLI functions that are exported from this package @@ -1229,3 +1230,152 @@ This workflow uses an include. // since it requires interactive confirmation. The logic is tested through // previewOrphanedIncludes and the flag handling is straightforward. } + +// TestExtractStopTimeFromLockFile tests the extractStopTimeFromLockFile function +func TestExtractStopTimeFromLockFile(t *testing.T) { + // Create temporary directory for test files + tmpDir := t.TempDir() + + tests := []struct { + name string + content string + expectedTime string + }{ + { + name: "valid stop-time in lock file", + content: `# This file was automatically generated by gh-aw +name: "Test Workflow" +jobs: + test: + steps: + - name: Safety checks + run: | + STOP_TIME="2025-12-31 23:59:59" + echo "Checking stop-time limit: $STOP_TIME"`, + expectedTime: "2025-12-31 23:59:59", + }, + { + name: "no stop-time in lock file", + content: `# This file was automatically generated by gh-aw +name: "Test Workflow" +jobs: + test: + steps: + - name: Run tests + run: echo "No stop time here"`, + expectedTime: "", + }, + { + name: "malformed stop-time line", + content: `# This file was automatically generated by gh-aw +name: "Test Workflow" +jobs: + test: + steps: + - name: Safety checks + run: | + STOP_TIME=malformed-no-quotes + echo "Invalid format"`, + expectedTime: "", + }, + { + name: "multiple stop-time lines (should get first)", + content: `# This file was automatically generated by gh-aw +name: "Test Workflow" +jobs: + test: + steps: + - name: Safety checks + run: | + STOP_TIME="2025-06-01 12:00:00" + echo "Checking stop-time limit: $STOP_TIME" + STOP_TIME="2025-07-01 12:00:00"`, + expectedTime: "2025-06-01 12:00:00", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create test lock file + lockFile := filepath.Join(tmpDir, tt.name+".lock.yml") + err := os.WriteFile(lockFile, []byte(tt.content), 0644) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // Test extraction + result := extractStopTimeFromLockFile(lockFile) + if result != tt.expectedTime { + t.Errorf("extractStopTimeFromLockFile() = %q, want %q", result, tt.expectedTime) + } + }) + } + + // Test non-existent file + t.Run("non-existent file", func(t *testing.T) { + result := extractStopTimeFromLockFile("/non/existent/file.lock.yml") + if result != "" { + t.Errorf("extractStopTimeFromLockFile() for non-existent file = %q, want empty string", result) + } + }) +} + +// TestCalculateTimeRemaining tests the calculateTimeRemaining function +func TestCalculateTimeRemaining(t *testing.T) { + tests := []struct { + name string + stopTimeStr string + expected string + }{ + { + name: "empty stop time", + stopTimeStr: "", + expected: "N/A", + }, + { + name: "invalid format", + stopTimeStr: "invalid-date-format", + expected: "Invalid", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := calculateTimeRemaining(tt.stopTimeStr) + if result != tt.expected { + t.Errorf("calculateTimeRemaining(%q) = %q, want %q", tt.stopTimeStr, result, tt.expected) + } + }) + } + + // Test with future time - this will test the logic but the exact result depends on current time + t.Run("future time formatting", func(t *testing.T) { + // Create a time 2 hours and 30 minutes in the future + futureTime := time.Now().Add(2*time.Hour + 30*time.Minute) + stopTimeStr := futureTime.Format("2006-01-02 15:04:05") + + result := calculateTimeRemaining(stopTimeStr) + + // Should contain "h" and "m" for hours and minutes + if !strings.Contains(result, "h") || !strings.Contains(result, "m") { + t.Errorf("calculateTimeRemaining() for future time should contain hours and minutes, got: %q", result) + } + + // Should not be "Expired", "Invalid", or "N/A" + if result == "Expired" || result == "Invalid" || result == "N/A" { + t.Errorf("calculateTimeRemaining() for future time should not be %q", result) + } + }) + + // Test with past time + t.Run("past time - expired", func(t *testing.T) { + // Create a time 1 hour in the past + pastTime := time.Now().Add(-1 * time.Hour) + stopTimeStr := pastTime.Format("2006-01-02 15:04:05") + + result := calculateTimeRemaining(stopTimeStr) + if result != "Expired" { + t.Errorf("calculateTimeRemaining() for past time = %q, want %q", result, "Expired") + } + }) +} diff --git a/pkg/cli/templates/instructions.md b/pkg/cli/templates/instructions.md index 5826370ee0..593c5183bf 100644 --- a/pkg/cli/templates/instructions.md +++ b/pkg/cli/templates/instructions.md @@ -74,7 +74,7 @@ The YAML frontmatter supports these fields: - `claude:` - Claude-specific tools - Custom tool names for MCP servers -- **`stop-time:`** - Deadline timestamp for workflow (string: "YYYY-MM-DD HH:MM:SS") +- **`stop-time:`** - Deadline for workflow. Can be absolute timestamp ("YYYY-MM-DD HH:MM:SS") or relative delta (+25h, +3d, +1d12h30m) - **`alias:`** - Alternative workflow name (string) - **`cache:`** - Cache configuration for workflow dependencies (object or array) diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index da575eb0d9..5c74697bd9 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -373,7 +373,7 @@ }, "stop-time": { "type": "string", - "description": "Time when workflow should stop running" + "description": "Time when workflow should stop running. Supports multiple formats: absolute dates (YYYY-MM-DD HH:MM:SS, June 1 2025, 1st June 2025, 06/01/2025, etc.) or relative time deltas (+25h, +3d, +1d12h30m)" }, "alias": { "type": "string", diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 2828a95fdf..4abe33b681 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -640,6 +640,23 @@ func (c *Compiler) parseWorkflowFile(markdownPath string) (*WorkflowData, error) workflowData.RunsOn = c.extractTopLevelYAMLSection(result.Frontmatter, "runs-on") workflowData.Cache = c.extractTopLevelYAMLSection(result.Frontmatter, "cache") workflowData.StopTime = c.extractYAMLValue(result.Frontmatter, "stop-time") + + // Resolve relative stop-time to absolute time if needed + if workflowData.StopTime != "" { + resolvedStopTime, err := resolveStopTime(workflowData.StopTime, time.Now()) + if err != nil { + return nil, fmt.Errorf("invalid stop-time format: %w", err) + } + originalStopTime := c.extractYAMLValue(result.Frontmatter, "stop-time") + workflowData.StopTime = resolvedStopTime + + if c.verbose && isRelativeStopTime(originalStopTime) { + fmt.Println(console.FormatInfoMessage(fmt.Sprintf("Resolved relative stop-time to: %s", resolvedStopTime))) + } else if c.verbose && originalStopTime != resolvedStopTime { + fmt.Println(console.FormatInfoMessage(fmt.Sprintf("Parsed absolute stop-time from '%s' to: %s", originalStopTime, resolvedStopTime))) + } + } + workflowData.Alias = c.extractAliasName(result.Frontmatter) workflowData.AIReaction = c.extractYAMLValue(result.Frontmatter, "ai-reaction") workflowData.Jobs = c.extractJobsFromFrontmatter(result.Frontmatter) @@ -1420,6 +1437,13 @@ func (c *Compiler) generateYAML(data *WorkflowData) (string, error) { yaml.WriteString("# This file was automatically generated by gh-aw. DO NOT EDIT.\n") yaml.WriteString("# To update this file, edit the corresponding .md file and run:\n") yaml.WriteString("# " + constants.CLIExtensionPrefix + " compile\n") + + // Add stop-time comment if configured + if data.StopTime != "" { + yaml.WriteString("#\n") + yaml.WriteString(fmt.Sprintf("# Effective stop-time: %s\n", data.StopTime)) + } + yaml.WriteString("\n") // Write basic workflow structure diff --git a/pkg/workflow/time_delta.go b/pkg/workflow/time_delta.go new file mode 100644 index 0000000000..c4ba24cafe --- /dev/null +++ b/pkg/workflow/time_delta.go @@ -0,0 +1,265 @@ +package workflow + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "time" +) + +// TimeDelta represents a time duration that can be added to a base time +type TimeDelta struct { + Hours int + Days int + Minutes int +} + +// parseTimeDelta parses a relative time delta string like "+25h", "+3d", "+1d12h30m", etc. +// Supported formats: +// - +25h (25 hours) +// - +3d (3 days) +// - +30m (30 minutes) +// - +1d12h (1 day and 12 hours) +// - +2d5h30m (2 days, 5 hours, 30 minutes) +func parseTimeDelta(deltaStr string) (*TimeDelta, error) { + if deltaStr == "" { + return nil, fmt.Errorf("empty time delta") + } + + // Must start with '+' + if !strings.HasPrefix(deltaStr, "+") { + return nil, fmt.Errorf("time delta must start with '+', got: %s", deltaStr) + } + + // Remove the '+' prefix + deltaStr = deltaStr[1:] + + if deltaStr == "" { + return nil, fmt.Errorf("empty time delta after '+'") + } + + // Parse components using regex + // Pattern matches: number followed by d/h/m + pattern := regexp.MustCompile(`(\d+)([dhm])`) + matches := pattern.FindAllStringSubmatch(deltaStr, -1) + + if len(matches) == 0 { + return nil, fmt.Errorf("invalid time delta format: +%s. Expected format like +25h, +3d, +1d12h30m", deltaStr) + } + + // Check that all characters are consumed by matches + consumed := 0 + for _, match := range matches { + consumed += len(match[0]) + } + if consumed != len(deltaStr) { + return nil, fmt.Errorf("invalid time delta format: +%s. Extra characters detected", deltaStr) + } + + delta := &TimeDelta{} + seenUnits := make(map[string]bool) + + for _, match := range matches { + if len(match) != 3 { + continue + } + + valueStr := match[1] + unit := match[2] + + // Check for duplicate units + if seenUnits[unit] { + return nil, fmt.Errorf("duplicate unit '%s' in time delta: +%s", unit, deltaStr) + } + seenUnits[unit] = true + + value, err := strconv.Atoi(valueStr) + if err != nil { + return nil, fmt.Errorf("invalid number '%s' in time delta: +%s", valueStr, deltaStr) + } + + if value < 0 { + return nil, fmt.Errorf("negative values not allowed in time delta: +%s", deltaStr) + } + + switch unit { + case "d": + delta.Days = value + case "h": + delta.Hours = value + case "m": + delta.Minutes = value + default: + return nil, fmt.Errorf("unsupported time unit '%s' in time delta: +%s", unit, deltaStr) + } + } + + // Validate reasonable limits + if delta.Days > 365 { + return nil, fmt.Errorf("time delta too large: %d days exceeds maximum of 365 days", delta.Days) + } + if delta.Hours > 8760 { // 365 * 24 + return nil, fmt.Errorf("time delta too large: %d hours exceeds maximum of 8760 hours", delta.Hours) + } + if delta.Minutes > 525600 { // 365 * 24 * 60 + return nil, fmt.Errorf("time delta too large: %d minutes exceeds maximum of 525600 minutes", delta.Minutes) + } + + return delta, nil +} + +// toDuration converts a TimeDelta to a Go time.Duration +func (td *TimeDelta) toDuration() time.Duration { + duration := time.Duration(td.Days) * 24 * time.Hour + duration += time.Duration(td.Hours) * time.Hour + duration += time.Duration(td.Minutes) * time.Minute + return duration +} + +// String returns a human-readable representation of the TimeDelta +func (td *TimeDelta) String() string { + var parts []string + if td.Days > 0 { + parts = append(parts, fmt.Sprintf("%dd", td.Days)) + } + if td.Hours > 0 { + parts = append(parts, fmt.Sprintf("%dh", td.Hours)) + } + if td.Minutes > 0 { + parts = append(parts, fmt.Sprintf("%dm", td.Minutes)) + } + if len(parts) == 0 { + return "0m" + } + return "+" + strings.Join(parts, "") +} + +// isRelativeStopTime checks if a stop-time value is a relative time delta +func isRelativeStopTime(stopTime string) bool { + return strings.HasPrefix(stopTime, "+") +} + +// parseAbsoluteDateTime parses various date-time formats and returns a standardized timestamp +func parseAbsoluteDateTime(dateTimeStr string) (string, error) { + // Try multiple date-time formats in order of preference + formats := []string{ + // Standard formats + "2006-01-02 15:04:05", // YYYY-MM-DD HH:MM:SS + "2006-01-02T15:04:05", // ISO 8601 without timezone + "2006-01-02T15:04:05Z", // ISO 8601 UTC + "2006-01-02 15:04", // YYYY-MM-DD HH:MM + "2006-01-02", // YYYY-MM-DD (defaults to start of day) + + // Alternative formats + "01/02/2006 15:04:05", // MM/DD/YYYY HH:MM:SS + "01/02/2006 15:04", // MM/DD/YYYY HH:MM + "01/02/2006", // MM/DD/YYYY + "02/01/2006 15:04:05", // DD/MM/YYYY HH:MM:SS + "02/01/2006 15:04", // DD/MM/YYYY HH:MM + "02/01/2006", // DD/MM/YYYY + + // Readable formats + "January 2, 2006 15:04:05", // January 2, 2006 15:04:05 + "January 2, 2006 15:04", // January 2, 2006 15:04 + "January 2, 2006", // January 2, 2006 + "Jan 2, 2006 15:04:05", // Jan 2, 2006 15:04:05 + "Jan 2, 2006 15:04", // Jan 2, 2006 15:04 + "Jan 2, 2006", // Jan 2, 2006 + "2 January 2006 15:04:05", // 2 January 2006 15:04:05 + "2 January 2006 15:04", // 2 January 2006 15:04 + "2 January 2006", // 2 January 2006 + "2 Jan 2006 15:04:05", // 2 Jan 2006 15:04:05 + "2 Jan 2006 15:04", // 2 Jan 2006 15:04 + "2 Jan 2006", // 2 Jan 2006 + "January 2 2006 15:04:05", // January 2 2006 15:04:05 (no comma) + "January 2 2006 15:04", // January 2 2006 15:04 (no comma) + "January 2 2006", // January 2 2006 (no comma) + "Jan 2 2006 15:04:05", // Jan 2 2006 15:04:05 (no comma) + "Jan 2 2006 15:04", // Jan 2 2006 15:04 (no comma) + "Jan 2 2006", // Jan 2 2006 (no comma) + + // RFC formats + time.RFC3339, // 2006-01-02T15:04:05Z07:00 + time.RFC822, // 02 Jan 06 15:04 MST + time.RFC850, // Monday, 02-Jan-06 15:04:05 MST + time.RFC1123, // Mon, 02 Jan 2006 15:04:05 MST + } + + // Clean up the input string + dateTimeStr = strings.TrimSpace(dateTimeStr) + + // Handle ordinal numbers (1st, 2nd, 3rd, 4th, etc.) + ordinalPattern := regexp.MustCompile(`\b(\d+)(st|nd|rd|th)\b`) + dateTimeStr = ordinalPattern.ReplaceAllString(dateTimeStr, "$1") + + // Try to parse with each format + for _, format := range formats { + if parsed, err := time.Parse(format, dateTimeStr); err == nil { + // Successfully parsed, return in standard format + return parsed.Format("2006-01-02 15:04:05"), nil + } + } + + // Try with more flexible ordinal handling - sometimes the ordinal removal creates double spaces + normalizedStr := strings.ReplaceAll(dateTimeStr, " ", " ") + normalizedStr = strings.TrimSpace(normalizedStr) + + for _, format := range formats { + if parsed, err := time.Parse(format, normalizedStr); err == nil { + // Successfully parsed, return in standard format + return parsed.Format("2006-01-02 15:04:05"), nil + } + } + + // If none of the standard formats work, try some smart parsing + // Handle formats like "June 1st 2025", "1st June 2025", etc. + smartFormats := []string{ + "January 2nd 2006", + "2nd January 2006", + "Jan 2nd 2006", + "2nd Jan 2006", + "January 2nd 2006 15:04", + "2nd January 2006 15:04", + "Jan 2nd 2006 15:04", + "2nd Jan 2006 15:04", + "January 2nd 2006 15:04:05", + "2nd January 2006 15:04:05", + "Jan 2nd 2006 15:04:05", + "2nd Jan 2006 15:04:05", + } + + for _, format := range smartFormats { + if parsed, err := time.Parse(format, dateTimeStr); err == nil { + return parsed.Format("2006-01-02 15:04:05"), nil + } + } + + return "", fmt.Errorf("unable to parse date-time: %s. Supported formats include: YYYY-MM-DD HH:MM:SS, MM/DD/YYYY, January 2 2006, 1st June 2025, etc.", dateTimeStr) +} + +// resolveStopTime resolves a stop-time value to an absolute timestamp +// If the stop-time is relative (starts with '+'), it calculates the absolute time +// from the compilation time. Otherwise, it parses the absolute time using various formats. +func resolveStopTime(stopTime string, compilationTime time.Time) (string, error) { + if stopTime == "" { + return "", nil + } + + if isRelativeStopTime(stopTime) { + // Parse the relative time delta + delta, err := parseTimeDelta(stopTime) + if err != nil { + return "", err + } + + // Calculate absolute time + absoluteTime := compilationTime.Add(delta.toDuration()) + + // Format in the expected format: "YYYY-MM-DD HH:MM:SS" + return absoluteTime.Format("2006-01-02 15:04:05"), nil + } + + // Parse absolute date-time with flexible format support + return parseAbsoluteDateTime(stopTime) +} diff --git a/pkg/workflow/time_delta_integration_test.go b/pkg/workflow/time_delta_integration_test.go new file mode 100644 index 0000000000..88f905a208 --- /dev/null +++ b/pkg/workflow/time_delta_integration_test.go @@ -0,0 +1,262 @@ +package workflow + +import ( + "fmt" + "os" + "strings" + "testing" + "time" +) + +func TestStopTimeResolutionIntegration(t *testing.T) { + tests := []struct { + name string + frontmatter string + markdown string + expectStopTime bool + shouldContain string + }{ + { + name: "absolute stop-time unchanged", + frontmatter: `--- +engine: claude +on: + schedule: + - cron: "0 9 * * 1" +stop-time: "2025-12-31 23:59:59" +---`, + markdown: "# Test Workflow\n\nThis is a test workflow.", + expectStopTime: true, + shouldContain: "2025-12-31 23:59:59", + }, + { + name: "readable date format", + frontmatter: `--- +engine: claude +on: + schedule: + - cron: "0 9 * * 1" +stop-time: "June 1, 2025" +---`, + markdown: "# Test Workflow\n\nThis is a test workflow.", + expectStopTime: true, + shouldContain: "2025-06-01 00:00:00", + }, + { + name: "ordinal date format", + frontmatter: `--- +engine: claude +on: + schedule: + - cron: "0 9 * * 1" +stop-time: "1st June 2025" +---`, + markdown: "# Test Workflow\n\nThis is a test workflow.", + expectStopTime: true, + shouldContain: "2025-06-01 00:00:00", + }, + { + name: "US date format", + frontmatter: `--- +engine: claude +on: + schedule: + - cron: "0 9 * * 1" +stop-time: "06/01/2025 15:30" +---`, + markdown: "# Test Workflow\n\nThis is a test workflow.", + expectStopTime: true, + shouldContain: "2025-06-01 15:30:00", + }, + { + name: "relative stop-time gets resolved", + frontmatter: `--- +engine: claude +on: + schedule: + - cron: "0 9 * * 1" +stop-time: "+25h" +---`, + markdown: "# Test Workflow\n\nThis is a test workflow.", + expectStopTime: true, + shouldContain: "", // We'll check the format but not exact time + }, + { + name: "complex relative stop-time gets resolved", + frontmatter: `--- +engine: claude +on: + schedule: + - cron: "0 9 * * 1" +stop-time: "+1d12h30m" +---`, + markdown: "# Test Workflow\n\nThis is a test workflow.", + expectStopTime: true, + shouldContain: "", // We'll check the format but not exact time + }, + { + name: "no stop-time specified", + frontmatter: `--- +engine: claude +on: + schedule: + - cron: "0 9 * * 1" +---`, + markdown: "# Test Workflow\n\nThis is a test workflow.", + expectStopTime: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temporary files + tmpDir := t.TempDir() + mdFile := tmpDir + "/test-workflow.md" + lockFile := tmpDir + "/test-workflow.lock.yml" + + // Write the test workflow + content := tt.frontmatter + "\n\n" + tt.markdown + err := os.WriteFile(mdFile, []byte(content), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + // Compile the workflow + compiler := NewCompiler(false, "", "test-version") + err = compiler.CompileWorkflow(mdFile) + if err != nil { + t.Fatalf("Failed to compile workflow: %v", err) + } + + // Check that the lock file was created + if _, err := os.Stat(lockFile); os.IsNotExist(err) { + t.Fatalf("Lock file was not created: %s", lockFile) + } + + // Read the compiled workflow + compiledContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read compiled workflow: %v", err) + } + + compiledStr := string(compiledContent) + + if tt.expectStopTime { + // Should contain stop-time check + if !strings.Contains(compiledStr, "STOP_TIME=") { + t.Error("Compiled workflow should contain stop-time check but doesn't") + } + + if tt.shouldContain != "" { + // Check for specific absolute time + if !strings.Contains(compiledStr, tt.shouldContain) { + t.Errorf("Compiled workflow should contain %q but doesn't", tt.shouldContain) + } + } else { + // For relative times, check that the format looks like a resolved timestamp + // Extract the STOP_TIME value + lines := strings.Split(compiledStr, "\n") + var stopTimeLine string + for _, line := range lines { + if strings.Contains(line, "STOP_TIME=") { + stopTimeLine = line + break + } + } + + if stopTimeLine == "" { + t.Error("Could not find STOP_TIME line in compiled workflow") + return + } + + // Extract the timestamp value (between quotes) + start := strings.Index(stopTimeLine, `"`) + 1 + end := strings.LastIndex(stopTimeLine, `"`) + if start <= 0 || end <= start { + t.Error("Could not extract STOP_TIME value from line: " + stopTimeLine) + return + } + + timestamp := stopTimeLine[start:end] + + // Parse as timestamp to verify it's valid + _, err := time.Parse("2006-01-02 15:04:05", timestamp) + if err != nil { + t.Errorf("STOP_TIME value %q is not a valid timestamp: %v", timestamp, err) + } + + // Verify it's in the future (relative to now) + parsedTime, _ := time.Parse("2006-01-02 15:04:05", timestamp) + if parsedTime.Before(time.Now()) { + t.Errorf("Resolved stop-time %q is in the past, expected future time", timestamp) + } + } + } else { + // Should not contain stop-time check + if strings.Contains(compiledStr, "STOP_TIME=") { + t.Error("Compiled workflow should not contain stop-time check but does") + } + } + }) + } +} + +func TestStopTimeResolutionError(t *testing.T) { + tests := []struct { + name string + stopTime string + expectedErr string + }{ + { + name: "invalid relative format", + stopTime: "+invalid", + expectedErr: "invalid stop-time format", + }, + { + name: "invalid absolute format", + stopTime: "not-a-date", + expectedErr: "invalid stop-time format", + }, + { + name: "invalid month name", + stopTime: "Foo 1, 2025", + expectedErr: "invalid stop-time format", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + mdFile := tmpDir + "/test-workflow.md" + + content := fmt.Sprintf(`--- +engine: claude +on: + schedule: + - cron: "0 9 * * 1" +stop-time: "%s" +--- + +# Test Workflow + +This is a test workflow with invalid stop-time.`, tt.stopTime) + + err := os.WriteFile(mdFile, []byte(content), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + // Compile the workflow - should fail + compiler := NewCompiler(false, "", "test-version") + err = compiler.CompileWorkflow(mdFile) + if err == nil { + t.Errorf("Expected compilation to fail with invalid stop-time format %q but it succeeded", tt.stopTime) + return + } + + if !strings.Contains(err.Error(), tt.expectedErr) { + t.Errorf("Expected error to mention %q but got: %v", tt.expectedErr, err) + } + }) + } +} diff --git a/pkg/workflow/time_delta_test.go b/pkg/workflow/time_delta_test.go new file mode 100644 index 0000000000..ac472d6fda --- /dev/null +++ b/pkg/workflow/time_delta_test.go @@ -0,0 +1,614 @@ +package workflow + +import ( + "testing" + "time" +) + +func TestParseTimeDelta(t *testing.T) { + tests := []struct { + name string + input string + expected *TimeDelta + expectError bool + errorMsg string + }{ + // Valid cases + { + name: "hours only", + input: "+25h", + expected: &TimeDelta{Hours: 25}, + }, + { + name: "days only", + input: "+3d", + expected: &TimeDelta{Days: 3}, + }, + { + name: "minutes only", + input: "+30m", + expected: &TimeDelta{Minutes: 30}, + }, + { + name: "days and hours", + input: "+1d12h", + expected: &TimeDelta{Days: 1, Hours: 12}, + }, + { + name: "all units", + input: "+2d5h30m", + expected: &TimeDelta{Days: 2, Hours: 5, Minutes: 30}, + }, + { + name: "different order", + input: "+5h2d30m", + expected: &TimeDelta{Days: 2, Hours: 5, Minutes: 30}, + }, + { + name: "single digit", + input: "+1d", + expected: &TimeDelta{Days: 1}, + }, + { + name: "large numbers", + input: "+100h", + expected: &TimeDelta{Hours: 100}, + }, + { + name: "zero values allowed in middle", + input: "+0d5h", + expected: &TimeDelta{Days: 0, Hours: 5}, + }, + + // Error cases + { + name: "empty string", + input: "", + expectError: true, + errorMsg: "empty time delta", + }, + { + name: "no plus prefix", + input: "25h", + expectError: true, + errorMsg: "time delta must start with '+'", + }, + { + name: "only plus", + input: "+", + expectError: true, + errorMsg: "empty time delta after '+'", + }, + { + name: "no units", + input: "+25", + expectError: true, + errorMsg: "invalid time delta format", + }, + { + name: "invalid unit", + input: "+25x", + expectError: true, + errorMsg: "invalid time delta format", + }, + { + name: "duplicate units", + input: "+25h5h", + expectError: true, + errorMsg: "duplicate unit 'h'", + }, + { + name: "invalid characters", + input: "+25h5x", + expectError: true, + errorMsg: "invalid time delta format", + }, + { + name: "negative numbers not allowed", + input: "+-5h", + expectError: true, + errorMsg: "invalid time delta format", + }, + { + name: "too many days", + input: "+400d", + expectError: true, + errorMsg: "time delta too large: 400 days exceeds maximum", + }, + { + name: "too many hours", + input: "+9000h", + expectError: true, + errorMsg: "time delta too large: 9000 hours exceeds maximum", + }, + { + name: "extra characters", + input: "+5h extra", + expectError: true, + errorMsg: "Extra characters detected", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseTimeDelta(tt.input) + + if tt.expectError { + if err == nil { + t.Errorf("parseTimeDelta(%q) expected error but got none", tt.input) + return + } + if tt.errorMsg != "" && !containsString(err.Error(), tt.errorMsg) { + t.Errorf("parseTimeDelta(%q) error = %v, want to contain %v", tt.input, err.Error(), tt.errorMsg) + } + } else { + if err != nil { + t.Errorf("parseTimeDelta(%q) unexpected error: %v", tt.input, err) + return + } + if result == nil { + t.Errorf("parseTimeDelta(%q) returned nil result", tt.input) + return + } + if result.Days != tt.expected.Days || result.Hours != tt.expected.Hours || result.Minutes != tt.expected.Minutes { + t.Errorf("parseTimeDelta(%q) = {Days: %d, Hours: %d, Minutes: %d}, want {Days: %d, Hours: %d, Minutes: %d}", + tt.input, result.Days, result.Hours, result.Minutes, tt.expected.Days, tt.expected.Hours, tt.expected.Minutes) + } + } + }) + } +} + +func TestTimeDeltaToDuration(t *testing.T) { + tests := []struct { + name string + delta *TimeDelta + expected time.Duration + }{ + { + name: "hours only", + delta: &TimeDelta{Hours: 25}, + expected: 25 * time.Hour, + }, + { + name: "days only", + delta: &TimeDelta{Days: 3}, + expected: 3 * 24 * time.Hour, + }, + { + name: "minutes only", + delta: &TimeDelta{Minutes: 30}, + expected: 30 * time.Minute, + }, + { + name: "all units", + delta: &TimeDelta{Days: 2, Hours: 5, Minutes: 30}, + expected: 2*24*time.Hour + 5*time.Hour + 30*time.Minute, + }, + { + name: "zero values", + delta: &TimeDelta{Days: 0, Hours: 0, Minutes: 0}, + expected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.delta.toDuration() + if result != tt.expected { + t.Errorf("TimeDelta.toDuration() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestTimeDeltaString(t *testing.T) { + tests := []struct { + name string + delta *TimeDelta + expected string + }{ + { + name: "hours only", + delta: &TimeDelta{Hours: 25}, + expected: "+25h", + }, + { + name: "days only", + delta: &TimeDelta{Days: 3}, + expected: "+3d", + }, + { + name: "minutes only", + delta: &TimeDelta{Minutes: 30}, + expected: "+30m", + }, + { + name: "all units", + delta: &TimeDelta{Days: 2, Hours: 5, Minutes: 30}, + expected: "+2d5h30m", + }, + { + name: "zero values", + delta: &TimeDelta{Days: 0, Hours: 0, Minutes: 0}, + expected: "0m", + }, + { + name: "some zero values", + delta: &TimeDelta{Days: 1, Hours: 0, Minutes: 30}, + expected: "+1d30m", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.delta.String() + if result != tt.expected { + t.Errorf("TimeDelta.String() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestIsRelativeStopTime(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + { + name: "relative time delta", + input: "+25h", + expected: true, + }, + { + name: "absolute timestamp", + input: "2025-12-31 23:59:59", + expected: false, + }, + { + name: "empty string", + input: "", + expected: false, + }, + { + name: "just plus", + input: "+", + expected: true, + }, + { + name: "plus in middle", + input: "25h+5m", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isRelativeStopTime(tt.input) + if result != tt.expected { + t.Errorf("isRelativeStopTime(%q) = %v, want %v", tt.input, result, tt.expected) + } + }) + } +} + +func TestParseAbsoluteDateTime(t *testing.T) { + tests := []struct { + name string + input string + expectedErr bool + expectedDay int // Day of month to verify correct parsing + expectedMonth time.Month + expectedYear int + }{ + // Standard formats + { + name: "standard YYYY-MM-DD HH:MM:SS", + input: "2025-06-01 14:30:00", + expectedDay: 1, + expectedMonth: time.June, + expectedYear: 2025, + }, + { + name: "ISO 8601 format", + input: "2025-06-01T14:30:00", + expectedDay: 1, + expectedMonth: time.June, + expectedYear: 2025, + }, + { + name: "date only YYYY-MM-DD", + input: "2025-06-01", + expectedDay: 1, + expectedMonth: time.June, + expectedYear: 2025, + }, + + // US format MM/DD/YYYY + { + name: "US format MM/DD/YYYY", + input: "06/01/2025", + expectedDay: 1, + expectedMonth: time.June, + expectedYear: 2025, + }, + { + name: "US format with time", + input: "06/01/2025 14:30", + expectedDay: 1, + expectedMonth: time.June, + expectedYear: 2025, + }, + + // Readable formats + { + name: "readable January 1, 2025", + input: "January 1, 2025", + expectedDay: 1, + expectedMonth: time.January, + expectedYear: 2025, + }, + { + name: "readable June 15, 2025", + input: "June 15, 2025", + expectedDay: 15, + expectedMonth: time.June, + expectedYear: 2025, + }, + { + name: "readable with abbreviated month", + input: "Jun 15, 2025", + expectedDay: 15, + expectedMonth: time.June, + expectedYear: 2025, + }, + { + name: "European style 15 June 2025", + input: "15 June 2025", + expectedDay: 15, + expectedMonth: time.June, + expectedYear: 2025, + }, + { + name: "European style abbreviated", + input: "15 Jun 2025", + expectedDay: 15, + expectedMonth: time.June, + expectedYear: 2025, + }, + + // Ordinal numbers + { + name: "ordinal 1st June 2025", + input: "1st June 2025", + expectedDay: 1, + expectedMonth: time.June, + expectedYear: 2025, + }, + { + name: "ordinal June 1st 2025", + input: "June 1st 2025", + expectedDay: 1, + expectedMonth: time.June, + expectedYear: 2025, + }, + { + name: "ordinal 2nd January 2026", + input: "2nd January 2026", + expectedDay: 2, + expectedMonth: time.January, + expectedYear: 2026, + }, + { + name: "ordinal 23rd December 2025", + input: "23rd December 2025", + expectedDay: 23, + expectedMonth: time.December, + expectedYear: 2025, + }, + { + name: "ordinal with time 1st June 2025 15:30", + input: "1st June 2025 15:30", + expectedDay: 1, + expectedMonth: time.June, + expectedYear: 2025, + }, + + // RFC formats + { + name: "RFC3339 format", + input: "2025-06-01T14:30:00Z", + expectedDay: 1, + expectedMonth: time.June, + expectedYear: 2025, + }, + + // Edge cases + { + name: "whitespace around date", + input: " June 1, 2025 ", + expectedDay: 1, + expectedMonth: time.June, + expectedYear: 2025, + }, + + // Error cases + { + name: "invalid format", + input: "not-a-date", + expectedErr: true, + }, + { + name: "invalid month", + input: "Foo 1, 2025", + expectedErr: true, + }, + { + name: "empty string", + input: "", + expectedErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseAbsoluteDateTime(tt.input) + + if tt.expectedErr { + if err == nil { + t.Errorf("parseAbsoluteDateTime(%q) expected error but got none", tt.input) + } + return + } + + if err != nil { + t.Errorf("parseAbsoluteDateTime(%q) unexpected error: %v", tt.input, err) + return + } + + // Parse the result to verify it's correct + parsed, err := time.Parse("2006-01-02 15:04:05", result) + if err != nil { + t.Errorf("parseAbsoluteDateTime(%q) result %q is not a valid timestamp: %v", tt.input, result, err) + return + } + + if parsed.Day() != tt.expectedDay { + t.Errorf("parseAbsoluteDateTime(%q) day = %d, want %d", tt.input, parsed.Day(), tt.expectedDay) + } + if parsed.Month() != tt.expectedMonth { + t.Errorf("parseAbsoluteDateTime(%q) month = %v, want %v", tt.input, parsed.Month(), tt.expectedMonth) + } + if parsed.Year() != tt.expectedYear { + t.Errorf("parseAbsoluteDateTime(%q) year = %d, want %d", tt.input, parsed.Year(), tt.expectedYear) + } + }) + } +} + +func TestResolveStopTime(t *testing.T) { + baseTime := time.Date(2025, 8, 15, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + stopTime string + compileTime time.Time + expected string + expectError bool + errorMsg string + }{ + { + name: "empty stop time", + stopTime: "", + compileTime: baseTime, + expected: "", + }, + { + name: "absolute time standard format", + stopTime: "2025-12-31 23:59:59", + compileTime: baseTime, + expected: "2025-12-31 23:59:59", + }, + { + name: "absolute time readable format", + stopTime: "June 1, 2025", + compileTime: baseTime, + expected: "2025-06-01 00:00:00", + }, + { + name: "absolute time with ordinal", + stopTime: "1st June 2025", + compileTime: baseTime, + expected: "2025-06-01 00:00:00", + }, + { + name: "absolute time US format", + stopTime: "06/01/2025 15:30", + compileTime: baseTime, + expected: "2025-06-01 15:30:00", + }, + { + name: "absolute time European style", + stopTime: "15 June 2025 14:30", + compileTime: baseTime, + expected: "2025-06-15 14:30:00", + }, + { + name: "relative hours", + stopTime: "+25h", + compileTime: baseTime, + expected: "2025-08-16 13:00:00", + }, + { + name: "relative days", + stopTime: "+3d", + compileTime: baseTime, + expected: "2025-08-18 12:00:00", + }, + { + name: "relative complex", + stopTime: "+1d12h30m", + compileTime: baseTime, + expected: "2025-08-17 00:30:00", + }, + { + name: "invalid relative format", + stopTime: "+invalid", + compileTime: baseTime, + expectError: true, + errorMsg: "invalid time delta format", + }, + { + name: "invalid absolute format", + stopTime: "not-a-date", + compileTime: baseTime, + expectError: true, + errorMsg: "unable to parse date-time", + }, + { + name: "relative with different base time", + stopTime: "+24h", + compileTime: time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC), + expected: "2026-01-01 00:00:00", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := resolveStopTime(tt.stopTime, tt.compileTime) + + if tt.expectError { + if err == nil { + t.Errorf("resolveStopTime(%q, %v) expected error but got none", tt.stopTime, tt.compileTime) + return + } + if tt.errorMsg != "" && !containsString(err.Error(), tt.errorMsg) { + t.Errorf("resolveStopTime(%q, %v) error = %v, want to contain %v", tt.stopTime, tt.compileTime, err.Error(), tt.errorMsg) + } + } else { + if err != nil { + t.Errorf("resolveStopTime(%q, %v) unexpected error: %v", tt.stopTime, tt.compileTime, err) + return + } + if result != tt.expected { + t.Errorf("resolveStopTime(%q, %v) = %v, want %v", tt.stopTime, tt.compileTime, result, tt.expected) + } + } + }) + } +} + +// Helper function to check if a string contains a substring +func containsString(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || containsSubstring(s, substr)) +} + +func containsSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +}