Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 25 additions & 9 deletions docs/frontmatter.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:`)

Expand Down
10 changes: 10 additions & 0 deletions docs/security-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 0 additions & 1 deletion docs/workflow-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
---

Expand Down
74 changes: 71 additions & 3 deletions pkg/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"
Expand All @@ -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
Expand All @@ -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)
Expand Down
150 changes: 150 additions & 0 deletions pkg/cli/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
)

// Test the CLI functions that are exported from this package
Expand Down Expand Up @@ -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")
}
})
}
2 changes: 1 addition & 1 deletion pkg/cli/templates/instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading