feat: RoutingWorkflow + LLM Router + Memory Activity
ci / test (push) Successful in 2m12s

- Add RoutingWorkflow: generic state machine executor for WorkflowSpec
- Add LLM Router: natural language → WorkflowSpec generation
- Add RetrieveMemoryActivity: query poimen-memory for context
- Add activities: AnalyzeCode, SecurityScan, GenerateReport, Notify, etc.
- Add agent-prompts/router: LLM prompt documentation
- Extend starter with --route flag for routing workflows
- Remove orchestrator job (trigger via API/message instead)
- Clean up: move docs to Desktop, add .gitignore for *.md
This commit is contained in:
Test
2026-09-02 19:21:53 -07:00
parent 5a465b145c
commit a0e64224a7
74 changed files with 3950 additions and 12421 deletions
+296
View File
@@ -0,0 +1,296 @@
package action
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// AnalyzeCodeInput is input for AnalyzeCodeActivity
type AnalyzeCodeInput struct {
Path string `json:"path"`
Language string `json:"language,omitempty"`
Depth int `json:"depth,omitempty"`
}
// AnalyzeCodeOutput is output from AnalyzeCodeActivity
type AnalyzeCodeOutput struct {
Quality float64 `json:"quality"`
Metrics map[string]interface{} `json:"metrics"`
Issues []string `json:"issues"`
Summary string `json:"summary"`
}
// AnalyzeCodeActivity analyzes code quality using available tools
func AnalyzeCodeActivity(ctx context.Context, in AnalyzeCodeInput) (AnalyzeCodeOutput, error) {
logger := newActivityLogger(ctx)
logger.Info("AnalyzeCodeActivity started", "path", in.Path)
output := AnalyzeCodeOutput{
Quality: 0.0,
Metrics: make(map[string]interface{}),
Issues: []string{},
}
// Verify path exists
if _, err := os.Stat(in.Path); os.IsNotExist(err) {
return output, fmt.Errorf("path does not exist: %s", in.Path)
}
// Count files and lines
var totalFiles, totalLines int
err := filepath.Walk(in.Path, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // Skip errors
}
if info.IsDir() {
// Skip hidden and vendor directories
if strings.HasPrefix(info.Name(), ".") || info.Name() == "vendor" || info.Name() == "node_modules" {
return filepath.SkipDir
}
return nil
}
ext := filepath.Ext(path)
if isCodeFile(ext) {
totalFiles++
if lines, err := countLines(path); err == nil {
totalLines += lines
}
}
return nil
})
if err != nil {
logger.Warn("Error walking path", "error", err)
}
output.Metrics["totalFiles"] = totalFiles
output.Metrics["totalLines"] = totalLines
// Try to run go vet if it's a Go project
if _, err := os.Stat(filepath.Join(in.Path, "go.mod")); err == nil {
cmd := exec.CommandContext(ctx, "go", "vet", "./...")
cmd.Dir = in.Path
vetOutput, err := cmd.CombinedOutput()
if err != nil {
issues := strings.Split(string(vetOutput), "\n")
for _, issue := range issues {
if strings.TrimSpace(issue) != "" {
output.Issues = append(output.Issues, issue)
}
}
}
output.Metrics["goVetRan"] = true
}
// Calculate quality score (simple heuristic)
issueCount := len(output.Issues)
if totalFiles > 0 {
issuesPerFile := float64(issueCount) / float64(totalFiles)
output.Quality = max(0, 1.0 - (issuesPerFile * 0.1))
} else {
output.Quality = 0.5
}
output.Summary = fmt.Sprintf("Analyzed %d files (%d lines). Found %d issues. Quality score: %.2f",
totalFiles, totalLines, issueCount, output.Quality)
logger.Info("AnalyzeCodeActivity completed", "quality", output.Quality, "issues", issueCount)
return output, nil
}
// SecurityScanInput is input for SecurityScanActivity
type SecurityScanInput struct {
Path string `json:"path"`
Severity string `json:"severity,omitempty"` // low, medium, high, critical
}
// SecurityScanOutput is output from SecurityScanActivity
type SecurityScanOutput struct {
Vulnerabilities []Vulnerability `json:"vulnerabilities"`
SecurityScore float64 `json:"securityScore"`
RiskLevel string `json:"riskLevel"`
}
// Vulnerability represents a security issue
type Vulnerability struct {
ID string `json:"id"`
Severity string `json:"severity"`
Description string `json:"description"`
File string `json:"file,omitempty"`
Line int `json:"line,omitempty"`
}
// SecurityScanActivity scans code for security vulnerabilities
func SecurityScanActivity(ctx context.Context, in SecurityScanInput) (SecurityScanOutput, error) {
logger := newActivityLogger(ctx)
logger.Info("SecurityScanActivity started", "path", in.Path)
output := SecurityScanOutput{
Vulnerabilities: []Vulnerability{},
SecurityScore: 100.0,
RiskLevel: "low",
}
// Verify path exists
if _, err := os.Stat(in.Path); os.IsNotExist(err) {
return output, fmt.Errorf("path does not exist: %s", in.Path)
}
// Check for common security issues
// 1. Check for hardcoded secrets
secretPatterns := []string{
"password=",
"secret=",
"api_key=",
"apikey=",
"private_key",
"AWS_SECRET",
}
err := filepath.Walk(in.Path, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
if !isCodeFile(filepath.Ext(path)) {
return nil
}
content, err := os.ReadFile(path)
if err != nil {
return nil
}
contentStr := strings.ToLower(string(content))
for _, pattern := range secretPatterns {
if strings.Contains(contentStr, pattern) {
output.Vulnerabilities = append(output.Vulnerabilities, Vulnerability{
ID: fmt.Sprintf("SEC-%d", len(output.Vulnerabilities)+1),
Severity: "high",
Description: fmt.Sprintf("Possible hardcoded secret: %s", pattern),
File: path,
})
}
}
return nil
})
if err != nil {
logger.Warn("Error scanning", "error", err)
}
// Try gosec if available and it's a Go project
if _, err := os.Stat(filepath.Join(in.Path, "go.mod")); err == nil {
if _, err := exec.LookPath("gosec"); err == nil {
cmd := exec.CommandContext(ctx, "gosec", "-fmt=json", "-quiet", "./...")
cmd.Dir = in.Path
// gosec returns non-zero if issues found, so we ignore the error
cmd.CombinedOutput()
}
}
// Calculate score
vulnCount := len(output.Vulnerabilities)
if vulnCount == 0 {
output.SecurityScore = 100.0
output.RiskLevel = "low"
} else if vulnCount < 3 {
output.SecurityScore = 80.0
output.RiskLevel = "medium"
} else if vulnCount < 10 {
output.SecurityScore = 50.0
output.RiskLevel = "high"
} else {
output.SecurityScore = 20.0
output.RiskLevel = "critical"
}
logger.Info("SecurityScanActivity completed", "vulnerabilities", vulnCount, "riskLevel", output.RiskLevel)
return output, nil
}
// GenerateReportInput is input for GenerateReportActivity
type GenerateReportInput struct {
AnalysisResult interface{} `json:"analysisResult"`
SecurityResult interface{} `json:"securityResult"`
Format string `json:"format,omitempty"` // markdown, html, json
}
// GenerateReportOutput is output from GenerateReportActivity
type GenerateReportOutput struct {
Report string `json:"report"`
ReportPath string `json:"reportPath"`
}
// GenerateReportActivity generates a combined report
func GenerateReportActivity(ctx context.Context, in GenerateReportInput) (GenerateReportOutput, error) {
logger := newActivityLogger(ctx)
logger.Info("GenerateReportActivity started")
format := in.Format
if format == "" {
format = "markdown"
}
var report strings.Builder
timestamp := time.Now().Format(time.RFC3339)
switch format {
case "markdown":
report.WriteString("# Analysis Report\n\n")
report.WriteString(fmt.Sprintf("Generated: %s\n\n", timestamp))
report.WriteString("## Code Analysis\n\n")
report.WriteString(fmt.Sprintf("```\n%v\n```\n\n", in.AnalysisResult))
report.WriteString("## Security Scan\n\n")
report.WriteString(fmt.Sprintf("```\n%v\n```\n\n", in.SecurityResult))
case "json":
report.WriteString(fmt.Sprintf(`{"timestamp":"%s","analysis":%v,"security":%v}`,
timestamp, in.AnalysisResult, in.SecurityResult))
default:
report.WriteString(fmt.Sprintf("Report generated at %s\n", timestamp))
report.WriteString(fmt.Sprintf("Analysis: %v\n", in.AnalysisResult))
report.WriteString(fmt.Sprintf("Security: %v\n", in.SecurityResult))
}
// Save to temp file
reportPath := filepath.Join(os.TempDir(), fmt.Sprintf("report-%d.%s", time.Now().UnixNano(), format))
if err := os.WriteFile(reportPath, []byte(report.String()), 0644); err != nil {
logger.Warn("Failed to save report", "error", err)
}
logger.Info("GenerateReportActivity completed", "format", format)
return GenerateReportOutput{
Report: report.String(),
ReportPath: reportPath,
}, nil
}
// Helper functions
func isCodeFile(ext string) bool {
codeExts := map[string]bool{
".go": true, ".py": true, ".js": true, ".ts": true,
".java": true, ".c": true, ".cpp": true, ".h": true,
".rs": true, ".rb": true, ".php": true, ".swift": true,
}
return codeExts[ext]
}
func countLines(path string) (int, error) {
content, err := os.ReadFile(path)
if err != nil {
return 0, err
}
return len(strings.Split(string(content), "\n")), nil
}
func max(a, b float64) float64 {
if a > b {
return a
}
return b
}
+144
View File
@@ -0,0 +1,144 @@
package action
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func TestAnalyzeCodeActivity(t *testing.T) {
// Create temp directory with some Go code
tmpDir, err := os.MkdirTemp("", "analyze-test")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
// Create go.mod
goMod := `module test
go 1.21
`
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte(goMod), 0644))
// Create a simple Go file
goCode := `package main
func main() {
println("hello")
}
`
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte(goCode), 0644))
// Run activity
output, err := AnalyzeCodeActivity(context.Background(), AnalyzeCodeInput{
Path: tmpDir,
Depth: 3,
})
require.NoError(t, err)
// Verify output
require.Greater(t, output.Quality, 0.0)
require.NotNil(t, output.Metrics)
require.NotEmpty(t, output.Summary)
totalFiles, ok := output.Metrics["totalFiles"].(int)
require.True(t, ok)
require.Equal(t, 1, totalFiles) // Just main.go (go.mod not counted)
}
func TestAnalyzeCodeActivity_PathNotExist(t *testing.T) {
_, err := AnalyzeCodeActivity(context.Background(), AnalyzeCodeInput{
Path: "/nonexistent/path",
})
require.Error(t, err)
require.Contains(t, err.Error(), "does not exist")
}
func TestSecurityScanActivity(t *testing.T) {
// Create temp directory
tmpDir, err := os.MkdirTemp("", "security-test")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
// Create a file with potential secret (matches pattern "password=")
code := `package main
var config = map[string]string{
"password=": "supersecret123",
"api_key=": "sk-12345",
}
`
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte(code), 0644))
// Run activity
output, err := SecurityScanActivity(context.Background(), SecurityScanInput{
Path: tmpDir,
Severity: "medium",
})
require.NoError(t, err)
// Should find the hardcoded password
require.Greater(t, len(output.Vulnerabilities), 0)
require.Less(t, output.SecurityScore, 100.0)
}
func TestSecurityScanActivity_Clean(t *testing.T) {
// Create temp directory with clean code
tmpDir, err := os.MkdirTemp("", "security-clean-test")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
// Create clean code
code := `package main
func main() {
println("hello")
}
`
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte(code), 0644))
// Run activity
output, err := SecurityScanActivity(context.Background(), SecurityScanInput{
Path: tmpDir,
})
require.NoError(t, err)
// Should be clean
require.Equal(t, 0, len(output.Vulnerabilities))
require.Equal(t, 100.0, output.SecurityScore)
require.Equal(t, "low", output.RiskLevel)
}
func TestGenerateReportActivity(t *testing.T) {
output, err := GenerateReportActivity(context.Background(), GenerateReportInput{
AnalysisResult: map[string]interface{}{"quality": 0.85},
SecurityResult: map[string]interface{}{"score": 95.0},
Format: "markdown",
})
require.NoError(t, err)
require.Contains(t, output.Report, "# Analysis Report")
require.NotEmpty(t, output.ReportPath)
// Verify file was created
_, err = os.Stat(output.ReportPath)
require.NoError(t, err)
// Cleanup
os.Remove(output.ReportPath)
}
func TestGenerateReportActivity_JSON(t *testing.T) {
output, err := GenerateReportActivity(context.Background(), GenerateReportInput{
AnalysisResult: map[string]interface{}{"quality": 0.85},
SecurityResult: map[string]interface{}{"score": 95.0},
Format: "json",
})
require.NoError(t, err)
require.Contains(t, output.Report, `"timestamp"`)
// Cleanup
os.Remove(output.ReportPath)
}
+49
View File
@@ -0,0 +1,49 @@
package action
import (
"context"
"log"
"go.temporal.io/sdk/activity"
)
// activityLogger provides logging that works both in and outside Temporal context
type activityLogger struct {
ctx context.Context
}
func newActivityLogger(ctx context.Context) *activityLogger {
return &activityLogger{ctx: ctx}
}
func (l *activityLogger) Info(msg string, args ...interface{}) {
if activity.IsActivity(l.ctx) {
activity.GetLogger(l.ctx).Info(msg, args...)
} else {
log.Printf("INFO: "+msg+" %v", args)
}
}
func (l *activityLogger) Warn(msg string, args ...interface{}) {
if activity.IsActivity(l.ctx) {
activity.GetLogger(l.ctx).Warn(msg, args...)
} else {
log.Printf("WARN: "+msg+" %v", args)
}
}
func (l *activityLogger) Error(msg string, args ...interface{}) {
if activity.IsActivity(l.ctx) {
activity.GetLogger(l.ctx).Error(msg, args...)
} else {
log.Printf("ERROR: "+msg+" %v", args)
}
}
func (l *activityLogger) Debug(msg string, args ...interface{}) {
if activity.IsActivity(l.ctx) {
activity.GetLogger(l.ctx).Debug(msg, args...)
} else {
log.Printf("DEBUG: "+msg+" %v", args)
}
}
+255
View File
@@ -0,0 +1,255 @@
package action
import (
"context"
"fmt"
"os"
"github.com/rockliang/poimen/workflows/internal/memory"
)
// RetrieveMemoryInput input for RetrieveMemoryActivity
type RetrieveMemoryInput struct {
// Query semantic search query
Query string `json:"query"`
// Project memory project (default: "poimen")
Project string `json:"project,omitempty"`
// Scope retrieval scope: "skills", "lessons", "all" (default: "all")
Scope string `json:"scope,omitempty"`
// Limit max results (default: 10)
Limit int `json:"limit,omitempty"`
// LevelFilter filter by level: L1, L2, R (reference)
LevelFilter []string `json:"levelFilter,omitempty"`
// Tool tool context for skill matching
Tool string `json:"tool,omitempty"`
// Task task description for context retrieval
Task string `json:"task,omitempty"`
}
// RetrieveMemoryOutput output from RetrieveMemoryActivity
type RetrieveMemoryOutput struct {
// Skills relevant skills found
Skills []MemorySkill `json:"skills"`
// Lessons relevant lessons/knowledge found
Lessons []MemoryLesson `json:"lessons"`
// References reference documents found
References []MemoryReference `json:"references"`
// TotalResults total results found
TotalResults int `json:"totalResults"`
// Budget token budget info
Budget MemoryBudget `json:"budget"`
}
// MemorySkill skill from memory
type MemorySkill struct {
Name string `json:"name"`
Description string `json:"description"`
Why string `json:"why,omitempty"`
}
// MemoryLesson lesson from memory
type MemoryLesson struct {
ID string `json:"id"`
Text string `json:"text"`
Level string `json:"level"`
Score float32 `json:"score"`
Breadcrumb string `json:"breadcrumb,omitempty"`
}
// MemoryReference reference document from memory
type MemoryReference struct {
ID string `json:"id"`
Text string `json:"text"`
Score float32 `json:"score"`
Breadcrumb string `json:"breadcrumb,omitempty"`
}
// MemoryBudget token budget tracking
type MemoryBudget struct {
Requested int `json:"requested"`
Used int `json:"used"`
}
// RetrieveMemoryActivity retrieves relevant knowledge from poimen-memory
func RetrieveMemoryActivity(ctx context.Context, in RetrieveMemoryInput) (RetrieveMemoryOutput, error) {
logger := newActivityLogger(ctx)
logger.Info("RetrieveMemoryActivity started", "query", in.Query, "scope", in.Scope)
output := RetrieveMemoryOutput{
Skills: []MemorySkill{},
Lessons: []MemoryLesson{},
References: []MemoryReference{},
}
// Get memory service URL and token
baseURL := os.Getenv("POIMEN_MEMORY_URL")
if baseURL == "" {
baseURL = "http://poimen-memory.poimen.svc.cluster.local:8080"
}
token := os.Getenv("POIMEN_MEMORY_TOKEN")
// Token optional for internal cluster access
// Set defaults
project := in.Project
if project == "" {
project = "poimen"
}
scope := in.Scope
if scope == "" {
scope = "all"
}
limit := in.Limit
if limit == 0 {
limit = 10
}
client := memory.NewClient(baseURL, token)
// If tool/task provided, use Context API for skill matching
if in.Tool != "" || in.Task != "" {
contextResp, err := client.Context(ctx, &memory.ContextRequest{
Project: project,
Tool: in.Tool,
Task: in.Task,
SignatureSource: in.Query,
Scope: "tool_context",
Budget: 8192,
})
if err != nil {
logger.Warn("Context retrieval failed, falling back to query", "error", err)
} else {
// Extract skills
for _, skill := range contextResp.Skills {
output.Skills = append(output.Skills, MemorySkill{
Name: skill.Name,
Why: skill.Why,
})
}
// Extract lessons
for i, lesson := range contextResp.Lessons {
output.Lessons = append(output.Lessons, MemoryLesson{
ID: fmt.Sprintf("ctx-%d", i),
Text: lesson.Text,
Level: lesson.Level,
Score: lesson.Score,
Breadcrumb: "",
})
}
output.Budget = MemoryBudget{
Requested: contextResp.Budget.Requested,
Used: contextResp.Budget.Used,
}
output.TotalResults = len(output.Skills) + len(output.Lessons)
}
}
// Also do semantic query for additional context
if scope == "all" || scope == "lessons" || scope == "references" {
levelFilter := in.LevelFilter
if len(levelFilter) == 0 {
levelFilter = []string{"L1", "L2"}
}
queryResp, err := client.Query(ctx, &memory.QueryRequest{
Project: project,
Query: in.Query,
LevelFilter: levelFilter,
Limit: limit,
Scope: "all",
})
if err != nil {
logger.Warn("Query failed", "error", err)
} else {
for _, result := range queryResp.Results {
if result.Level == "R" {
output.References = append(output.References, MemoryReference{
ID: result.ID,
Text: result.Text,
Score: result.Score,
Breadcrumb: result.Breadcrumb,
})
} else {
// Avoid duplicates from Context call
found := false
for _, existing := range output.Lessons {
if existing.ID == result.ID {
found = true
break
}
}
if !found {
output.Lessons = append(output.Lessons, MemoryLesson{
ID: result.ID,
Text: result.Text,
Level: result.Level,
Score: result.Score,
Breadcrumb: result.Breadcrumb,
})
}
}
}
output.TotalResults = len(output.Skills) + len(output.Lessons) + len(output.References)
}
}
logger.Info("RetrieveMemoryActivity completed",
"skills", len(output.Skills),
"lessons", len(output.Lessons),
"references", len(output.References))
return output, nil
}
// FormatMemoryForPrompt formats memory output for LLM prompt injection
func FormatMemoryForPrompt(mem RetrieveMemoryOutput) string {
if mem.TotalResults == 0 {
return ""
}
var result string
if len(mem.Skills) > 0 {
result += "\n## Relevant Skills\n"
for _, skill := range mem.Skills {
result += fmt.Sprintf("- **%s**: %s\n", skill.Name, skill.Why)
}
}
if len(mem.Lessons) > 0 {
result += "\n## Relevant Knowledge\n"
for _, lesson := range mem.Lessons {
result += fmt.Sprintf("- [%s] %s\n", lesson.Level, truncate(lesson.Text, 200))
}
}
if len(mem.References) > 0 {
result += "\n## Reference Documents\n"
for _, ref := range mem.References {
result += fmt.Sprintf("- %s\n", truncate(ref.Text, 200))
}
}
return result
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}
+138
View File
@@ -0,0 +1,138 @@
package action
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
)
func TestRetrieveMemoryActivity_Query(t *testing.T) {
// Mock memory service
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/memory/query" {
resp := map[string]interface{}{
"results": []map[string]interface{}{
{
"id": "doc-1",
"level": "L1",
"score": 0.95,
"text": "Security scanning best practices: always check for hardcoded secrets",
},
{
"id": "doc-2",
"level": "L2",
"score": 0.85,
"text": "Use gosec for Go security analysis",
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
return
}
http.NotFound(w, r)
}))
defer server.Close()
// Set env for test
t.Setenv("POIMEN_MEMORY_URL", server.URL)
output, err := RetrieveMemoryActivity(context.Background(), RetrieveMemoryInput{
Query: "security scanning",
Project: "poimen",
Scope: "lessons",
Limit: 5,
})
require.NoError(t, err)
require.Equal(t, 2, len(output.Lessons))
require.Equal(t, "L1", output.Lessons[0].Level)
require.Contains(t, output.Lessons[0].Text, "Security scanning")
}
func TestRetrieveMemoryActivity_Context(t *testing.T) {
// Mock memory service with context endpoint
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/memory/context" {
resp := map[string]interface{}{
"tier": 1,
"skills": []map[string]interface{}{
{
"name": "security-analysis",
"why": "User is asking about security scanning",
},
},
"lessons": []map[string]interface{}{
{
"tier": 1,
"level": "L1",
"score": 0.9,
"text": "Always scan dependencies for vulnerabilities",
},
},
"budget": map[string]interface{}{
"requested": 8192,
"used": 1024,
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
return
}
if r.URL.Path == "/memory/query" {
resp := map[string]interface{}{"results": []interface{}{}}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
return
}
http.NotFound(w, r)
}))
defer server.Close()
t.Setenv("POIMEN_MEMORY_URL", server.URL)
output, err := RetrieveMemoryActivity(context.Background(), RetrieveMemoryInput{
Query: "security scan repo",
Tool: "poimen-router",
Task: "generate workflow for security scanning",
})
require.NoError(t, err)
require.Equal(t, 1, len(output.Skills))
require.Equal(t, "security-analysis", output.Skills[0].Name)
require.Equal(t, 1, len(output.Lessons))
require.Equal(t, 8192, output.Budget.Requested)
require.Equal(t, 1024, output.Budget.Used)
}
func TestFormatMemoryForPrompt(t *testing.T) {
mem := RetrieveMemoryOutput{
Skills: []MemorySkill{
{Name: "security-scan", Description: "Run security scanner", Why: "Matches user intent"},
},
Lessons: []MemoryLesson{
{ID: "1", Level: "L1", Text: "Always check dependencies"},
},
TotalResults: 2,
}
result := FormatMemoryForPrompt(mem)
require.Contains(t, result, "## Relevant Skills")
require.Contains(t, result, "security-scan")
require.Contains(t, result, "## Relevant Knowledge")
require.Contains(t, result, "Always check dependencies")
}
func TestFormatMemoryForPrompt_Empty(t *testing.T) {
mem := RetrieveMemoryOutput{
TotalResults: 0,
}
result := FormatMemoryForPrompt(mem)
require.Equal(t, "", result)
}
+298
View File
@@ -0,0 +1,298 @@
package action
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"time"
)
// NotifyStatusInput is input for NotifyStatusActivity
type NotifyStatusInput struct {
Channel string `json:"channel"` // slack, email, webhook
Status string `json:"status"` // success, failure, warning
Message string `json:"message"`
}
// NotifyStatusOutput is output from NotifyStatusActivity
type NotifyStatusOutput struct {
NotificationID string `json:"notificationId"`
Timestamp string `json:"timestamp"`
}
// NotifyStatusActivity sends notifications
func NotifyStatusActivity(ctx context.Context, in NotifyStatusInput) (NotifyStatusOutput, error) {
logger := newActivityLogger(ctx)
logger.Info("NotifyStatusActivity started", "channel", in.Channel, "status", in.Status)
timestamp := time.Now().Format(time.RFC3339)
notificationID := fmt.Sprintf("notify-%d", time.Now().UnixNano())
switch in.Channel {
case "slack":
if err := sendSlackNotification(ctx, in); err != nil {
logger.Warn("Slack notification failed", "error", err)
// Don't fail the activity, just log
}
case "webhook":
if err := sendWebhookNotification(ctx, in); err != nil {
logger.Warn("Webhook notification failed", "error", err)
}
case "email":
// Email would require SMTP setup - log for now
logger.Info("Email notification (logged)", "message", in.Message)
default:
logger.Info("Notification logged", "channel", in.Channel, "message", in.Message)
}
logger.Info("NotifyStatusActivity completed", "notificationId", notificationID)
return NotifyStatusOutput{
NotificationID: notificationID,
Timestamp: timestamp,
}, nil
}
func sendSlackNotification(ctx context.Context, in NotifyStatusInput) error {
webhookURL := os.Getenv("SLACK_WEBHOOK_URL")
if webhookURL == "" {
return fmt.Errorf("SLACK_WEBHOOK_URL not set")
}
// Map status to emoji
emoji := "️"
switch in.Status {
case "success":
emoji = "✅"
case "failure":
emoji = "❌"
case "warning":
emoji = "⚠️"
}
payload := map[string]string{
"text": fmt.Sprintf("%s *%s*: %s", emoji, in.Status, in.Message),
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, "POST", webhookURL, bytes.NewBuffer(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("slack returned status %d", resp.StatusCode)
}
return nil
}
func sendWebhookNotification(ctx context.Context, in NotifyStatusInput) error {
webhookURL := os.Getenv("NOTIFICATION_WEBHOOK_URL")
if webhookURL == "" {
return fmt.Errorf("NOTIFICATION_WEBHOOK_URL not set")
}
payload := map[string]interface{}{
"channel": in.Channel,
"status": in.Status,
"message": in.Message,
"timestamp": time.Now().Format(time.RFC3339),
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, "POST", webhookURL, bytes.NewBuffer(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("webhook returned status %d", resp.StatusCode)
}
return nil
}
// ArchiveResultsInput is input for ArchiveResultsActivity
type ArchiveResultsInput struct {
ReportPath string `json:"reportPath"`
Destination string `json:"destination"` // s3://bucket/path or local path
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// ArchiveResultsOutput is output from ArchiveResultsActivity
type ArchiveResultsOutput struct {
ArchiveURL string `json:"archiveUrl"`
ArchiveSize int64 `json:"archiveSize"`
}
// ArchiveResultsActivity archives results to storage
func ArchiveResultsActivity(ctx context.Context, in ArchiveResultsInput) (ArchiveResultsOutput, error) {
logger := newActivityLogger(ctx)
logger.Info("ArchiveResultsActivity started", "reportPath", in.ReportPath, "destination", in.Destination)
// Check if source exists
info, err := os.Stat(in.ReportPath)
if err != nil {
return ArchiveResultsOutput{}, fmt.Errorf("report not found: %s", in.ReportPath)
}
// For now, just copy to local destination or log for cloud
var archiveURL string
var archiveSize int64
if len(in.Destination) > 5 && in.Destination[:5] == "s3://" {
// Would use AWS SDK - for now just log
logger.Info("Would upload to S3", "destination", in.Destination)
archiveURL = in.Destination
archiveSize = info.Size()
} else {
// Copy to local destination
content, err := os.ReadFile(in.ReportPath)
if err != nil {
return ArchiveResultsOutput{}, fmt.Errorf("failed to read report: %w", err)
}
destPath := in.Destination
if destPath == "" {
destPath = fmt.Sprintf("/tmp/archive-%d", time.Now().UnixNano())
}
if err := os.WriteFile(destPath, content, 0644); err != nil {
return ArchiveResultsOutput{}, fmt.Errorf("failed to write archive: %w", err)
}
archiveURL = destPath
archiveSize = int64(len(content))
}
logger.Info("ArchiveResultsActivity completed", "archiveUrl", archiveURL, "size", archiveSize)
return ArchiveResultsOutput{
ArchiveURL: archiveURL,
ArchiveSize: archiveSize,
}, nil
}
// DeploymentPreCheckInput is input for DeploymentPreCheckActivity
type DeploymentPreCheckInput struct {
Path string `json:"path"`
CheckType string `json:"checkType,omitempty"` // lint, test, build, all
}
// DeploymentPreCheckOutput is output from DeploymentPreCheckActivity
type DeploymentPreCheckOutput struct {
Passed bool `json:"passed"`
Failures []string `json:"failures"`
Warnings []string `json:"warnings"`
}
// DeploymentPreCheckActivity validates deployment readiness
func DeploymentPreCheckActivity(ctx context.Context, in DeploymentPreCheckInput) (DeploymentPreCheckOutput, error) {
logger := newActivityLogger(ctx)
logger.Info("DeploymentPreCheckActivity started", "path", in.Path, "checkType", in.CheckType)
output := DeploymentPreCheckOutput{
Passed: true,
Failures: []string{},
Warnings: []string{},
}
checkType := in.CheckType
if checkType == "" {
checkType = "all"
}
// Check if path exists
if _, err := os.Stat(in.Path); os.IsNotExist(err) {
output.Passed = false
output.Failures = append(output.Failures, fmt.Sprintf("path does not exist: %s", in.Path))
return output, nil
}
// Check for Go project
isGo := false
if _, err := os.Stat(fmt.Sprintf("%s/go.mod", in.Path)); err == nil {
isGo = true
}
if isGo && (checkType == "all" || checkType == "build") {
// Try go build
cmd := exec.CommandContext(ctx, "go", "build", "./...")
cmd.Dir = in.Path
if buildOut, err := cmd.CombinedOutput(); err != nil {
output.Passed = false
output.Failures = append(output.Failures, fmt.Sprintf("build failed: %s", string(buildOut)))
}
}
if isGo && (checkType == "all" || checkType == "test") {
// Try go test
cmd := exec.CommandContext(ctx, "go", "test", "-short", "./...")
cmd.Dir = in.Path
if testOut, err := cmd.CombinedOutput(); err != nil {
output.Passed = false
output.Failures = append(output.Failures, fmt.Sprintf("tests failed: %s", string(testOut)))
}
}
if isGo && (checkType == "all" || checkType == "lint") {
// Try go vet
cmd := exec.CommandContext(ctx, "go", "vet", "./...")
cmd.Dir = in.Path
if vetOut, err := cmd.CombinedOutput(); err != nil {
output.Warnings = append(output.Warnings, fmt.Sprintf("vet issues: %s", string(vetOut)))
}
}
logger.Info("DeploymentPreCheckActivity completed", "passed", output.Passed, "failures", len(output.Failures))
return output, nil
}
// ApproveWorkflowInput is input for ApproveWorkflowActivity
type ApproveWorkflowInput struct {
WorkflowID string `json:"workflowId"`
RequiredApprovals int `json:"requiredApprovals,omitempty"`
TimeoutMinutes int `json:"timeoutMinutes,omitempty"`
}
// ApproveWorkflowOutput is output from ApproveWorkflowActivity
type ApproveWorkflowOutput struct {
Approved bool `json:"approved"`
Approver string `json:"approver,omitempty"`
Timestamp string `json:"timestamp"`
}
// ApproveWorkflowActivity handles approval workflow (auto-approves for now)
func ApproveWorkflowActivity(ctx context.Context, in ApproveWorkflowInput) (ApproveWorkflowOutput, error) {
logger := newActivityLogger(ctx)
logger.Info("ApproveWorkflowActivity started", "workflowId", in.WorkflowID)
// For now, auto-approve
// In production, this would wait for human approval via signal or external system
timestamp := time.Now().Format(time.RFC3339)
logger.Info("ApproveWorkflowActivity completed (auto-approved)")
return ApproveWorkflowOutput{
Approved: true,
Approver: "system-auto",
Timestamp: timestamp,
}, nil
}
+130
View File
@@ -0,0 +1,130 @@
package action
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func TestNotifyStatusActivity(t *testing.T) {
// Test with default channel (logs only)
output, err := NotifyStatusActivity(context.Background(), NotifyStatusInput{
Channel: "log",
Status: "success",
Message: "Test notification",
})
require.NoError(t, err)
require.NotEmpty(t, output.NotificationID)
require.NotEmpty(t, output.Timestamp)
}
func TestNotifyStatusActivity_AllStatuses(t *testing.T) {
statuses := []string{"success", "failure", "warning"}
for _, status := range statuses {
t.Run(status, func(t *testing.T) {
output, err := NotifyStatusActivity(context.Background(), NotifyStatusInput{
Channel: "log",
Status: status,
Message: "Test " + status,
})
require.NoError(t, err)
require.NotEmpty(t, output.NotificationID)
})
}
}
func TestArchiveResultsActivity(t *testing.T) {
// Create temp source file
tmpDir, err := os.MkdirTemp("", "archive-test")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
srcPath := filepath.Join(tmpDir, "report.txt")
require.NoError(t, os.WriteFile(srcPath, []byte("test report content"), 0644))
// Archive to local destination
destPath := filepath.Join(tmpDir, "archive.txt")
output, err := ArchiveResultsActivity(context.Background(), ArchiveResultsInput{
ReportPath: srcPath,
Destination: destPath,
})
require.NoError(t, err)
require.Equal(t, destPath, output.ArchiveURL)
require.Greater(t, output.ArchiveSize, int64(0))
// Verify file was copied
content, err := os.ReadFile(destPath)
require.NoError(t, err)
require.Equal(t, "test report content", string(content))
}
func TestArchiveResultsActivity_NotFound(t *testing.T) {
_, err := ArchiveResultsActivity(context.Background(), ArchiveResultsInput{
ReportPath: "/nonexistent/file.txt",
Destination: "/tmp/archive.txt",
})
require.Error(t, err)
require.Contains(t, err.Error(), "not found")
}
func TestDeploymentPreCheckActivity(t *testing.T) {
// Create temp directory with valid Go code
tmpDir, err := os.MkdirTemp("", "precheck-test")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
// Create go.mod
goMod := `module test
go 1.21
`
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte(goMod), 0644))
// Create valid Go file
goCode := `package main
func main() {
println("hello")
}
`
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte(goCode), 0644))
// Run pre-check
output, err := DeploymentPreCheckActivity(context.Background(), DeploymentPreCheckInput{
Path: tmpDir,
CheckType: "build",
})
require.NoError(t, err)
require.True(t, output.Passed)
require.Empty(t, output.Failures)
}
func TestDeploymentPreCheckActivity_PathNotExist(t *testing.T) {
output, err := DeploymentPreCheckActivity(context.Background(), DeploymentPreCheckInput{
Path: "/nonexistent/path",
})
require.NoError(t, err) // Activity doesn't error, just reports failure
require.False(t, output.Passed)
require.NotEmpty(t, output.Failures)
}
func TestApproveWorkflowActivity(t *testing.T) {
output, err := ApproveWorkflowActivity(context.Background(), ApproveWorkflowInput{
WorkflowID: "test-workflow-123",
RequiredApprovals: 1,
TimeoutMinutes: 60,
})
require.NoError(t, err)
// Auto-approved
require.True(t, output.Approved)
require.Equal(t, "system-auto", output.Approver)
require.NotEmpty(t, output.Timestamp)
}
+89
View File
@@ -0,0 +1,89 @@
package action
import (
"context"
"fmt"
"github.com/rockliang/poimen/workflows/internal/routing"
"go.temporal.io/sdk/activity"
)
// LLMRouterActivity is the Temporal activity that routes user requests to workflows
func LLMRouterActivity(ctx context.Context, input routing.LLMRouterInput) (*routing.LLMRouterOutput, error) {
logger := activity.GetLogger(ctx)
logger.Info("LLMRouterActivity started", "message", input.Message)
// Load knowledge base
kb, err := routing.LoadKnowledgeBaseFromDefaultPath()
if err != nil {
return nil, fmt.Errorf("failed to load knowledge base: %w", err)
}
// Create router
router, err := routing.NewLLMRouter(kb)
if err != nil {
return nil, fmt.Errorf("failed to create router: %w", err)
}
// Route the request
output, err := router.Route(ctx, input)
if err != nil {
logger.Error("LLMRouterActivity failed", "error", err)
return nil, err
}
if output.IsCron {
logger.Info("LLMRouterActivity completed (cron)",
"workflowName", output.CronSpec.Name,
"schedule", output.CronSpec.Schedule,
"stateCount", len(output.CronSpec.States))
} else {
logger.Info("LLMRouterActivity completed",
"workflowName", output.Spec.Name,
"stateCount", len(output.Spec.States))
}
return output, nil
}
// ValidateWorkflowSpecActivity validates a workflow spec before execution
func ValidateWorkflowSpecActivity(ctx context.Context, spec routing.WorkflowSpec) (*routing.ValidationResult, error) {
logger := activity.GetLogger(ctx)
logger.Info("ValidateWorkflowSpecActivity started", "workflowName", spec.Name)
kb, err := routing.LoadKnowledgeBaseFromDefaultPath()
if err != nil {
return nil, fmt.Errorf("failed to load knowledge base: %w", err)
}
validator := routing.NewValidator(kb)
result := validator.ValidateWorkflowSpec(&spec)
logger.Info("ValidateWorkflowSpecActivity completed",
"valid", result.Valid,
"errorCount", len(result.Errors))
return result, nil
}
// ValidateCronWorkflowSpecActivity validates a cron workflow spec before scheduling
func ValidateCronWorkflowSpecActivity(ctx context.Context, spec routing.CronWorkflowSpec) (*routing.ValidationResult, error) {
logger := activity.GetLogger(ctx)
logger.Info("ValidateCronWorkflowSpecActivity started",
"workflowName", spec.Name,
"schedule", spec.Schedule)
kb, err := routing.LoadKnowledgeBaseFromDefaultPath()
if err != nil {
return nil, fmt.Errorf("failed to load knowledge base: %w", err)
}
validator := routing.NewValidator(kb)
result := validator.ValidateCronWorkflowSpec(&spec)
logger.Info("ValidateCronWorkflowSpecActivity completed",
"valid", result.Valid,
"errorCount", len(result.Errors))
return result, nil
}