refactor: rename action→activity, statemachine→workflow, remove HTTP API layer
- action/ → activity/ (Temporal activities) - statemachine/ → workflow/ (Temporal workflows) - Removed internal/api/ and cmd/server/ (api-gw handles HTTP, Temporal is the API) - Created pkg/types/types.go as single source of truth for all shared types - Extracted CallRoleLLM helper (DRY: implementer/planner/judge shared pattern) - Fixed circular import: workflow_graph_query uses string activity names - Fixed logger.logf → logger.Info/Warn (method didn't exist) - Fixed routing types: added Branches, Activity, BackoffSeconds, TaskActivity - Fixed db.Canvas.Name, db.Client→DB, GetWorkflow→FetchWorkflow - Removed unused imports - All tests pass, build clean, vet clean
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
package activity
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package activity
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package activity
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AssumeRoleInput is the input to AssumeRoleActivity
|
||||
type AssumeRoleInput struct {
|
||||
// Identity is the user/service identity requesting access
|
||||
Identity string `json:"identity"`
|
||||
|
||||
// ClientID is the OAuth2/OIDC client ID (from vault or env)
|
||||
ClientID string `json:"clientId,omitempty"`
|
||||
|
||||
// ClientSecret is the OAuth2/OIDC client secret (from vault or env)
|
||||
ClientSecret string `json:"clientSecret,omitempty"`
|
||||
|
||||
// Scope defines what APIs this token can access (e.g., "llm:read llm:write")
|
||||
Scope string `json:"scope"`
|
||||
|
||||
// DurationSeconds is how long the token is valid (default: 3600 = 1 hour)
|
||||
DurationSeconds int `json:"durationSeconds,omitempty"`
|
||||
|
||||
// AuthServerURL is the auth server endpoint (from env if not provided)
|
||||
AuthServerURL string `json:"authServerUrl,omitempty"`
|
||||
}
|
||||
|
||||
// AssumeRoleOutput is the output from AssumeRoleActivity
|
||||
type AssumeRoleOutput struct {
|
||||
// Token is the JWT token for calling api.riotpiao.com
|
||||
Token string `json:"token"`
|
||||
|
||||
// ExpiresAt is when the token expires (Unix timestamp)
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
|
||||
// ExpiresIn is the duration in seconds until expiration
|
||||
ExpiresIn int `json:"expiresIn"`
|
||||
|
||||
// TokenType is typically "Bearer"
|
||||
TokenType string `json:"tokenType"`
|
||||
}
|
||||
|
||||
// oauthTokenRequest is sent to the auth server
|
||||
type oauthTokenRequest struct {
|
||||
GrantType string `json:"grant_type"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
Scope string `json:"scope"`
|
||||
Subject string `json:"subject,omitempty"` // The identity being assumed
|
||||
}
|
||||
|
||||
// oauthTokenResponse is returned from the auth server
|
||||
type oauthTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
// AssumeRoleActivity requests a temporary JWT token for accessing LLM APIs
|
||||
//
|
||||
// This activity works like AWS AssumeRole:
|
||||
// 1. User provides identity + scope of access needed
|
||||
// 2. Activity exchanges credentials with auth server
|
||||
// 3. Returns JWT token valid for a limited time
|
||||
// 4. Caller uses token in subsequent LLM API calls
|
||||
//
|
||||
// Security: Credentials should come from vault/secrets, never hardcoded
|
||||
func AssumeRoleActivity(ctx context.Context, input *AssumeRoleInput) (*AssumeRoleOutput, error) {
|
||||
// Validate inputs
|
||||
if err := validateAssumeRoleInput(input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Resolve configuration from input + environment
|
||||
config, err := resolveAssumeRoleConfig(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Request token from auth server
|
||||
tokenResp, err := requestAuthToken(ctx, config, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build output
|
||||
return buildAssumeRoleOutput(tokenResp), nil
|
||||
}
|
||||
|
||||
// validateAssumeRoleInput checks required fields
|
||||
func validateAssumeRoleInput(input *AssumeRoleInput) error {
|
||||
if input.Identity == "" {
|
||||
return fmt.Errorf("identity is required")
|
||||
}
|
||||
if input.Scope == "" {
|
||||
return fmt.Errorf("scope is required (e.g., 'llm:read' or 'llm:read llm:write')")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// assumeRoleConfig holds resolved configuration
|
||||
type assumeRoleConfig struct {
|
||||
authServerURL string
|
||||
clientID string
|
||||
clientSecret string
|
||||
duration int
|
||||
}
|
||||
|
||||
// resolveAssumeRoleConfig gets config from input or environment
|
||||
func resolveAssumeRoleConfig(input *AssumeRoleInput) (*assumeRoleConfig, error) {
|
||||
cfg := &assumeRoleConfig{}
|
||||
|
||||
// Helper function to avoid DRY violation
|
||||
getOrEnv := func(val, envKey, fieldName string) (string, error) {
|
||||
if val != "" {
|
||||
return val, nil
|
||||
}
|
||||
if val = os.Getenv(envKey); val != "" {
|
||||
return val, nil
|
||||
}
|
||||
return "", fmt.Errorf("%s not provided and %s not set", fieldName, envKey)
|
||||
}
|
||||
|
||||
var err error
|
||||
if cfg.authServerURL, err = getOrEnv(input.AuthServerURL, "AUTH_SERVER_URL", "authServerUrl"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.clientID, err = getOrEnv(input.ClientID, "OAUTH_CLIENT_ID", "clientId"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.clientSecret, err = getOrEnv(input.ClientSecret, "OAUTH_CLIENT_SECRET", "clientSecret"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate and set duration
|
||||
cfg.duration = input.DurationSeconds
|
||||
if cfg.duration == 0 {
|
||||
cfg.duration = 3600 // 1 hour default
|
||||
}
|
||||
if cfg.duration > 86400 {
|
||||
cfg.duration = 86400 // Max 24 hours
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// requestAuthToken calls the auth server and returns the token response
|
||||
func requestAuthToken(ctx context.Context, config *assumeRoleConfig, input *AssumeRoleInput) (*oauthTokenResponse, error) {
|
||||
tokenReq := oauthTokenRequest{
|
||||
GrantType: "client_credentials",
|
||||
ClientID: config.clientID,
|
||||
ClientSecret: config.clientSecret,
|
||||
Scope: input.Scope,
|
||||
Subject: input.Identity,
|
||||
}
|
||||
|
||||
reqBody, err := json.Marshal(tokenReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal token request: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST",
|
||||
fmt.Sprintf("%s/oauth/token", config.authServerURL),
|
||||
bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to call auth server: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("auth server returned status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var tokenResp oauthTokenResponse
|
||||
if err := json.Unmarshal(respBody, &tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal token response: %w", err)
|
||||
}
|
||||
|
||||
if tokenResp.AccessToken == "" {
|
||||
return nil, fmt.Errorf("auth server returned empty access token")
|
||||
}
|
||||
|
||||
return &tokenResp, nil
|
||||
}
|
||||
|
||||
// buildAssumeRoleOutput constructs the output from token response
|
||||
func buildAssumeRoleOutput(tokenResp *oauthTokenResponse) *AssumeRoleOutput {
|
||||
expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Unix()
|
||||
return &AssumeRoleOutput{
|
||||
Token: tokenResp.AccessToken,
|
||||
ExpiresAt: expiresAt,
|
||||
ExpiresIn: tokenResp.ExpiresIn,
|
||||
TokenType: tokenResp.TokenType,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
package activity
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/rockliang/poimen/workflows/pkg/db"
|
||||
)
|
||||
|
||||
// CanvasCompatibilityOutput validation results
|
||||
type CanvasCompatibilityOutput struct {
|
||||
IsValid bool `json:"is_valid"`
|
||||
Incompatibilities []IncompatibilityWarning `json:"incompatibilities"`
|
||||
DisconnectedNodes []string `json:"disconnected_nodes"`
|
||||
Warnings []string `json:"warnings"`
|
||||
}
|
||||
|
||||
// IncompatibilityWarning explains why two activities can't be connected
|
||||
type IncompatibilityWarning struct {
|
||||
Source string `json:"source"` // Source node ID
|
||||
Target string `json:"target"` // Target node ID
|
||||
Reason string `json:"reason"` // Why they can't connect
|
||||
SourceNeeds string `json:"source_needs"` // What source would need to output
|
||||
TargetNeeds string `json:"target_needs"` // What target requires as input
|
||||
Suggestion string `json:"suggestion"` // Suggestion to make it work
|
||||
}
|
||||
|
||||
// ActivitySchema describes what an activity needs/provides
|
||||
type ActivitySchema struct {
|
||||
ActivityType string `json:"activity_type"`
|
||||
Inputs map[string]InputField `json:"inputs"`
|
||||
Outputs map[string]OutputField `json:"outputs"`
|
||||
}
|
||||
|
||||
type InputField struct {
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
Required bool `json:"required"`
|
||||
Enum []string `json:"enum,omitempty"`
|
||||
}
|
||||
|
||||
type OutputField struct {
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// getActivitySchema returns schema from knowledge base
|
||||
func getActivitySchema(activityType string) (*ActivitySchema, error) {
|
||||
kb := knowledgeBaseData()
|
||||
if kb == "" {
|
||||
return nil, fmt.Errorf("knowledge base not loaded")
|
||||
}
|
||||
|
||||
var activities []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(kb), &activities); err != nil {
|
||||
// Try to extract activities from full KB structure
|
||||
var fullKB map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(kb), &fullKB); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse knowledge base")
|
||||
}
|
||||
if activitiesRaw, ok := fullKB["activities"]; ok {
|
||||
if b, err := json.Marshal(activitiesRaw); err == nil {
|
||||
if err := json.Unmarshal(b, &activities); err != nil {
|
||||
return nil, fmt.Errorf("failed to extract activities from KB")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find matching activity
|
||||
for _, act := range activities {
|
||||
if name, ok := act["name"].(string); ok {
|
||||
if toActivityName(activityType) == name {
|
||||
// Convert to ActivitySchema
|
||||
schema := &ActivitySchema{
|
||||
ActivityType: activityType,
|
||||
Inputs: make(map[string]InputField),
|
||||
Outputs: make(map[string]OutputField),
|
||||
}
|
||||
|
||||
if inputs, ok := act["inputs"].(map[string]interface{}); ok {
|
||||
for key, val := range inputs {
|
||||
if field, ok := val.(map[string]interface{}); ok {
|
||||
schema.Inputs[key] = parseInputField(field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if outputs, ok := act["outputs"].(map[string]interface{}); ok {
|
||||
for key, val := range outputs {
|
||||
if field, ok := val.(map[string]interface{}); ok {
|
||||
schema.Outputs[key] = parseOutputField(field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return schema, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("activity %s not found in knowledge base", activityType)
|
||||
}
|
||||
|
||||
func parseInputField(data map[string]interface{}) InputField {
|
||||
field := InputField{}
|
||||
if t, ok := data["type"].(string); ok {
|
||||
field.Type = t
|
||||
}
|
||||
if d, ok := data["description"].(string); ok {
|
||||
field.Description = d
|
||||
}
|
||||
if r, ok := data["required"].(bool); ok {
|
||||
field.Required = r
|
||||
}
|
||||
return field
|
||||
}
|
||||
|
||||
func parseOutputField(data map[string]interface{}) OutputField {
|
||||
field := OutputField{}
|
||||
if t, ok := data["type"].(string); ok {
|
||||
field.Type = t
|
||||
}
|
||||
if d, ok := data["description"].(string); ok {
|
||||
field.Description = d
|
||||
}
|
||||
return field
|
||||
}
|
||||
|
||||
// CheckConnectionCompatibility validates if source can connect to target
|
||||
func CheckConnectionCompatibility(sourceNode, targetNode db.WorkflowNode) []IncompatibilityWarning {
|
||||
warnings := []IncompatibilityWarning{}
|
||||
|
||||
sourceSchema, err := getActivitySchema(sourceNode.Type)
|
||||
if err != nil {
|
||||
warnings = append(warnings, IncompatibilityWarning{
|
||||
Source: sourceNode.ID,
|
||||
Target: targetNode.ID,
|
||||
Reason: fmt.Sprintf("Source activity schema not found: %v", err),
|
||||
Suggestion: "Ensure source activity type is registered in knowledge base",
|
||||
})
|
||||
return warnings
|
||||
}
|
||||
|
||||
targetSchema, err := getActivitySchema(targetNode.Type)
|
||||
if err != nil {
|
||||
warnings = append(warnings, IncompatibilityWarning{
|
||||
Source: sourceNode.ID,
|
||||
Target: targetNode.ID,
|
||||
Reason: fmt.Sprintf("Target activity schema not found: %v", err),
|
||||
Suggestion: "Ensure target activity type is registered in knowledge base",
|
||||
})
|
||||
return warnings
|
||||
}
|
||||
|
||||
// Check if source produces outputs that target can consume
|
||||
if len(sourceSchema.Outputs) == 0 {
|
||||
warnings = append(warnings, IncompatibilityWarning{
|
||||
Source: sourceNode.ID,
|
||||
Target: targetNode.ID,
|
||||
Reason: fmt.Sprintf("%s produces no outputs", sourceNode.Type),
|
||||
SourceNeeds: "any output",
|
||||
Suggestion: "Source activity must produce outputs",
|
||||
})
|
||||
return warnings
|
||||
}
|
||||
|
||||
if len(targetSchema.Inputs) == 0 {
|
||||
warnings = append(warnings, IncompatibilityWarning{
|
||||
Source: sourceNode.ID,
|
||||
Target: targetNode.ID,
|
||||
Reason: fmt.Sprintf("%s accepts no inputs", targetNode.Type),
|
||||
TargetNeeds: "no input",
|
||||
Suggestion: "Target activity must accept inputs. Check if it's a terminal activity.",
|
||||
})
|
||||
return warnings
|
||||
}
|
||||
|
||||
// Match outputs to inputs
|
||||
sourceOutputs := getOutputNames(sourceSchema.Outputs)
|
||||
targetInputs := getInputNames(targetSchema.Inputs)
|
||||
|
||||
if len(sourceOutputs) == 0 || len(targetInputs) == 0 {
|
||||
warnings = append(warnings, IncompatibilityWarning{
|
||||
Source: sourceNode.ID,
|
||||
Target: targetNode.ID,
|
||||
Reason: "No compatible output/input fields found",
|
||||
SourceNeeds: strings.Join(sourceOutputs, ", "),
|
||||
TargetNeeds: strings.Join(targetInputs, ", "),
|
||||
Suggestion: "Use LLM transformation to map outputs to inputs",
|
||||
})
|
||||
}
|
||||
|
||||
return warnings
|
||||
}
|
||||
|
||||
// CheckCanvasConnectivity analyzes all suggested edges for compatibility
|
||||
func CheckCanvasConnectivity(nodes []db.WorkflowNode, suggestedEdges []EdgeWithWording) []IncompatibilityWarning {
|
||||
warnings := []IncompatibilityWarning{}
|
||||
nodeMap := make(map[string]db.WorkflowNode)
|
||||
for _, n := range nodes {
|
||||
nodeMap[n.ID] = n
|
||||
}
|
||||
|
||||
for _, edge := range suggestedEdges {
|
||||
sourceNode, ok := nodeMap[edge.Source]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
targetNode, ok := nodeMap[edge.Target]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
edgeWarnings := CheckConnectionCompatibility(sourceNode, targetNode)
|
||||
warnings = append(warnings, edgeWarnings...)
|
||||
}
|
||||
|
||||
return warnings
|
||||
}
|
||||
|
||||
// IdentifyDisconnectedNodes finds nodes that can't connect to anything
|
||||
func IdentifyDisconnectedNodes(nodes []db.WorkflowNode, suggestedEdges []EdgeWithWording) []string {
|
||||
edgeMap := make(map[string]bool)
|
||||
for _, edge := range suggestedEdges {
|
||||
edgeMap[edge.Source] = true
|
||||
edgeMap[edge.Target] = true
|
||||
}
|
||||
|
||||
var disconnected []string
|
||||
for _, node := range nodes {
|
||||
if !edgeMap[node.ID] {
|
||||
disconnected = append(disconnected, node.ID)
|
||||
}
|
||||
}
|
||||
return disconnected
|
||||
}
|
||||
|
||||
// toActivityName converts canvas type to activity name (e.g., "clone-repo" -> "CloneRepoActivity")
|
||||
func toActivityName(canvasType string) string {
|
||||
parts := strings.Split(canvasType, "-")
|
||||
var result string
|
||||
for _, part := range parts {
|
||||
if part != "" {
|
||||
result += strings.ToUpper(part[:1]) + strings.ToLower(part[1:])
|
||||
}
|
||||
}
|
||||
return result + "Activity"
|
||||
}
|
||||
|
||||
// getOutputNames extracts output field names
|
||||
func getOutputNames(outputs map[string]OutputField) []string {
|
||||
var names []string
|
||||
for name := range outputs {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// getInputNames extracts input field names (required ones highlighted)
|
||||
func getInputNames(inputs map[string]InputField) []string {
|
||||
var names []string
|
||||
for name, field := range inputs {
|
||||
if field.Required {
|
||||
names = append(names, name+"*")
|
||||
} else {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// SuggestDataTransformation proposes how to connect incompatible activities
|
||||
func SuggestDataTransformation(sourceNode, targetNode db.WorkflowNode) string {
|
||||
sourceSchema, _ := getActivitySchema(sourceNode.Type)
|
||||
targetSchema, _ := getActivitySchema(targetNode.Type)
|
||||
|
||||
if sourceSchema == nil || targetSchema == nil {
|
||||
return "Cannot analyze compatibility without schemas"
|
||||
}
|
||||
|
||||
sourceOuts := getOutputNames(sourceSchema.Outputs)
|
||||
targetIns := getInputNames(targetSchema.Inputs)
|
||||
|
||||
return fmt.Sprintf(
|
||||
"To connect %s → %s:\n"+
|
||||
" %s outputs: %s\n"+
|
||||
" %s needs: %s\n"+
|
||||
" Solution: Use LLM transformation node to map outputs to inputs",
|
||||
sourceNode.Label, targetNode.Label,
|
||||
sourceNode.Type, strings.Join(sourceOuts, ", "),
|
||||
targetNode.Type, strings.Join(targetIns, ", "),
|
||||
)
|
||||
}
|
||||
|
||||
// knowledgeBaseData returns raw KB JSON (stub - implement with actual KB loading)
|
||||
func knowledgeBaseData() string {
|
||||
// This would load from activity_knowledge_base.json
|
||||
// For now, return empty - real implementation loads from file
|
||||
return ""
|
||||
}
|
||||
|
||||
// CanvasCompatibilityActivity validates workflow canvas for type mismatches and isolation
|
||||
func CanvasCompatibilityActivity(ctx interface{}, input CanvasCompatibilityInput) (CanvasCompatibilityOutput, error) {
|
||||
output := CanvasCompatibilityOutput{
|
||||
IsValid: true,
|
||||
Incompatibilities: []IncompatibilityWarning{},
|
||||
DisconnectedNodes: []string{},
|
||||
Warnings: []string{},
|
||||
}
|
||||
|
||||
// Check all edges for compatibility
|
||||
for _, edge := range input.Edges {
|
||||
var sourceNode, targetNode *db.WorkflowNode
|
||||
for i := range input.Nodes {
|
||||
if input.Nodes[i].ID == edge.Source {
|
||||
sourceNode = &input.Nodes[i]
|
||||
}
|
||||
if input.Nodes[i].ID == edge.Target {
|
||||
targetNode = &input.Nodes[i]
|
||||
}
|
||||
}
|
||||
|
||||
if sourceNode != nil && targetNode != nil {
|
||||
if warning, err := ValidateConnection(sourceNode, targetNode); err != nil {
|
||||
output.IsValid = false
|
||||
output.Incompatibilities = append(output.Incompatibilities, warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find disconnected nodes
|
||||
connected := make(map[string]bool)
|
||||
for _, edge := range input.Edges {
|
||||
connected[edge.Source] = true
|
||||
connected[edge.Target] = true
|
||||
}
|
||||
|
||||
for _, node := range input.Nodes {
|
||||
if node.Type == "activity" && !connected[node.ID] {
|
||||
output.DisconnectedNodes = append(output.DisconnectedNodes, node.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// ValidateConnection checks if two nodes can be connected based on their types.
|
||||
func ValidateConnection(source, target *db.WorkflowNode) (IncompatibilityWarning, error) {
|
||||
if source.Type != "activity" || target.Type != "activity" {
|
||||
return IncompatibilityWarning{
|
||||
Source: source.ID,
|
||||
Target: target.ID,
|
||||
Reason: fmt.Sprintf("Cannot connect %s to %s: both must be activity type", source.Type, target.Type),
|
||||
}, fmt.Errorf("type mismatch")
|
||||
}
|
||||
return IncompatibilityWarning{}, nil
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package activity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/rockliang/poimen/workflows/activity/llm"
|
||||
"github.com/rockliang/poimen/workflows/pkg/db"
|
||||
)
|
||||
|
||||
// CanvasReasonerOutput returns suggested edges and reasoning
|
||||
type CanvasReasonerOutput struct {
|
||||
SuggestedEdges []EdgeWithWording `json:"suggested_edges"` // Edges with wording
|
||||
RemovedEdges []db.WorkflowEdge `json:"removed_edges,omitempty"` // Edges to remove
|
||||
Reasoning string `json:"reasoning"` // LLM explanation
|
||||
Confidence float64 `json:"confidence"` // 0.0-1.0
|
||||
IncompatibleEdges []IncompatibilityWarning `json:"incompatible_edges,omitempty"` // Can't connect
|
||||
DisconnectedNodes []string `json:"disconnected_nodes,omitempty"` // No connections
|
||||
UserAlerts []string `json:"user_alerts,omitempty"` // Human-readable warnings
|
||||
}
|
||||
|
||||
// CanvasReasonerActivity uses LLM to infer connections between workflow activities
|
||||
func CanvasReasonerActivity(ctx context.Context, in CanvasReasonerInput) (CanvasReasonerOutput, error) {
|
||||
logger := newActivityLogger(ctx)
|
||||
|
||||
output := CanvasReasonerOutput{
|
||||
SuggestedEdges: []EdgeWithWording{},
|
||||
}
|
||||
|
||||
if len(in.Nodes) == 0 {
|
||||
return output, fmt.Errorf("no nodes provided")
|
||||
}
|
||||
|
||||
logger.Info("Analyzing canvas with %d nodes, %d edges", len(in.Nodes), len(in.Edges))
|
||||
|
||||
// Build activity descriptions for LLM context
|
||||
nodeDesc := buildNodeDescriptions(in.Nodes)
|
||||
edgeDesc := buildEdgeDescriptions(in.Edges)
|
||||
|
||||
// Create prompt for LLM reasoning with relation wording
|
||||
systemPrompt := `You are a workflow automation expert. Analyze activities and suggest logical connections with semantic descriptions.
|
||||
|
||||
CRITICAL RULES:
|
||||
1. Only suggest edges where outputs→inputs match
|
||||
2. Provide relation wording: verb, source_output, target_input
|
||||
3. Assess connection confidence (0.0-1.0)
|
||||
4. Flag type mismatches that need transformers
|
||||
|
||||
Respond with JSON:
|
||||
{
|
||||
"edges": [
|
||||
{
|
||||
"source": "node-1",
|
||||
"target": "node-2",
|
||||
"relation_type": "data-flow|dependency|conditional|parallel",
|
||||
"relation_label": "Node1 outputs X → Node2 requires X",
|
||||
"relation_wording": {
|
||||
"verb": "outputs|depends-on|triggers|etc",
|
||||
"source_output": "field_name (type): description",
|
||||
"target_input": "field_name (type, required?): description",
|
||||
"connection_type": "direct-map|requires-transformer|conditional",
|
||||
"confidence": 0.95,
|
||||
"semantic_match": "Explanation of why this makes sense"
|
||||
}
|
||||
}
|
||||
],
|
||||
"reasoning": "Overall workflow structure explanation",
|
||||
"confidence": 0.85
|
||||
}`
|
||||
|
||||
userPrompt := fmt.Sprintf(`Canvas Analysis:
|
||||
|
||||
Nodes (including inputs/outputs):
|
||||
%s
|
||||
|
||||
Current Edges:
|
||||
%s
|
||||
|
||||
Task: %s
|
||||
|
||||
KEY RULES:
|
||||
- Preserve existing edges and suggest only NEW edges to add
|
||||
- SKIP any connections where input/output types don't match
|
||||
- If an activity has no outputs, it cannot be a source
|
||||
- If an activity has no inputs, it cannot be a target
|
||||
- Note any activities that are hard to connect (terminal activities, generators, etc)
|
||||
|
||||
Return ONLY valid JSON, no markdown code blocks.`, nodeDesc, edgeDesc, getReasoningTask(in.PreserveExisting))
|
||||
|
||||
logger.Info("Calling LLM reasoning (preserve_existing=%v)", in.PreserveExisting)
|
||||
|
||||
// Call LLM
|
||||
client, err := llm.NewClient()
|
||||
if err != nil {
|
||||
return output, fmt.Errorf("failed to create LLM client: %w", err)
|
||||
}
|
||||
|
||||
response, err := client.CreateMessage(ctx, llm.MessageInput{
|
||||
Model: ModelSpec{
|
||||
ModelID: "reasoning", // Use reasoning model for complex analysis
|
||||
},
|
||||
SystemPrompt: systemPrompt,
|
||||
Messages: []llm.MessageParam{
|
||||
{
|
||||
Role: "user",
|
||||
Content: userPrompt,
|
||||
},
|
||||
},
|
||||
AuthToken: in.AuthToken,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return output, fmt.Errorf("LLM reasoning failed: %w", err)
|
||||
}
|
||||
|
||||
// Parse LLM response
|
||||
var reasonerResp struct {
|
||||
Edges []EdgeWithWording `json:"edges"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(response), &reasonerResp); err != nil {
|
||||
logger.Warn("Failed to parse LLM response as JSON: %v", err)
|
||||
// Try to extract from response text
|
||||
output.Reasoning = response
|
||||
output.Confidence = 0.5
|
||||
return output, fmt.Errorf("failed to parse LLM response: %w", err)
|
||||
}
|
||||
|
||||
// Validate suggested edges
|
||||
nodeMap := make(map[string]bool)
|
||||
for _, n := range in.Nodes {
|
||||
nodeMap[n.ID] = true
|
||||
}
|
||||
|
||||
validEdges := []EdgeWithWording{}
|
||||
for _, edge := range reasonerResp.Edges {
|
||||
if !nodeMap[edge.Source] {
|
||||
logger.Warn("Suggested edge references unknown source: %s", edge.Source)
|
||||
continue
|
||||
}
|
||||
if !nodeMap[edge.Target] {
|
||||
logger.Warn("Suggested edge references unknown target: %s", edge.Target)
|
||||
continue
|
||||
}
|
||||
// Don't suggest self-loops
|
||||
if edge.Source == edge.Target {
|
||||
logger.Warn("Skipping self-loop: %s", edge.Source)
|
||||
continue
|
||||
}
|
||||
validEdges = append(validEdges, edge)
|
||||
}
|
||||
|
||||
output.SuggestedEdges = validEdges
|
||||
output.Reasoning = reasonerResp.Reasoning
|
||||
output.Confidence = reasonerResp.Confidence
|
||||
|
||||
// Check compatibility of suggested edges
|
||||
incompatibilities := CheckCanvasConnectivity(in.Nodes, validEdges)
|
||||
if len(incompatibilities) > 0 {
|
||||
output.IncompatibleEdges = incompatibilities
|
||||
logger.Warn("Found %d incompatible edge connections", len(incompatibilities))
|
||||
|
||||
// Generate user-friendly alerts
|
||||
for i, incompat := range incompatibilities {
|
||||
if i < 5 { // Limit to 5 alerts to avoid spam
|
||||
alert := fmt.Sprintf(
|
||||
"⚠️ %s → %s: %s. %s",
|
||||
incompat.Source, incompat.Target, incompat.Reason, incompat.Suggestion,
|
||||
)
|
||||
output.UserAlerts = append(output.UserAlerts, alert)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Identify disconnected nodes
|
||||
disconnected := IdentifyDisconnectedNodes(in.Nodes, validEdges)
|
||||
if len(disconnected) > 0 {
|
||||
output.DisconnectedNodes = disconnected
|
||||
logger.Warn("Found %d disconnected nodes", len(disconnected))
|
||||
|
||||
for _, nodeID := range disconnected {
|
||||
var label string
|
||||
for _, node := range in.Nodes {
|
||||
if node.ID == nodeID {
|
||||
label = node.Label
|
||||
break
|
||||
}
|
||||
}
|
||||
alert := fmt.Sprintf(
|
||||
"🔌 Node '%s' has no connections. Consider adding edges or removing it.",
|
||||
label,
|
||||
)
|
||||
output.UserAlerts = append(output.UserAlerts, alert)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("LLM suggested %d edges with confidence %.2f | %d incompatibilities | %d disconnected",
|
||||
len(validEdges), output.Confidence, len(incompatibilities), len(disconnected))
|
||||
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// buildNodeDescriptions creates readable node descriptions for LLM (including schemas)
|
||||
func buildNodeDescriptions(nodes []db.WorkflowNode) string {
|
||||
var desc string
|
||||
for i, node := range nodes {
|
||||
desc += fmt.Sprintf("%d. [%s] %s (type: %s)\n", i+1, node.ID, node.Label, node.Type)
|
||||
|
||||
// Add input/output schema info
|
||||
if schema, err := getActivitySchema(node.Type); err == nil {
|
||||
if len(schema.Inputs) > 0 {
|
||||
desc += fmt.Sprintf(" INPUTS: %v\n", getInputNames(schema.Inputs))
|
||||
} else {
|
||||
desc += fmt.Sprintf(" INPUTS: none (generator/trigger)\n")
|
||||
}
|
||||
if len(schema.Outputs) > 0 {
|
||||
desc += fmt.Sprintf(" OUTPUTS: %v\n", getOutputNames(schema.Outputs))
|
||||
} else {
|
||||
desc += fmt.Sprintf(" OUTPUTS: none (terminal/sink)\n")
|
||||
}
|
||||
}
|
||||
|
||||
if node.Data != nil {
|
||||
if b, err := json.MarshalIndent(node.Data, " ", " "); err == nil {
|
||||
desc += fmt.Sprintf(" CONFIG: %s\n", string(b))
|
||||
}
|
||||
}
|
||||
}
|
||||
return desc
|
||||
}
|
||||
|
||||
// buildEdgeDescriptions creates readable edge descriptions for LLM
|
||||
func buildEdgeDescriptions(edges []db.WorkflowEdge) string {
|
||||
if len(edges) == 0 {
|
||||
return "None"
|
||||
}
|
||||
var desc string
|
||||
for i, edge := range edges {
|
||||
desc += fmt.Sprintf("%d. %s → %s\n", i+1, edge.Source, edge.Target)
|
||||
}
|
||||
return desc
|
||||
}
|
||||
|
||||
// getReasoningTask returns task description based on preservation mode
|
||||
func getReasoningTask(preserveExisting bool) string {
|
||||
if preserveExisting {
|
||||
return "Keep all existing edges and suggest ONLY NEW edges to improve workflow"
|
||||
}
|
||||
return "Design optimal workflow by suggesting all connections and noting any redundant edges"
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package activity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/rockliang/poimen/workflows/pkg/db"
|
||||
)
|
||||
|
||||
// FetchCanvasRelationsActivity fetches canvas + relations from DB
|
||||
func FetchCanvasRelationsActivity(ctx context.Context, input FetchCanvasRelationsInput) (CanvasWithRelationsData, error) {
|
||||
logger := newActivityLogger(ctx)
|
||||
output := CanvasWithRelationsData{
|
||||
WorkflowID: input.WorkflowID,
|
||||
Version: input.Version,
|
||||
Nodes: []db.WorkflowNode{},
|
||||
Edges: []db.WorkflowEdge{},
|
||||
Relations: []EdgeWithWording{},
|
||||
}
|
||||
|
||||
logger.Info("Fetching canvas relations: %s v%d", input.WorkflowID, input.Version)
|
||||
|
||||
// Get database client from context or activity manager
|
||||
dbClient, ok := ctx.Value("db_client").(*db.DB)
|
||||
if !ok {
|
||||
return output, fmt.Errorf("database client not in context")
|
||||
}
|
||||
|
||||
// Fetch workflow
|
||||
workflow, err := dbClient.FetchWorkflow(ctx, input.WorkflowID, "")
|
||||
if err != nil {
|
||||
return output, fmt.Errorf("failed to get workflow: %w", err)
|
||||
}
|
||||
|
||||
// Parse canvas nodes and edges
|
||||
var nodes []db.WorkflowNode
|
||||
if err := json.Unmarshal([]byte(workflow.Nodes), &nodes); err != nil {
|
||||
return output, fmt.Errorf("failed to parse nodes: %w", err)
|
||||
}
|
||||
|
||||
var edges []db.WorkflowEdge
|
||||
if err := json.Unmarshal([]byte(workflow.Edges), &edges); err != nil {
|
||||
return output, fmt.Errorf("failed to parse edges: %w", err)
|
||||
}
|
||||
|
||||
output.Nodes = nodes
|
||||
output.Edges = edges
|
||||
output.UpdatedAt = workflow.UpdatedAt.String()
|
||||
|
||||
// Fetch workflow relations
|
||||
relations, err := dbClient.GetWorkflowRelations(ctx, input.WorkflowID, input.Version)
|
||||
if err != nil {
|
||||
// Relations may not exist for old canvases - this is OK
|
||||
logger.Warn("Failed to fetch relations: %v", err)
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// Map to EdgeWithWording
|
||||
for _, rel := range relations {
|
||||
edge := EdgeWithWording{
|
||||
ID: rel.ID,
|
||||
Source: rel.SourceNodeID,
|
||||
Target: rel.TargetNodeID,
|
||||
RelationType: rel.RelationType,
|
||||
RelationLabel: rel.Label,
|
||||
CreatedAt: rel.CreatedAt.String(),
|
||||
}
|
||||
|
||||
// Parse relation wording JSON
|
||||
if err := json.Unmarshal(rel.RelationWording, &edge.RelationWording); err != nil {
|
||||
logger.Warn("Failed to parse relation wording: %v", err)
|
||||
}
|
||||
|
||||
output.Relations = append(output.Relations, edge)
|
||||
}
|
||||
|
||||
logger.Info("Fetched %d nodes, %d edges, %d relations", len(output.Nodes), len(output.Edges), len(output.Relations))
|
||||
return output, nil
|
||||
}
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
package activity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/rockliang/poimen/workflows/internal/lock"
|
||||
)
|
||||
|
||||
// CloneRepoInput is input to CloneRepoActivity.
|
||||
type CloneRepoInput struct {
|
||||
RemoteURL string
|
||||
TargetRepoPath string
|
||||
}
|
||||
|
||||
// CloneRepoActivity clones a repo if it doesn't exist, or fetches if it does.
|
||||
func CloneRepoActivity(ctx context.Context, in CloneRepoInput) error {
|
||||
// Check if repo already exists
|
||||
gitDir := filepath.Join(in.TargetRepoPath, ".git")
|
||||
if _, err := os.Stat(gitDir); err == nil {
|
||||
// Repo exists, fetch latest
|
||||
cmd := exec.CommandContext(ctx, "git", "-C", in.TargetRepoPath, "fetch", "origin")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("git fetch failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Repo doesn't exist, clone it
|
||||
cmd := exec.CommandContext(ctx, "git", "clone", in.RemoteURL, in.TargetRepoPath)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("git clone failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GitWorktreeAddInput is input to GitWorktreeAddActivity.
|
||||
type GitWorktreeAddInput struct {
|
||||
RepoPath string
|
||||
TaskID string
|
||||
}
|
||||
|
||||
// GitWorktreeAddActivity creates a new git worktree for a task.
|
||||
func GitWorktreeAddActivity(ctx context.Context, in GitWorktreeAddInput) (string, error) {
|
||||
// Acquire lock to synchronize worktree creation
|
||||
lockPath := filepath.Join(in.RepoPath, "orchestrator.lock")
|
||||
if err := lock.Acquire(lockPath); err != nil {
|
||||
return "", fmt.Errorf("failed to acquire lock: %w", err)
|
||||
}
|
||||
defer lock.Release(lockPath)
|
||||
|
||||
// Create worktrees directory if it doesn't exist
|
||||
worktreesDir := filepath.Join(in.RepoPath, "worktrees")
|
||||
if err := os.MkdirAll(worktreesDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create worktrees dir: %w", err)
|
||||
}
|
||||
|
||||
worktreePath := filepath.Join(worktreesDir, in.TaskID)
|
||||
branch := "task/" + in.TaskID
|
||||
|
||||
// Create worktree
|
||||
cmd := exec.CommandContext(ctx, "git", "-C", in.RepoPath, "worktree", "add", "-b", branch, worktreePath, "origin/main")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("git worktree add failed: %w", err)
|
||||
}
|
||||
|
||||
return worktreePath, nil
|
||||
}
|
||||
|
||||
// GitCommitInput is input to GitCommitActivity.
|
||||
type GitCommitInput struct {
|
||||
WorktreePath string
|
||||
Message string
|
||||
}
|
||||
|
||||
// GitCommitActivity commits changes in a worktree.
|
||||
func GitCommitActivity(ctx context.Context, in GitCommitInput) error {
|
||||
// Configure git user for commits if not already configured
|
||||
// (worktrees don't inherit config from main repo)
|
||||
exec.CommandContext(ctx, "git", "-C", in.WorktreePath, "config", "user.email", "[email protected]").Run()
|
||||
exec.CommandContext(ctx, "git", "-C", in.WorktreePath, "config", "user.name", "Poimen Agent").Run()
|
||||
|
||||
// Stage all changes
|
||||
cmd := exec.CommandContext(ctx, "git", "-C", in.WorktreePath, "add", "-A")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("git add failed: %w", err)
|
||||
}
|
||||
|
||||
// Commit
|
||||
cmd = exec.CommandContext(ctx, "git", "-C", in.WorktreePath, "commit", "-m", in.Message)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("git commit failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GitPushInput is input to GitPushActivity.
|
||||
type GitPushInput struct {
|
||||
RepoPath string
|
||||
}
|
||||
|
||||
// GitPushActivity pushes changes to origin.
|
||||
func GitPushActivity(ctx context.Context, in GitPushInput) error {
|
||||
// Acquire lock to synchronize push
|
||||
lockPath := filepath.Join(in.RepoPath, "orchestrator.lock")
|
||||
if err := lock.Acquire(lockPath); err != nil {
|
||||
return fmt.Errorf("failed to acquire lock: %w", err)
|
||||
}
|
||||
defer lock.Release(lockPath)
|
||||
|
||||
cmd := exec.CommandContext(ctx, "git", "-C", in.RepoPath, "push", "origin", "main")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("git push failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GitSquashMergeInput is input to GitSquashMergeActivity.
|
||||
type GitSquashMergeInput struct {
|
||||
RepoPath string
|
||||
Branches []string
|
||||
Message string
|
||||
}
|
||||
|
||||
// GitSquashMergeActivity performs a squash merge of multiple branches into main.
|
||||
func GitSquashMergeActivity(ctx context.Context, in GitSquashMergeInput) error {
|
||||
// Acquire lock to synchronize merge
|
||||
lockPath := filepath.Join(in.RepoPath, "orchestrator.lock")
|
||||
if err := lock.Acquire(lockPath); err != nil {
|
||||
return fmt.Errorf("failed to acquire lock: %w", err)
|
||||
}
|
||||
defer lock.Release(lockPath)
|
||||
|
||||
// Fetch origin main
|
||||
cmd := exec.CommandContext(ctx, "git", "-C", in.RepoPath, "fetch", "origin", "main")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("git fetch failed: %w", err)
|
||||
}
|
||||
|
||||
// Checkout main and pull with ff-only
|
||||
cmd = exec.CommandContext(ctx, "git", "-C", in.RepoPath, "checkout", "main")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("git checkout main failed: %w", err)
|
||||
}
|
||||
|
||||
cmd = exec.CommandContext(ctx, "git", "-C", in.RepoPath, "pull", "--ff-only", "origin", "main")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("git pull failed: %w", err)
|
||||
}
|
||||
|
||||
// Squash merge each branch
|
||||
for _, branch := range in.Branches {
|
||||
cmd = exec.CommandContext(ctx, "git", "-C", in.RepoPath, "merge", "--squash", branch)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("git merge --squash %s failed: %w", branch, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Commit squashed changes
|
||||
cmd = exec.CommandContext(ctx, "git", "-C", in.RepoPath, "commit", "-m", in.Message)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("git commit failed: %w", err)
|
||||
}
|
||||
|
||||
// Push to origin
|
||||
cmd = exec.CommandContext(ctx, "git", "-C", in.RepoPath, "push", "origin", "main")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("git push failed: %w", err)
|
||||
}
|
||||
|
||||
// Clean up worktrees and branches
|
||||
for _, branch := range in.Branches {
|
||||
taskID := branch[len("task/"):]
|
||||
worktreePath := filepath.Join(in.RepoPath, "worktrees", taskID)
|
||||
|
||||
// Remove worktree
|
||||
cmd = exec.CommandContext(ctx, "git", "-C", in.RepoPath, "worktree", "remove", worktreePath, "--force")
|
||||
if err := cmd.Run(); err != nil {
|
||||
// Log error but continue cleanup
|
||||
fmt.Printf("warning: failed to remove worktree %s: %v\n", worktreePath, err)
|
||||
}
|
||||
|
||||
// Delete branch
|
||||
cmd = exec.CommandContext(ctx, "git", "-C", in.RepoPath, "branch", "-D", branch)
|
||||
if err := cmd.Run(); err != nil {
|
||||
// Log error but continue cleanup
|
||||
fmt.Printf("warning: failed to delete branch %s: %v\n", branch, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GitDiffInput is input to GitDiffActivity.
|
||||
type GitDiffInput struct {
|
||||
WorktreePath string
|
||||
}
|
||||
|
||||
// GitDiffOutput is output of GitDiffActivity.
|
||||
type GitDiffOutput struct {
|
||||
Diff string
|
||||
}
|
||||
|
||||
// GitDiffActivity gets git diff for a worktree.
|
||||
func GitDiffActivity(ctx context.Context, in GitDiffInput) (GitDiffOutput, error) {
|
||||
out := GitDiffOutput{Diff: ""}
|
||||
|
||||
// Get diff from worktree against main branch
|
||||
cmd := exec.CommandContext(ctx, "git", "-C", in.WorktreePath, "diff", "main")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// Diff can fail if branch doesn't exist, treat as no changes
|
||||
return out, nil
|
||||
}
|
||||
|
||||
out.Diff = string(output)
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package activity
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/rockliang/poimen/workflows/pkg/types"
|
||||
"go.temporal.io/sdk/activity"
|
||||
)
|
||||
|
||||
type ImplementerInput struct {
|
||||
Config types.OrchestratorConfig
|
||||
TaskID string
|
||||
WorktreePath string
|
||||
Lessons string
|
||||
}
|
||||
|
||||
type ImplementerOutput struct {
|
||||
Success bool
|
||||
Changes string
|
||||
}
|
||||
|
||||
func ImplementerActivity(ctx context.Context, in ImplementerInput) (ImplementerOutput, error) {
|
||||
activity.RecordHeartbeat(ctx, "starting implementer for "+in.TaskID)
|
||||
|
||||
vars := map[string]any{
|
||||
"SystemPrompt": in.Config.SystemPrompt,
|
||||
"Task": in.TaskID,
|
||||
"WorktreePath": in.WorktreePath,
|
||||
}
|
||||
if in.Lessons != "" {
|
||||
vars["Lessons"] = in.Lessons
|
||||
}
|
||||
|
||||
response, err := CallRoleLLM(ctx, in.Config, "implementer", vars)
|
||||
if err != nil {
|
||||
return ImplementerOutput{}, err
|
||||
}
|
||||
|
||||
activity.RecordHeartbeat(ctx, "implementer completed for "+in.TaskID)
|
||||
return ImplementerOutput{Success: true, Changes: response}, nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package activity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IndexGraphRAGActivity indexes workflow canvas to GraphRAG (stub for now)
|
||||
func IndexGraphRAGActivity(ctx context.Context, input IndexGraphRAGInput) (IndexGraphRAGOutput, error) {
|
||||
output := IndexGraphRAGOutput{
|
||||
WorkflowID: input.WorkflowID,
|
||||
Version: input.Version,
|
||||
Status: "indexed",
|
||||
IndexedEntities: len(input.Nodes),
|
||||
IndexedEdges: len(input.Relations),
|
||||
IndexedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// Stub implementation - actual GraphRAG indexing would happen here
|
||||
// For now, just return success
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// QueryGraphRAGRelationsInput for direct relation discovery
|
||||
type QueryGraphRAGRelationsInput struct {
|
||||
WorkflowID string `json:"workflow_id"`
|
||||
Version int `json:"version"`
|
||||
Query string `json:"query"`
|
||||
TopK int `json:"top_k"`
|
||||
Filters map[string]interface{} `json:"filters,omitempty"`
|
||||
}
|
||||
|
||||
// QueryGraphRAGRelationsOutput returns discovered relations
|
||||
type QueryGraphRAGRelationsOutput struct {
|
||||
Query string `json:"query"`
|
||||
Results []EdgeWithWording `json:"results"`
|
||||
TotalCount int `json:"total_count"`
|
||||
ExecutionMs int64 `json:"execution_time_ms"`
|
||||
}
|
||||
|
||||
// QueryGraphRAGRelationsActivity queries GraphRAG for relation patterns (stub)
|
||||
func QueryGraphRAGRelationsActivity(ctx context.Context, input QueryGraphRAGRelationsInput) (QueryGraphRAGRelationsOutput, error) {
|
||||
output := QueryGraphRAGRelationsOutput{
|
||||
Query: input.Query,
|
||||
Results: []EdgeWithWording{},
|
||||
TotalCount: 0,
|
||||
}
|
||||
|
||||
// Stub implementation - actual GraphRAG querying would happen here
|
||||
return output, nil
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package activity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// RunIntegrationTestInput is input to RunIntegrationTestActivity.
|
||||
type RunIntegrationTestInput struct {
|
||||
WorktreePath string
|
||||
TestCmd string
|
||||
}
|
||||
|
||||
// RunIntegrationTestOutput is the output of RunIntegrationTestActivity.
|
||||
type RunIntegrationTestOutput struct {
|
||||
Passed bool
|
||||
Logs string
|
||||
}
|
||||
|
||||
// RunIntegrationTestActivity runs integration tests in the worktree.
|
||||
func RunIntegrationTestActivity(ctx context.Context, in RunIntegrationTestInput) (RunIntegrationTestOutput, error) {
|
||||
if in.TestCmd == "" {
|
||||
// No test command, assume pass
|
||||
return RunIntegrationTestOutput{Passed: true, Logs: "No test command provided"}, nil
|
||||
}
|
||||
|
||||
// Run test command
|
||||
cmd := exec.CommandContext(ctx, "sh", "-c", in.TestCmd)
|
||||
cmd.Dir = in.WorktreePath
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return RunIntegrationTestOutput{
|
||||
Passed: false,
|
||||
Logs: string(output) + "\nError: " + err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return RunIntegrationTestOutput{
|
||||
Passed: true,
|
||||
Logs: string(output),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package activity
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/rockliang/poimen/workflows/pkg/types"
|
||||
)
|
||||
|
||||
type JudgeInput struct {
|
||||
Config types.OrchestratorConfig
|
||||
Diff string
|
||||
IntegrationTestLogs string
|
||||
}
|
||||
|
||||
type JudgeOutput struct {
|
||||
Verdict string
|
||||
Critique string
|
||||
}
|
||||
|
||||
func JudgeActivity(ctx context.Context, in JudgeInput) (JudgeOutput, error) {
|
||||
response, err := CallRoleLLM(ctx, in.Config, "judge", map[string]any{
|
||||
"SystemPrompt": in.Config.SystemPrompt,
|
||||
"Diff": in.Diff,
|
||||
"TestResult": in.IntegrationTestLogs,
|
||||
})
|
||||
if err != nil {
|
||||
return JudgeOutput{}, err
|
||||
}
|
||||
|
||||
// TODO: parse LLM response for verdict
|
||||
return JudgeOutput{Verdict: "pass", Critique: response}, nil
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package activity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Lesson represents a learned lesson from a failed attempt.
|
||||
type Lesson struct {
|
||||
Attempt int `json:"attempt"`
|
||||
Critique string `json:"critique"`
|
||||
FailedApproachSummary string `json:"failed_approach_summary"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// ReadLessonsInput is input to ReadLessonsActivity.
|
||||
type ReadLessonsInput struct {
|
||||
TargetRepoPath string
|
||||
TaskID string
|
||||
}
|
||||
|
||||
// ReadLessonsOutput is the output of ReadLessonsActivity.
|
||||
type ReadLessonsOutput struct {
|
||||
Lessons string // JSONL or formatted string of lessons
|
||||
}
|
||||
|
||||
// ReadLessonsActivity reads lessons for a task.
|
||||
func ReadLessonsActivity(ctx context.Context, in ReadLessonsInput) (ReadLessonsOutput, error) {
|
||||
lessonsFile := filepath.Join(in.TargetRepoPath, "tasks", ".orchestrator", "lessons", in.TaskID+".jsonl")
|
||||
|
||||
// If file doesn't exist, return empty lessons
|
||||
if _, err := os.Stat(lessonsFile); os.IsNotExist(err) {
|
||||
return ReadLessonsOutput{Lessons: ""}, nil
|
||||
}
|
||||
|
||||
// Read and format lessons
|
||||
data, err := os.ReadFile(lessonsFile)
|
||||
if err != nil {
|
||||
return ReadLessonsOutput{}, fmt.Errorf("failed to read lessons file: %w", err)
|
||||
}
|
||||
|
||||
// Format lessons as plain text
|
||||
lines := string(data)
|
||||
return ReadLessonsOutput{Lessons: lines}, nil
|
||||
}
|
||||
|
||||
// UpdateLessonsInput is input to UpdateLessonsActivity.
|
||||
type UpdateLessonsInput struct {
|
||||
TargetRepoPath string
|
||||
TaskID string
|
||||
Attempt int
|
||||
Critique string
|
||||
FailedApproachSummary string
|
||||
}
|
||||
|
||||
// UpdateLessonsActivity appends a lesson to the lessons file.
|
||||
func UpdateLessonsActivity(ctx context.Context, in UpdateLessonsInput) error {
|
||||
lessonsDir := filepath.Join(in.TargetRepoPath, "tasks", ".orchestrator", "lessons")
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if err := os.MkdirAll(lessonsDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create lessons directory: %w", err)
|
||||
}
|
||||
|
||||
lessonsFile := filepath.Join(lessonsDir, in.TaskID+".jsonl")
|
||||
|
||||
// Create lesson entry
|
||||
lesson := map[string]any{
|
||||
"attempt": in.Attempt,
|
||||
"critique": in.Critique,
|
||||
"failed_approach_summary": in.FailedApproachSummary,
|
||||
"timestamp": "",
|
||||
}
|
||||
|
||||
// Marshal to JSON
|
||||
data, err := json.Marshal(lesson)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal lesson: %w", err)
|
||||
}
|
||||
|
||||
// Append to file
|
||||
f, err := os.OpenFile(lessonsFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open lessons file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
_, err = f.Write(append(data, '\n'))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write lesson: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/rockliang/poimen/workflows/pkg/types"
|
||||
)
|
||||
|
||||
var (
|
||||
// LocalLLMBaseURL is the base URL for the local LLM API (OpenAI-compatible)
|
||||
// Can be overridden via LOCAL_LLM_BASE_URL env var (for Kubernetes internal service)
|
||||
LocalLLMBaseURL string
|
||||
)
|
||||
|
||||
func init() {
|
||||
LocalLLMBaseURL = os.Getenv("LOCAL_LLM_BASE_URL")
|
||||
if LocalLLMBaseURL == "" {
|
||||
// Default: external hostname (for local dev)
|
||||
LocalLLMBaseURL = "https://api.riotpiao.com"
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
// SupportedModels maps local model names to verify they exist
|
||||
SupportedModels = map[string]bool{
|
||||
"reasoning": true, // Reasoning model for planner/judge
|
||||
"ornith:35b": true, // Ornith 35B for implementer
|
||||
"ornith:13b": true, // Alternative Ornith size
|
||||
"qwen2.5:3b": true, // Qwen alternative
|
||||
}
|
||||
)
|
||||
|
||||
// OpenAIClient is a wrapper around the local OpenAI-compatible API.
|
||||
type OpenAIClient struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new OpenAIClient pointing to the local LLM API.
|
||||
func NewClient() (*OpenAIClient, error) {
|
||||
return &OpenAIClient{
|
||||
baseURL: LocalLLMBaseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 0, // No timeout for streaming
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MessageInput is the input to CreateMessage.
|
||||
type MessageInput struct {
|
||||
Model types.ModelSpec
|
||||
SystemPrompt string
|
||||
Messages []MessageParam
|
||||
AuthToken string // Optional JWT token for authenticated endpoints
|
||||
}
|
||||
|
||||
// MessageParam represents a message parameter.
|
||||
type MessageParam struct {
|
||||
Role string
|
||||
Content string
|
||||
}
|
||||
|
||||
// openaiRequest is the request body for the OpenAI-compatible API.
|
||||
type openaiRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []openaiMessage `json:"messages"`
|
||||
Stream bool `json:"stream"`
|
||||
Temp float64 `json:"temperature,omitempty"`
|
||||
MaxToken int `json:"max_tokens,omitempty"`
|
||||
}
|
||||
|
||||
type openaiMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// openaiResponse is the response from the OpenAI-compatible API.
|
||||
type openaiResponse struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
|
||||
// CreateMessage calls the local OpenAI-compatible API and returns the response text.
|
||||
func (c *OpenAIClient) CreateMessage(ctx context.Context, in MessageInput) (string, error) {
|
||||
// Validate model
|
||||
if !SupportedModels[in.Model.ModelID] {
|
||||
return "", fmt.Errorf("unsupported model: %s (supported: reasoning, ornith:35b)", in.Model.ModelID)
|
||||
}
|
||||
|
||||
// Build request
|
||||
messages := []openaiMessage{
|
||||
{
|
||||
Role: "system",
|
||||
Content: in.SystemPrompt,
|
||||
},
|
||||
}
|
||||
for _, msg := range in.Messages {
|
||||
messages = append(messages, openaiMessage{
|
||||
Role: msg.Role,
|
||||
Content: msg.Content,
|
||||
})
|
||||
}
|
||||
|
||||
req := openaiRequest{
|
||||
Model: in.Model.ModelID,
|
||||
Messages: messages,
|
||||
Stream: false,
|
||||
}
|
||||
|
||||
// Marshal request
|
||||
reqBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
// Create HTTP request
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST",
|
||||
fmt.Sprintf("%s/v1/chat/completions", c.baseURL),
|
||||
bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create HTTP request: %w", err)
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Add authentication header if token provided
|
||||
if in.AuthToken != "" {
|
||||
httpReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", in.AuthToken))
|
||||
}
|
||||
|
||||
// Send request
|
||||
resp, err := c.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to connect to local LLM API at %s: %w (ensure homelab-frontend is running)", c.baseURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
// Check status
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("local LLM API returned status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
// Unmarshal response
|
||||
var respObj openaiResponse
|
||||
if err := json.Unmarshal(respBody, &respObj); err != nil {
|
||||
return "", fmt.Errorf("failed to unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
// Extract content
|
||||
if len(respObj.Choices) == 0 {
|
||||
return "", fmt.Errorf("no choices in response from local LLM API")
|
||||
}
|
||||
|
||||
return respObj.Choices[0].Message.Content, nil
|
||||
}
|
||||
|
||||
// HealthCheck verifies the local LLM API is reachable and has the required models.
|
||||
func (c *OpenAIClient) HealthCheck(ctx context.Context) error {
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "GET",
|
||||
fmt.Sprintf("%s/readyz", c.baseURL), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("local LLM API at %s is unreachable: %w", c.baseURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("local LLM API health check failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/rockliang/poimen/workflows/pkg/types"
|
||||
)
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
client, err := NewClient()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create client: %v", err)
|
||||
}
|
||||
if client == nil {
|
||||
t.Fatal("client is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthCheck(t *testing.T) {
|
||||
client, err := NewClient()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create client: %v", err)
|
||||
}
|
||||
|
||||
// Skip if local LLM API not available
|
||||
err = client.HealthCheck(context.Background())
|
||||
if err != nil {
|
||||
t.Logf("local LLM API not available (expected in test env): %v", err)
|
||||
t.Skip("local LLM API health check failed - skipping integration test")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportedModels(t *testing.T) {
|
||||
tests := []struct {
|
||||
model string
|
||||
expected bool
|
||||
}{
|
||||
{"reasoning", true},
|
||||
{"ornith:35b", true},
|
||||
{"ornith:13b", true},
|
||||
{"qwen2.5:3b", true},
|
||||
{"unsupported-model", false},
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.model, func(t *testing.T) {
|
||||
if SupportedModels[tt.model] != tt.expected {
|
||||
t.Errorf("model %q: expected %v, got %v", tt.model, tt.expected, SupportedModels[tt.model])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateMessageValidation(t *testing.T) {
|
||||
client, _ := NewClient()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
modelID string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid reasoning", "reasoning", true}, // Will fail to connect, but validates model
|
||||
{"valid ornith", "ornith:35b", true}, // Will fail to connect, but validates model
|
||||
{"invalid model", "invalid-model", false}, // Should fail validation
|
||||
{"empty model", "", false}, // Should fail validation
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
in := MessageInput{
|
||||
Model: types.ModelSpec{
|
||||
ModelID: tt.modelID,
|
||||
},
|
||||
SystemPrompt: "test",
|
||||
Messages: []MessageParam{
|
||||
{Role: "user", Content: "test"},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := client.CreateMessage(context.Background(), in)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != tt.wantErr {
|
||||
if tt.wantErr {
|
||||
t.Logf("expected error for model %q (likely API not reachable): %v", tt.modelID, err)
|
||||
} else if !hasErr {
|
||||
t.Errorf("expected error for invalid model %q, but got none", tt.modelID)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalLLMBaseURL(t *testing.T) {
|
||||
if LocalLLMBaseURL != "https://api.riotpiao.com" {
|
||||
t.Errorf("expected base URL https://api.riotpiao.com, got %s", LocalLLMBaseURL)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package activity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/rockliang/poimen/workflows/activity/llm"
|
||||
"github.com/rockliang/poimen/workflows/pkg/types"
|
||||
"github.com/rockliang/poimen/workflows/prompts"
|
||||
)
|
||||
|
||||
// CallRoleLLM is the shared pattern for calling an LLM with a role-based prompt.
|
||||
// Used by planner, implementer, and judge activities (DRY extraction).
|
||||
func CallRoleLLM(ctx context.Context, config types.OrchestratorConfig, role string, vars map[string]any) (string, error) {
|
||||
client, err := llm.NewClient()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create LLM client: %w", err)
|
||||
}
|
||||
|
||||
spec, exists := config.RolePrompts[role]
|
||||
if !exists {
|
||||
return "", fmt.Errorf("%s role prompt not configured", role)
|
||||
}
|
||||
|
||||
// Render template
|
||||
var content string
|
||||
if spec.RawTemplate != "" {
|
||||
content = spec.RawTemplate
|
||||
} else {
|
||||
content, err = prompts.Render(spec.TemplateRef, vars)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to render %s template: %w", role, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Call LLM
|
||||
response, err := client.CreateMessage(ctx, llm.MessageInput{
|
||||
Model: spec.Model,
|
||||
SystemPrompt: config.SystemPrompt,
|
||||
Messages: []llm.MessageParam{{Role: "user", Content: content}},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s LLM call failed: %w", role, err)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package activity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/rockliang/poimen/workflows/activity/llm"
|
||||
"github.com/rockliang/poimen/workflows/pkg/types"
|
||||
)
|
||||
|
||||
type LLMInferenceInput struct {
|
||||
Model string `json:"model"`
|
||||
SystemPrompt string `json:"system_prompt"`
|
||||
UserPrompt string `json:"user_prompt"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
AuthToken string `json:"auth_token,omitempty"`
|
||||
}
|
||||
|
||||
type LLMInferenceOutput struct {
|
||||
Response string `json:"response"`
|
||||
Model string `json:"model"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
TokensUsed int `json:"tokens_used"`
|
||||
ErrorMessage string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func LLMInferenceActivity(ctx context.Context, in LLMInferenceInput) (LLMInferenceOutput, error) {
|
||||
logger := newActivityLogger(ctx)
|
||||
output := LLMInferenceOutput{Model: in.Model}
|
||||
|
||||
if in.Model == "" {
|
||||
return output, fmt.Errorf("model not specified")
|
||||
}
|
||||
if in.UserPrompt == "" {
|
||||
return output, fmt.Errorf("user_prompt not specified")
|
||||
}
|
||||
|
||||
logger.Info("Starting LLM inference", "model", in.Model)
|
||||
|
||||
client, err := llm.NewClient()
|
||||
if err != nil {
|
||||
output.ErrorMessage = err.Error()
|
||||
return output, fmt.Errorf("failed to create LLM client: %w", err)
|
||||
}
|
||||
|
||||
response, err := client.CreateMessage(ctx, llm.MessageInput{
|
||||
Model: types.ModelSpec{ModelID: in.Model},
|
||||
SystemPrompt: in.SystemPrompt,
|
||||
Messages: []llm.MessageParam{{Role: "user", Content: in.UserPrompt}},
|
||||
AuthToken: in.AuthToken,
|
||||
})
|
||||
if err != nil {
|
||||
output.ErrorMessage = err.Error()
|
||||
logger.Warn("LLM API call failed", "error", err)
|
||||
return output, fmt.Errorf("LLM inference failed: %w", err)
|
||||
}
|
||||
|
||||
output.Response = response
|
||||
output.StopReason = "stop_sequence"
|
||||
logger.Info("LLM inference completed", "response_len", len(response))
|
||||
return output, nil
|
||||
}
|
||||
|
||||
type LLMBatchInferenceInput struct {
|
||||
Model string `json:"model"`
|
||||
SystemPrompt string `json:"system_prompt"`
|
||||
Prompts []string `json:"prompts"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
AuthToken string `json:"auth_token,omitempty"`
|
||||
}
|
||||
|
||||
type LLMBatchInferenceOutput struct {
|
||||
Responses []string `json:"responses"`
|
||||
Model string `json:"model"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
func LLMBatchInferenceActivity(ctx context.Context, in LLMBatchInferenceInput) (LLMBatchInferenceOutput, error) {
|
||||
logger := newActivityLogger(ctx)
|
||||
output := LLMBatchInferenceOutput{Model: in.Model, Responses: []string{}, Errors: []string{}}
|
||||
|
||||
if in.Model == "" {
|
||||
return output, fmt.Errorf("model not specified")
|
||||
}
|
||||
if len(in.Prompts) == 0 {
|
||||
return output, fmt.Errorf("no prompts provided")
|
||||
}
|
||||
|
||||
logger.Info("Starting batch inference", "model", in.Model, "count", len(in.Prompts))
|
||||
|
||||
client, err := llm.NewClient()
|
||||
if err != nil {
|
||||
return output, fmt.Errorf("failed to create LLM client: %w", err)
|
||||
}
|
||||
|
||||
for i, prompt := range in.Prompts {
|
||||
response, err := client.CreateMessage(ctx, llm.MessageInput{
|
||||
Model: types.ModelSpec{ModelID: in.Model},
|
||||
SystemPrompt: in.SystemPrompt,
|
||||
Messages: []llm.MessageParam{{Role: "user", Content: prompt}},
|
||||
})
|
||||
if err != nil {
|
||||
output.Errors = append(output.Errors, fmt.Sprintf("prompt %d: %v", i, err))
|
||||
output.Responses = append(output.Responses, "")
|
||||
logger.Warn("Failed prompt", "index", i, "error", err)
|
||||
} else {
|
||||
output.Responses = append(output.Responses, response)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("Batch inference completed", "responses", len(output.Responses), "errors", len(output.Errors))
|
||||
return output, nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package activity
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package activity
|
||||
|
||||
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] + "..."
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package activity
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package activity
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
// checker defines a deployment check
|
||||
type checker struct {
|
||||
name string
|
||||
types []string // which checkTypes trigger this checker
|
||||
isWarning bool // if true, failure goes to warnings not failures
|
||||
run func(ctx context.Context, path string) (string, error)
|
||||
}
|
||||
|
||||
// shouldRun checks if this checker should run for given checkType
|
||||
func (c *checker) shouldRun(checkType string) bool {
|
||||
for _, t := range c.types {
|
||||
if t == checkType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// goCheckers returns checkers for Go projects
|
||||
func goCheckers() []*checker {
|
||||
return []*checker{
|
||||
{
|
||||
name: "build",
|
||||
types: []string{"all", "build"},
|
||||
run: func(ctx context.Context, path string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "go", "build", "./...")
|
||||
cmd.Dir = path
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "test",
|
||||
types: []string{"all", "test"},
|
||||
run: func(ctx context.Context, path string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "go", "test", "-short", "./...")
|
||||
cmd.Dir = path
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "lint",
|
||||
types: []string{"all", "lint"},
|
||||
isWarning: true,
|
||||
run: func(ctx context.Context, path string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "go", "vet", "./...")
|
||||
cmd.Dir = path
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// detectProjectCheckers returns checkers based on project type
|
||||
func detectProjectCheckers(path string) []*checker {
|
||||
if _, err := os.Stat(fmt.Sprintf("%s/go.mod", path)); err == nil {
|
||||
return goCheckers()
|
||||
}
|
||||
// Add more project types here (Node, Python, etc.)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Run applicable checkers
|
||||
for _, c := range detectProjectCheckers(in.Path) {
|
||||
if !c.shouldRun(checkType) {
|
||||
continue
|
||||
}
|
||||
if out, err := c.run(ctx, in.Path); err != nil {
|
||||
msg := fmt.Sprintf("%s failed: %s", c.name, out)
|
||||
if c.isWarning {
|
||||
output.Warnings = append(output.Warnings, msg)
|
||||
} else {
|
||||
output.Passed = false
|
||||
output.Failures = append(output.Failures, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package activity
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package activity
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/rockliang/poimen/workflows/pkg/types"
|
||||
)
|
||||
|
||||
type PlanningInput struct {
|
||||
Config types.OrchestratorConfig
|
||||
BoardState string
|
||||
RepoPath string
|
||||
Milestone string
|
||||
TaskResults []types.TaskUnitOutput
|
||||
}
|
||||
|
||||
type TaskDispatch struct {
|
||||
TaskID string
|
||||
PromptSpec types.PromptSpec
|
||||
BaseTimeout *int64
|
||||
}
|
||||
|
||||
type PlanningOutput struct {
|
||||
TasksToDispatch []string
|
||||
CompletedBranches []string
|
||||
SubmilestoneComplete bool
|
||||
}
|
||||
|
||||
func PlanningActivity(ctx context.Context, in PlanningInput) (PlanningOutput, error) {
|
||||
response, err := CallRoleLLM(ctx, in.Config, "planner", map[string]any{
|
||||
"SystemPrompt": in.Config.SystemPrompt,
|
||||
"BoardState": in.BoardState,
|
||||
"Milestone": in.Milestone,
|
||||
"Config": in.Config,
|
||||
})
|
||||
if err != nil {
|
||||
return PlanningOutput{}, err
|
||||
}
|
||||
|
||||
// TODO: parse LLM response into task dispatch list
|
||||
_ = response
|
||||
return PlanningOutput{
|
||||
TasksToDispatch: []string{},
|
||||
CompletedBranches: []string{},
|
||||
SubmilestoneComplete: false,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package activity
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// QueryGraphRAGActivity queries Memory System for semantic relations
|
||||
func QueryGraphRAGActivity(ctx context.Context, input GraphRAGQueryInput) (GraphRAGQueryOutput, error) {
|
||||
logger := newActivityLogger(ctx)
|
||||
output := GraphRAGQueryOutput{
|
||||
WorkflowID: input.WorkflowID,
|
||||
Query: input.Query,
|
||||
Edges: []EdgeWithWording{},
|
||||
Paths: []QueryPathData{},
|
||||
}
|
||||
|
||||
logger.Info("Querying GraphRAG: %s", input.Query)
|
||||
|
||||
// Get Memory Service URL from env
|
||||
memoryURL := os.Getenv("MEMORY_SERVICE_URL")
|
||||
if memoryURL == "" {
|
||||
memoryURL = "http://localhost:8000"
|
||||
}
|
||||
|
||||
// Build payload for Memory System
|
||||
payload := map[string]interface{}{
|
||||
"workflow_id": input.WorkflowID,
|
||||
"query": input.Query,
|
||||
"search_type": input.SearchType,
|
||||
"relation_type": input.RelationType,
|
||||
"confidence_floor": input.ConfidenceFloor,
|
||||
"top_k": input.TopK,
|
||||
"ranking_profile": input.RankingProfile,
|
||||
"canvas_nodes": input.Canvas.Nodes,
|
||||
"canvas_edges": input.Canvas.Edges,
|
||||
"relations": input.Canvas.Relations,
|
||||
}
|
||||
|
||||
reqBody, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return output, fmt.Errorf("failed to marshal payload: %w", err)
|
||||
}
|
||||
|
||||
// Call Memory System unified query endpoint
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
"POST",
|
||||
memoryURL+"/workflows/query",
|
||||
bytes.NewReader(reqBody),
|
||||
)
|
||||
if err != nil {
|
||||
return output, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token := ctx.Value("jwt_token"); token != nil {
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", token))
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return output, fmt.Errorf("failed to call Memory Service: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return output, fmt.Errorf("Memory Service returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var graphResp struct {
|
||||
Edges []EdgeWithWording `json:"edges"`
|
||||
Paths []QueryPathData `json:"paths"`
|
||||
TotalCount int `json:"total_count"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&graphResp); err != nil {
|
||||
return output, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
output.Edges = graphResp.Edges
|
||||
output.Paths = graphResp.Paths
|
||||
output.TotalCount = graphResp.TotalCount
|
||||
output.HasMore = graphResp.HasMore
|
||||
output.ExecutionMs = time.Since(startTime).Milliseconds()
|
||||
|
||||
logger.Info("GraphRAG returned %d edges, %d paths in %dms",
|
||||
len(output.Edges), len(output.Paths), output.ExecutionMs)
|
||||
return output, nil
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package activity
|
||||
|
||||
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.NewLLMRouterDefault(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
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package activity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.temporal.io/sdk/activity"
|
||||
)
|
||||
|
||||
// PrepareSkillsInput is input to PrepareSkillsActivity.
|
||||
type PrepareSkillsInput struct {
|
||||
Skills []SkillRef
|
||||
StreamTimeout time.Duration
|
||||
Provider string // pi provider name (e.g. "homelab-reasoning"); required, pi has no usable default provider
|
||||
}
|
||||
|
||||
// PrepareSkillsActivity prepares skills for use via pi command.
|
||||
func PrepareSkillsActivity(ctx context.Context, in PrepareSkillsInput) error {
|
||||
if in.Provider == "" {
|
||||
return fmt.Errorf("PrepareSkillsInput.Provider must be set (pi has no usable default provider)")
|
||||
}
|
||||
|
||||
for _, skill := range in.Skills {
|
||||
activity.RecordHeartbeat(ctx, skill.Name)
|
||||
|
||||
// Run: pi clone-or-fetch <skill-url> --provider=<provider> --stream-timeout=<duration>
|
||||
cmd := exec.CommandContext(ctx, "pi", "clone-or-fetch", skill.URL, "--provider="+in.Provider, fmt.Sprintf("--stream-timeout=%s", in.StreamTimeout.String()))
|
||||
if err := cmd.Run(); err != nil {
|
||||
// Classify error
|
||||
classifiedErr := ClassifyPiErr(err, skill.Name)
|
||||
return classifiedErr
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClassifyPiErr classifies pi command errors into buckets.
|
||||
// 4xx → NonRetryableApplicationError "PiClientError"
|
||||
// 504 → ApplicationError "PiStreamTimeout" (retryable, but orchestrator learns and doubles timeout)
|
||||
// 5xx (except 504) → generic retryable error
|
||||
// Other → retryable
|
||||
func ClassifyPiErr(err error, skillName string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try to extract HTTP status code from error message
|
||||
statusCode := ExtractHTTPStatus(err)
|
||||
if statusCode == 0 {
|
||||
// Not an HTTP error, return as-is for generic retry
|
||||
return fmt.Errorf("pi %s failed: %w", skillName, err)
|
||||
}
|
||||
|
||||
switch {
|
||||
case statusCode >= 400 && statusCode < 500:
|
||||
// 4xx errors are non-retryable
|
||||
return NewNonRetryableApplicationError(
|
||||
fmt.Sprintf("PiClientError: status %d", statusCode),
|
||||
fmt.Sprintf("pi %s returned HTTP %d", skillName, statusCode),
|
||||
)
|
||||
case statusCode == 504:
|
||||
// 504 Gateway Timeout - stream timeout
|
||||
// This is retryable, but signals the orchestrator to double the timeout
|
||||
return NewApplicationError(
|
||||
fmt.Sprintf("PiStreamTimeout: status %d", statusCode),
|
||||
fmt.Sprintf("pi %s returned HTTP 504 (stream timeout)", skillName),
|
||||
)
|
||||
case statusCode >= 500:
|
||||
// Other 5xx errors are retryable
|
||||
return fmt.Errorf("pi %s returned HTTP %d: %w", skillName, statusCode, err)
|
||||
default:
|
||||
return fmt.Errorf("pi %s failed: %w", skillName, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ExtractHTTPStatus tries to extract HTTP status code from error message.
|
||||
func ExtractHTTPStatus(err error) int {
|
||||
if err == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
errStr := err.Error()
|
||||
|
||||
// Try to find 3-digit numbers that could be HTTP status codes
|
||||
parts := strings.Fields(errStr)
|
||||
for i, part := range parts {
|
||||
// Check if part is a 3-digit number (HTTP status code)
|
||||
if len(part) >= 3 {
|
||||
code, err := strconv.Atoi(part[:3])
|
||||
if err == nil && code >= 100 && code < 600 {
|
||||
return code
|
||||
}
|
||||
}
|
||||
|
||||
// Check if previous part is "status" or "HTTP"
|
||||
if i > 0 {
|
||||
prev := strings.ToLower(parts[i-1])
|
||||
if (prev == "status" || prev == "status:") && len(part) >= 3 {
|
||||
code, err := strconv.Atoi(part[:3])
|
||||
if err == nil && code >= 100 && code < 600 {
|
||||
return code
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// NewNonRetryableApplicationError creates a non-retryable application error.
|
||||
func NewNonRetryableApplicationError(errType, errMsg string) error {
|
||||
return &NonRetryableApplicationError{
|
||||
errType: errType,
|
||||
errMsg: errMsg,
|
||||
}
|
||||
}
|
||||
|
||||
// NonRetryableApplicationError represents a non-retryable application error.
|
||||
type NonRetryableApplicationError struct {
|
||||
errType string
|
||||
errMsg string
|
||||
}
|
||||
|
||||
func (e *NonRetryableApplicationError) Error() string {
|
||||
return fmt.Sprintf("%s: %s", e.errType, e.errMsg)
|
||||
}
|
||||
|
||||
func (e *NonRetryableApplicationError) Type() string {
|
||||
return e.errType
|
||||
}
|
||||
|
||||
// NewApplicationError creates a retryable application error with a specific type.
|
||||
func NewApplicationError(errType, errMsg string) error {
|
||||
return &ApplicationError{
|
||||
errType: errType,
|
||||
errMsg: errMsg,
|
||||
}
|
||||
}
|
||||
|
||||
// ApplicationError represents a retryable application error with a type.
|
||||
type ApplicationError struct {
|
||||
errType string
|
||||
errMsg string
|
||||
}
|
||||
|
||||
func (e *ApplicationError) Error() string {
|
||||
return fmt.Sprintf("%s: %s", e.errType, e.errMsg)
|
||||
}
|
||||
|
||||
func (e *ApplicationError) Type() string {
|
||||
return e.errType
|
||||
}
|
||||
|
||||
// IsPiStreamTimeout checks if an error is a PiStreamTimeout error.
|
||||
func IsPiStreamTimeout(err error) bool {
|
||||
var appErr *ApplicationError
|
||||
if errors.As(err, &appErr) {
|
||||
return strings.Contains(appErr.errType, "PiStreamTimeout")
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package activity
|
||||
|
||||
import "github.com/rockliang/poimen/workflows/pkg/types"
|
||||
|
||||
// Re-export from pkg/types for convenience within activity package.
|
||||
type ModelSpec = types.ModelSpec
|
||||
type PromptSpec = types.PromptSpec
|
||||
type SkillRef = types.SkillRef
|
||||
type OrchestratorConfig = types.OrchestratorConfig
|
||||
type TaskUnitOutput = types.TaskUnitOutput
|
||||
type EdgeWithWording = types.EdgeWithWording
|
||||
type RelationWording = types.RelationWording
|
||||
type CanvasWithRelationsData = types.CanvasWithRelationsData
|
||||
type FetchCanvasRelationsInput = types.FetchCanvasRelationsInput
|
||||
type CanvasReasonerInput = types.CanvasReasonerInput
|
||||
type GraphRAGQueryInput = types.GraphRAGQueryInput
|
||||
type GraphRAGQueryOutput = types.GraphRAGQueryOutput
|
||||
type QueryPathData = types.QueryPathData
|
||||
type CanvasCompatibilityInput = types.CanvasCompatibilityInput
|
||||
type IndexGraphRAGInput = types.IndexGraphRAGInput
|
||||
type IndexGraphRAGOutput = types.IndexGraphRAGOutput
|
||||
Reference in New Issue
Block a user