feat(T4.1-T4.4): implement advanced operations & analytics (part 1)
T4.1: Real-time Metrics Dashboard - Add internal/dashboard package with MetricsAggregator - Record, aggregate, and query metrics - Percentile calculations (p50, p95, p99) - Time-series data with max size eviction - 13 metrics tests, all passing T4.2: Workflow Visualization & DAG Rendering - Add internal/visualization package with DAGRenderer - Convert dependency graphs to DOT format - Critical path highlighting - Topological sorting with parallel task detection - HTML rendering for visualization - 11 DAG rendering tests, all passing T4.3: Advanced Search & Filtering - Add internal/search package with WorkflowSearch - Full-text indexing with word-based lookup - Filter by status, assignee, tag, date range - Regex pattern matching - Saved filters for reusable queries - 15 search tests, all passing T4.4: Cost Tracking & Optimization - Add internal/cost package with CostTracker - Track LLM API costs (by token) - Track git operation costs - Track compute resource costs (by duration) - Cost aggregation by workflow/type - Optimization recommendations - 11 cost tests, all passing Total T4.1-T4.4: 50 tests passing Next: T4.5-T4.8 (alerting, profiling, multi-cluster, self-deployment)
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WorkflowEntry represents an indexed workflow
|
||||
type WorkflowEntry struct {
|
||||
ID string
|
||||
Name string
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Tags []string
|
||||
Content string
|
||||
Assignee string
|
||||
}
|
||||
|
||||
// WorkflowSearch provides full-text search and filtering
|
||||
type WorkflowSearch struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]*WorkflowEntry
|
||||
index map[string][]string // word -> workflow IDs
|
||||
filters map[string]interface{}
|
||||
}
|
||||
|
||||
// NewWorkflowSearch creates a new workflow search index
|
||||
func NewWorkflowSearch() *WorkflowSearch {
|
||||
return &WorkflowSearch{
|
||||
entries: make(map[string]*WorkflowEntry),
|
||||
index: make(map[string][]string),
|
||||
filters: make(map[string]interface{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Index adds a workflow to the search index
|
||||
func (ws *WorkflowSearch) Index(entry *WorkflowEntry) error {
|
||||
if entry.ID == "" {
|
||||
return fmt.Errorf("workflow ID required")
|
||||
}
|
||||
|
||||
ws.mu.Lock()
|
||||
defer ws.mu.Unlock()
|
||||
|
||||
ws.entries[entry.ID] = entry
|
||||
|
||||
// Index content
|
||||
words := strings.Fields(strings.ToLower(entry.Content + " " + entry.Name))
|
||||
for _, word := range words {
|
||||
// Remove punctuation
|
||||
clean := strings.Trim(word, ".,!?;:")
|
||||
if clean != "" {
|
||||
ws.index[clean] = append(ws.index[clean], entry.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Search performs full-text search
|
||||
func (ws *WorkflowSearch) Search(query string) []*WorkflowEntry {
|
||||
ws.mu.RLock()
|
||||
defer ws.mu.RUnlock()
|
||||
|
||||
query = strings.ToLower(query)
|
||||
matches := make(map[string]int)
|
||||
|
||||
words := strings.Fields(query)
|
||||
for _, word := range words {
|
||||
if ids, exists := ws.index[word]; exists {
|
||||
for _, id := range ids {
|
||||
matches[id]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by match count
|
||||
result := make([]*WorkflowEntry, 0)
|
||||
for id := range matches {
|
||||
if entry, exists := ws.entries[id]; exists {
|
||||
result = append(result, entry)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// FilterByStatus filters workflows by status
|
||||
func (ws *WorkflowSearch) FilterByStatus(status string) []*WorkflowEntry {
|
||||
ws.mu.RLock()
|
||||
defer ws.mu.RUnlock()
|
||||
|
||||
result := make([]*WorkflowEntry, 0)
|
||||
for _, entry := range ws.entries {
|
||||
if entry.Status == status {
|
||||
result = append(result, entry)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// FilterByAssignee filters workflows by assignee
|
||||
func (ws *WorkflowSearch) FilterByAssignee(assignee string) []*WorkflowEntry {
|
||||
ws.mu.RLock()
|
||||
defer ws.mu.RUnlock()
|
||||
|
||||
result := make([]*WorkflowEntry, 0)
|
||||
for _, entry := range ws.entries {
|
||||
if entry.Assignee == assignee {
|
||||
result = append(result, entry)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// FilterByTag filters workflows by tag
|
||||
func (ws *WorkflowSearch) FilterByTag(tag string) []*WorkflowEntry {
|
||||
ws.mu.RLock()
|
||||
defer ws.mu.RUnlock()
|
||||
|
||||
result := make([]*WorkflowEntry, 0)
|
||||
for _, entry := range ws.entries {
|
||||
for _, t := range entry.Tags {
|
||||
if t == tag {
|
||||
result = append(result, entry)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// FilterByDateRange filters workflows by date range
|
||||
func (ws *WorkflowSearch) FilterByDateRange(start, end time.Time) []*WorkflowEntry {
|
||||
ws.mu.RLock()
|
||||
defer ws.mu.RUnlock()
|
||||
|
||||
result := make([]*WorkflowEntry, 0)
|
||||
for _, entry := range ws.entries {
|
||||
if entry.CreatedAt.After(start) && entry.CreatedAt.Before(end) {
|
||||
result = append(result, entry)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// SearchRegex performs regex search on content
|
||||
func (ws *WorkflowSearch) SearchRegex(pattern string) ([]*WorkflowEntry, error) {
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ws.mu.RLock()
|
||||
defer ws.mu.RUnlock()
|
||||
|
||||
result := make([]*WorkflowEntry, 0)
|
||||
for _, entry := range ws.entries {
|
||||
if re.MatchString(entry.Content) || re.MatchString(entry.Name) {
|
||||
result = append(result, entry)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SaveFilter saves a named filter
|
||||
func (ws *WorkflowSearch) SaveFilter(name string, filter interface{}) {
|
||||
ws.mu.Lock()
|
||||
defer ws.mu.Unlock()
|
||||
|
||||
ws.filters[name] = filter
|
||||
}
|
||||
|
||||
// GetFilter retrieves a saved filter
|
||||
func (ws *WorkflowSearch) GetFilter(name string) (interface{}, bool) {
|
||||
ws.mu.RLock()
|
||||
defer ws.mu.RUnlock()
|
||||
|
||||
filter, exists := ws.filters[name]
|
||||
return filter, exists
|
||||
}
|
||||
|
||||
// GetAll returns all workflows
|
||||
func (ws *WorkflowSearch) GetAll() []*WorkflowEntry {
|
||||
ws.mu.RLock()
|
||||
defer ws.mu.RUnlock()
|
||||
|
||||
result := make([]*WorkflowEntry, 0, len(ws.entries))
|
||||
for _, entry := range ws.entries {
|
||||
result = append(result, entry)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetByID retrieves a workflow by ID
|
||||
func (ws *WorkflowSearch) GetByID(id string) (*WorkflowEntry, bool) {
|
||||
ws.mu.RLock()
|
||||
defer ws.mu.RUnlock()
|
||||
|
||||
entry, exists := ws.entries[id]
|
||||
return entry, exists
|
||||
}
|
||||
|
||||
// Delete removes a workflow from the index
|
||||
func (ws *WorkflowSearch) Delete(id string) error {
|
||||
ws.mu.Lock()
|
||||
defer ws.mu.Unlock()
|
||||
|
||||
if _, exists := ws.entries[id]; !exists {
|
||||
return fmt.Errorf("workflow not found: %s", id)
|
||||
}
|
||||
|
||||
delete(ws.entries, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clear clears the entire index
|
||||
func (ws *WorkflowSearch) Clear() {
|
||||
ws.mu.Lock()
|
||||
defer ws.mu.Unlock()
|
||||
|
||||
ws.entries = make(map[string]*WorkflowEntry)
|
||||
ws.index = make(map[string][]string)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIndex(t *testing.T) {
|
||||
ws := NewWorkflowSearch()
|
||||
|
||||
entry := &WorkflowEntry{
|
||||
ID: "wf-1",
|
||||
Name: "Deploy Service",
|
||||
Status: "completed",
|
||||
Content: "deployment task",
|
||||
}
|
||||
|
||||
err := ws.Index(entry)
|
||||
assert.NoError(t, err)
|
||||
|
||||
retrieved, exists := ws.GetByID("wf-1")
|
||||
assert.True(t, exists)
|
||||
assert.Equal(t, "Deploy Service", retrieved.Name)
|
||||
}
|
||||
|
||||
func TestSearch(t *testing.T) {
|
||||
ws := NewWorkflowSearch()
|
||||
|
||||
ws.Index(&WorkflowEntry{
|
||||
ID: "wf-1",
|
||||
Name: "Deploy Service",
|
||||
Content: "deployment production",
|
||||
})
|
||||
ws.Index(&WorkflowEntry{
|
||||
ID: "wf-2",
|
||||
Name: "Build Docker",
|
||||
Content: "docker image",
|
||||
})
|
||||
|
||||
results := ws.Search("deployment")
|
||||
assert.Equal(t, 1, len(results))
|
||||
assert.Equal(t, "wf-1", results[0].ID)
|
||||
}
|
||||
|
||||
func TestFilterByStatus(t *testing.T) {
|
||||
ws := NewWorkflowSearch()
|
||||
|
||||
ws.Index(&WorkflowEntry{ID: "wf-1", Status: "completed"})
|
||||
ws.Index(&WorkflowEntry{ID: "wf-2", Status: "running"})
|
||||
ws.Index(&WorkflowEntry{ID: "wf-3", Status: "completed"})
|
||||
|
||||
results := ws.FilterByStatus("completed")
|
||||
assert.Equal(t, 2, len(results))
|
||||
}
|
||||
|
||||
func TestFilterByAssignee(t *testing.T) {
|
||||
ws := NewWorkflowSearch()
|
||||
|
||||
ws.Index(&WorkflowEntry{ID: "wf-1", Assignee: "alice"})
|
||||
ws.Index(&WorkflowEntry{ID: "wf-2", Assignee: "bob"})
|
||||
ws.Index(&WorkflowEntry{ID: "wf-3", Assignee: "alice"})
|
||||
|
||||
results := ws.FilterByAssignee("alice")
|
||||
assert.Equal(t, 2, len(results))
|
||||
}
|
||||
|
||||
func TestFilterByTag(t *testing.T) {
|
||||
ws := NewWorkflowSearch()
|
||||
|
||||
ws.Index(&WorkflowEntry{
|
||||
ID: "wf-1",
|
||||
Tags: []string{"production", "critical"},
|
||||
})
|
||||
ws.Index(&WorkflowEntry{
|
||||
ID: "wf-2",
|
||||
Tags: []string{"staging"},
|
||||
})
|
||||
|
||||
results := ws.FilterByTag("production")
|
||||
assert.Equal(t, 1, len(results))
|
||||
}
|
||||
|
||||
func TestFilterByDateRange(t *testing.T) {
|
||||
ws := NewWorkflowSearch()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
ws.Index(&WorkflowEntry{
|
||||
ID: "wf-1",
|
||||
CreatedAt: now.Add(-1 * time.Hour),
|
||||
})
|
||||
ws.Index(&WorkflowEntry{
|
||||
ID: "wf-2",
|
||||
CreatedAt: now.Add(-24 * time.Hour),
|
||||
})
|
||||
|
||||
results := ws.FilterByDateRange(now.Add(-2*time.Hour), now)
|
||||
assert.Equal(t, 1, len(results))
|
||||
}
|
||||
|
||||
func TestSearchRegex(t *testing.T) {
|
||||
ws := NewWorkflowSearch()
|
||||
|
||||
ws.Index(&WorkflowEntry{
|
||||
ID: "wf-1",
|
||||
Content: "error 404 not found",
|
||||
})
|
||||
ws.Index(&WorkflowEntry{
|
||||
ID: "wf-2",
|
||||
Content: "success 200 ok",
|
||||
})
|
||||
|
||||
results, err := ws.SearchRegex("error.*404")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(results))
|
||||
}
|
||||
|
||||
func TestSaveAndGetFilter(t *testing.T) {
|
||||
ws := NewWorkflowSearch()
|
||||
|
||||
filter := map[string]interface{}{"status": "completed"}
|
||||
ws.SaveFilter("completed-only", filter)
|
||||
|
||||
retrieved, exists := ws.GetFilter("completed-only")
|
||||
assert.True(t, exists)
|
||||
assert.NotNil(t, retrieved)
|
||||
}
|
||||
|
||||
func TestGetAll(t *testing.T) {
|
||||
ws := NewWorkflowSearch()
|
||||
|
||||
ws.Index(&WorkflowEntry{ID: "wf-1"})
|
||||
ws.Index(&WorkflowEntry{ID: "wf-2"})
|
||||
ws.Index(&WorkflowEntry{ID: "wf-3"})
|
||||
|
||||
all := ws.GetAll()
|
||||
assert.Equal(t, 3, len(all))
|
||||
}
|
||||
|
||||
func TestDelete(t *testing.T) {
|
||||
ws := NewWorkflowSearch()
|
||||
|
||||
ws.Index(&WorkflowEntry{ID: "wf-1"})
|
||||
ws.Delete("wf-1")
|
||||
|
||||
_, exists := ws.GetByID("wf-1")
|
||||
assert.False(t, exists)
|
||||
}
|
||||
|
||||
func TestClear(t *testing.T) {
|
||||
ws := NewWorkflowSearch()
|
||||
|
||||
ws.Index(&WorkflowEntry{ID: "wf-1"})
|
||||
ws.Index(&WorkflowEntry{ID: "wf-2"})
|
||||
ws.Clear()
|
||||
|
||||
all := ws.GetAll()
|
||||
assert.Equal(t, 0, len(all))
|
||||
}
|
||||
|
||||
func TestMultiwordSearch(t *testing.T) {
|
||||
ws := NewWorkflowSearch()
|
||||
|
||||
ws.Index(&WorkflowEntry{
|
||||
ID: "wf-1",
|
||||
Content: "deploy service to production",
|
||||
})
|
||||
|
||||
results := ws.Search("deploy service")
|
||||
assert.Equal(t, 1, len(results))
|
||||
}
|
||||
|
||||
func TestCaseSensitivity(t *testing.T) {
|
||||
ws := NewWorkflowSearch()
|
||||
|
||||
ws.Index(&WorkflowEntry{
|
||||
ID: "wf-1",
|
||||
Content: "Deploy Service Production",
|
||||
})
|
||||
|
||||
results := ws.Search("deploy")
|
||||
assert.Equal(t, 1, len(results))
|
||||
}
|
||||
|
||||
func TestIndexError(t *testing.T) {
|
||||
ws := NewWorkflowSearch()
|
||||
|
||||
entry := &WorkflowEntry{
|
||||
Name: "No ID",
|
||||
}
|
||||
|
||||
err := ws.Index(entry)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
Reference in New Issue
Block a user