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:
Test
2026-09-05 23:59:13 -07:00
parent b7aa1fb293
commit 9ec7e6a344
54 changed files with 847 additions and 1901 deletions
+296
View File
@@ -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
}
+144
View File
@@ -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)
}
+216
View File
@@ -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,
}
}
+359
View File
@@ -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
}
+253
View File
@@ -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"
}
+80
View File
@@ -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
View File
@@ -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
}
+41
View File
@@ -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
}
+51
View File
@@ -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
}
+43
View File
@@ -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
}
+32
View File
@@ -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
}
+97
View File
@@ -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
}
+196
View File
@@ -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
}
+100
View File
@@ -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)
}
}
+47
View File
@@ -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
}
+114
View File
@@ -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
}
+49
View File
@@ -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)
}
}
+255
View File
@@ -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] + "..."
}
+138
View File
@@ -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)
}
+343
View File
@@ -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
}
+130
View File
@@ -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)
}
+47
View File
@@ -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
}
+101
View File
@@ -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
}
+89
View File
@@ -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
}
+167
View File
@@ -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
}
+21
View File
@@ -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