31 KiB
31 KiB
Temporal.io + Graph RAG Integration for Workflows
Architecture
┌─────────────────────────────────┐
│ Workflow API Request │
│ POST /workflows/{id}/query │
└──────────────┬──────────────────┘
│ {query, search_type, relation_type, version}
▼
┌─────────────────────────────────┐
│ Temporal Workflow Orchestrator│
│ - Start execution │
│ - Route to activities │
│ - Aggregate results │
└──────────────┬──────────────────┘
│
┌──────┴──────┐
▼ ▼
┌────────────┐ ┌─────────────────┐
│ Activity 1 │ │ Activity 2 │
│ Retrieve │ │ Query Graph RAG │
│ Entities │ │ for Relations │
└────────────┘ └─────────────────┘
│ │
▼ ▼
┌────────────────────────────────┐
│ Memory API │
│ /memory/query/semantic │
│ /memory/entities/{id}/v.. │
└────────────────────────────────┘
│
▼
┌────────────────────────────────┐
│ Graph RAG (PostgreSQL) │
│ - workflow_relations │
│ - workflow_versions │
│ - relation_changes │
└────────────────────────────────┘
1. Temporal Workflow Definition
File: statemachine/workflow_graph_query.go
package statemachine
import (
"context"
"time"
"go.temporal.io/sdk/workflow"
"github.com/rockliang/poimen/workflows/action"
"github.com/rockliang/poimen/workflows/pkg/db"
)
// WorkflowGraphQueryInput defines query parameters
type WorkflowGraphQueryInput struct {
WorkflowID string `json:"workflow_id"`
Query string `json:"query"`
SearchType string `json:"search_type"` // entities, edges, all
RelationType string `json:"relation_type"` // data-flow, dependency, etc
Version int `json:"version"` // canvas version
ConfidenceFloor float64 `json:"confidence_floor"`
TopK int `json:"top_k"`
FindPaths bool `json:"find_paths"`
TargetNodeID string `json:"target_node_id"`
MaxPathDepth int `json:"max_path_depth"`
RankingProfile string `json:"ranking_profile"` // default, recency, accuracy
IncludeReasoning bool `json:"include_reasoning"`
}
// WorkflowGraphQueryOutput aggregates results
type WorkflowGraphQueryOutput struct {
WorkflowID string `json:"workflow_id"`
Query string `json:"query"`
Version int `json:"version"`
ExecutionTimeMs int64 `json:"execution_time_ms"`
Results []EdgeWithWording `json:"results"`
Paths []QueryPath `json:"paths"`
TotalCount int `json:"total_count"`
HasMore bool `json:"has_more"`
RankingProfile string `json:"ranking_profile"`
}
type QueryPath struct {
SourceID string `json:"source_id"`
TargetID string `json:"target_id"`
PathCount int `json:"path_count"`
Distance int `json:"distance"`
Confidence float64 `json:"total_confidence"`
PathNodes []string `json:"node_ids"`
}
// WorkflowGraphQuery is the main Temporal workflow
func WorkflowGraphQuery(ctx workflow.Context, input WorkflowGraphQueryInput) (WorkflowGraphQueryOutput, error) {
startTime := time.Now()
output := WorkflowGraphQueryOutput{
WorkflowID: input.WorkflowID,
Query: input.Query,
Version: input.Version,
RankingProfile: input.RankingProfile,
Results: []EdgeWithWording{},
}
// Activity options
opts := workflow.ActivityOptions{
StartToCloseTimeout: 120 * time.Second,
RetryPolicy: &temporal.RetryPolicy{
InitialInterval: 2 * time.Second,
BackoffCoefficient: 2.0,
MaxInterval: 10 * time.Second,
MaxAttempts: 3,
},
}
ctx = workflow.WithActivityOptions(ctx, opts)
// Step 1: Fetch Canvas + Relations from Database
var canvasData action.CanvasWithRelationsData
err := workflow.ExecuteActivity(
ctx,
action.FetchCanvasRelationsActivity,
action.FetchCanvasRelationsInput{
WorkflowID: input.WorkflowID,
Version: input.Version,
},
).Get(ctx, &canvasData)
if err != nil {
return output, err
}
// Step 2: Query Graph RAG based on search_type
var graphResults action.GraphRAGQueryOutput
graphQuery := action.GraphRAGQueryInput{
WorkflowID: input.WorkflowID,
Query: input.Query,
SearchType: input.SearchType,
RelationType: input.RelationType,
ConfidenceFloor: input.ConfidenceFloor,
TopK: input.TopK,
RankingProfile: input.RankingProfile,
Canvas: canvasData,
}
err = workflow.ExecuteActivity(
ctx,
action.QueryGraphRAGActivity,
graphQuery,
).Get(ctx, &graphResults)
if err != nil {
return output, err
}
output.Results = graphResults.Edges
output.TotalCount = graphResults.TotalCount
output.HasMore = graphResults.HasMore
// Step 3: If find_paths requested, calculate paths
if input.FindPaths && input.TargetNodeID != "" {
var pathResults action.PathfindingOutput
err = workflow.ExecuteActivity(
ctx,
action.FindRelationPathsActivity,
action.FindRelationPathsInput{
WorkflowID: input.WorkflowID,
Version: input.Version,
SourceNodes: extractSourceNodes(graphResults.Edges),
TargetNodeID: input.TargetNodeID,
MaxDepth: input.MaxPathDepth,
Canvas: canvasData,
Relations: graphResults.Edges,
},
).Get(ctx, &pathResults)
if err != nil {
return output, err
}
output.Paths = pathResults.Paths
}
// Step 4: If reasoning requested, explain results
if input.IncludeReasoning {
var reasoning action.ReasoningOutput
err = workflow.ExecuteActivity(
ctx,
action.ExplainGraphResultsActivity,
action.ExplainGraphResultsInput{
Query: input.Query,
Results: output.Results,
Paths: output.Paths,
Confidence: calculateAverageConfidence(output.Results),
},
).Get(ctx, &reasoning)
if err != nil {
// Log but don't fail if reasoning fails
workflow.GetLogger(ctx).Warn("Reasoning failed", "error", err)
}
}
output.ExecutionTimeMs = time.Since(startTime).Milliseconds()
return output, nil
}
func extractSourceNodes(edges []EdgeWithWording) []string {
nodeSet := make(map[string]bool)
for _, edge := range edges {
nodeSet[edge.Source] = true
}
var nodes []string
for node := range nodeSet {
nodes = append(nodes, node)
}
return nodes
}
func calculateAverageConfidence(edges []EdgeWithWording) float64 {
if len(edges) == 0 {
return 0.0
}
sum := 0.0
for _, edge := range edges {
sum += edge.RelationWording.Confidence
}
return sum / float64(len(edges))
}
2. Activity: Fetch Canvas + Relations
File: action/fetch_canvas_relations.go
package action
import (
"context"
"fmt"
"github.com/rockliang/poimen/workflows/pkg/db"
)
type CanvasWithRelationsData struct {
WorkflowID string `json:"workflow_id"`
Version int `json:"version"`
Nodes []db.WorkflowNode `json:"nodes"`
Edges []EdgeWithWording `json:"edges"`
CreatedAt string `json:"created_at"`
Metadata map[string]interface{} `json:"metadata"`
}
type FetchCanvasRelationsInput struct {
WorkflowID string `json:"workflow_id"`
Version int `json:"version"`
}
// FetchCanvasRelationsActivity retrieves canvas + versioned relations
func FetchCanvasRelationsActivity(ctx context.Context, input FetchCanvasRelationsInput) (CanvasWithRelationsData, error) {
logger := newActivityLogger(ctx)
output := CanvasWithRelationsData{
WorkflowID: input.WorkflowID,
Version: input.Version,
Nodes: []db.WorkflowNode{},
Edges: []EdgeWithWording{},
}
logger.logf("info", "Fetching canvas relations for workflow %s version %d", input.WorkflowID, input.Version)
// Get database connection (injected via Temporal context)
dbConn, err := getDBFromContext(ctx)
if err != nil {
return output, fmt.Errorf("failed to get DB connection: %w", err)
}
// Fetch workflow version + canvas
var canvas db.Canvas
query := `
SELECT workflow_id, version, nodes, edges, created_at, metadata
FROM workflow_versions
WHERE workflow_id = $1 AND version = $2
`
rows, err := dbConn.Query(ctx, query, input.WorkflowID, input.Version)
if err != nil {
return output, fmt.Errorf("failed to query workflow_versions: %w", err)
}
defer rows.Close()
if !rows.Next() {
return output, fmt.Errorf("workflow version not found: %s v%d", input.WorkflowID, input.Version)
}
// Parse canvas JSONB
if err := rows.Scan(&canvas.WorkflowID, &canvas.Version, &canvas.Nodes, &canvas.Edges, &canvas.CreatedAt, &canvas.Metadata); err != nil {
return output, fmt.Errorf("failed to scan canvas: %w", err)
}
output.Nodes = canvas.Nodes
output.CreatedAt = canvas.CreatedAt.String()
// Fetch relations (edges with wording)
relQuery := `
SELECT id, source_node_id, target_node_id, relation_type, relation_label,
relation_wording, metadata, created_at
FROM workflow_relations
WHERE workflow_id = $1 AND version = $2
ORDER BY created_at
`
relRows, err := dbConn.Query(ctx, relQuery, input.WorkflowID, input.Version)
if err != nil {
return output, fmt.Errorf("failed to query workflow_relations: %w", err)
}
defer relRows.Close()
for relRows.Next() {
var edge EdgeWithWording
if err := relRows.Scan(
&edge.ID,
&edge.Source,
&edge.Target,
&edge.RelationType,
&edge.RelationLabel,
&edge.RelationWording,
&edge.Metadata,
&edge.CreatedAt,
); err != nil {
logger.logf("warn", "Failed to scan relation: %v", err)
continue
}
output.Edges = append(output.Edges, edge)
}
logger.logf("info", "Fetched %d nodes, %d relations", len(output.Nodes), len(output.Edges))
return output, nil
}
3. Activity: Query Graph RAG
File: action/query_graph_rag.go
package action
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)
type GraphRAGQueryInput struct {
WorkflowID string `json:"workflow_id"`
Query string `json:"query"`
SearchType string `json:"search_type"` // entities, edges, all
RelationType string `json:"relation_type"`
ConfidenceFloor float64 `json:"confidence_floor"`
TopK int `json:"top_k"`
RankingProfile string `json:"ranking_profile"`
Canvas CanvasWithRelationsData `json:"canvas"`
}
type GraphRAGQueryOutput struct {
WorkflowID string `json:"workflow_id"`
Query string `json:"query"`
Edges []EdgeWithWording `json:"results"`
TotalCount int `json:"total_count"`
HasMore bool `json:"has_more"`
ExecutionMs int64 `json:"execution_time_ms"`
}
// QueryGraphRAGActivity calls Memory System unified query
func QueryGraphRAGActivity(ctx context.Context, input GraphRAGQueryInput) (GraphRAGQueryOutput, error) {
logger := newActivityLogger(ctx)
output := GraphRAGQueryOutput{
WorkflowID: input.WorkflowID,
Query: input.Query,
Edges: []EdgeWithWording{},
}
logger.logf("info", "Querying Graph RAG: %s (type: %s, confidence: %.2f)", input.Query, input.SearchType, input.ConfidenceFloor)
// Build unified query for Memory System
memoryQuery := buildMemoryUnifiedQuery(input)
// Call Memory System /memory/query endpoint
payload, err := json.Marshal(memoryQuery)
if err != nil {
return output, fmt.Errorf("failed to marshal query: %w", err)
}
// Get JWT token from context (set by worker)
token := ctx.Value("jwt_token").(string)
req, err := http.NewRequestWithContext(ctx, "POST", "http://localhost:8080/memory/query", bytes.NewReader(payload))
if err != nil {
return output, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return output, fmt.Errorf("failed to query Memory System: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return output, fmt.Errorf("Memory System returned %d: %s", resp.StatusCode, string(body))
}
// Parse response
var memoryResp struct {
Results []map[string]interface{} `json:"results"`
TotalCount int `json:"total_count"`
HasMore bool `json:"has_more"`
ExecutionMs int64 `json:"execution_time_ms"`
}
if err := json.NewDecoder(resp.Body).Decode(&memoryResp); err != nil {
return output, fmt.Errorf("failed to decode Memory response: %w", err)
}
// Map results to edges with wording
for _, result := range memoryResp.Results {
edge := EdgeWithWording{}
// Extract edge data from Memory result
if sourceID, ok := result["source_entity_id"].(string); ok {
edge.Source = sourceID
}
if targetID, ok := result["target_entity_id"].(string); ok {
edge.Target = targetID
}
if relType, ok := result["relation_type"].(string); ok {
edge.RelationType = relType
}
if fact, ok := result["fact"].(string); ok {
edge.RelationLabel = fact
}
if conf, ok := result["confidence"].(float64); ok {
edge.RelationWording.Confidence = conf
}
output.Edges = append(output.Edges, edge)
}
output.TotalCount = memoryResp.TotalCount
output.HasMore = memoryResp.HasMore
output.ExecutionMs = memoryResp.ExecutionMs
logger.logf("info", "Graph RAG returned %d edges", len(output.Edges))
return output, nil
}
// buildMemoryUnifiedQuery constructs Memory System unified query format
func buildMemoryUnifiedQuery(input GraphRAGQueryInput) map[string]interface{} {
query := map[string]interface{}{
"query": input.Query,
"search_type": "edges", // Default to edges for workflow relations
"confidence_floor": input.ConfidenceFloor,
"top_k": input.TopK,
"find_paths": input.RelationType != "",
"ranking_profile": input.RankingProfile,
"detect_communities": false,
"discover_facets": false,
}
// Add relation type filter if specified
if input.RelationType != "" {
if query["facet_filters"] == nil {
query["facet_filters"] = map[string]interface{}{}
}
filters := query["facet_filters"].(map[string]interface{})
filters["relation_type"] = input.RelationType
}
return query
}
4. Activity: Find Relation Paths
File: action/find_relation_paths.go
package action
import (
"context"
"fmt"
)
type FindRelationPathsInput struct {
WorkflowID string `json:"workflow_id"`
Version int `json:"version"`
SourceNodes []string `json:"source_nodes"`
TargetNodeID string `json:"target_node_id"`
MaxDepth int `json:"max_depth"`
Canvas CanvasWithRelationsData `json:"canvas"`
Relations []EdgeWithWording `json:"relations"`
}
type PathfindingOutput struct {
Paths []QueryPath `json:"paths"`
}
// FindRelationPathsActivity computes shortest paths in relation graph
func FindRelationPathsActivity(ctx context.Context, input FindRelationPathsInput) (PathfindingOutput, error) {
logger := newActivityLogger(ctx)
output := PathfindingOutput{Paths: []QueryPath{}}
if input.TargetNodeID == "" {
return output, fmt.Errorf("target_node_id required for pathfinding")
}
logger.logf("info", "Finding paths to %s (max depth: %d)", input.TargetNodeID, input.MaxDepth)
// Build adjacency list from relations
graph := buildRelationGraph(input.Relations)
// BFS from each source to target
for _, sourceNode := range input.SourceNodes {
if sourceNode == input.TargetNodeID {
continue
}
paths := bfsShortestPaths(graph, sourceNode, input.TargetNodeID, input.MaxDepth)
for _, path := range paths {
output.Paths = append(output.Paths, path)
}
}
logger.logf("info", "Found %d paths", len(output.Paths))
return output, nil
}
func buildRelationGraph(relations []EdgeWithWording) map[string][]string {
graph := make(map[string][]string)
for _, edge := range relations {
graph[edge.Source] = append(graph[edge.Source], edge.Target)
}
return graph
}
func bfsShortestPaths(graph map[string][]string, source, target string, maxDepth int) []QueryPath {
var paths []QueryPath
queue := [][]string{{source}}
visited := make(map[string]bool)
visited[source] = true
for len(queue) > 0 {
path := queue[0]
queue = queue[1:]
if len(path) > maxDepth {
continue
}
current := path[len(path)-1]
if current == target {
paths = append(paths, QueryPath{
SourceID: source,
TargetID: target,
Distance: len(path) - 1,
PathCount: len(paths) + 1,
PathNodes: path,
})
continue
}
for _, neighbor := range graph[current] {
newPath := append([]string{}, path...)
newPath = append(newPath, neighbor)
queue = append(queue, newPath)
}
}
return paths
}
5. API Handler: Wire Everything Together
File: internal/api/workflows_graph_query.go
package api
import (
"context"
"encoding/json"
"net/http"
"time"
"go.temporal.io/sdk/client"
"github.com/rockliang/poimen/workflows/statemachine"
)
// QueryWorkflowGraph handles POST /workflows/{id}/query
func (s *Server) QueryWorkflowGraph(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
workflowID := r.PathValue("id")
if workflowID == "" {
http.Error(w, "Missing workflow ID", http.StatusBadRequest)
return
}
// Parse request
var input statemachine.WorkflowGraphQueryInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
input.WorkflowID = workflowID
// Set defaults
if input.SearchType == "" {
input.SearchType = "edges"
}
if input.TopK == 0 {
input.TopK = 10
}
if input.ConfidenceFloor == 0 {
input.ConfidenceFloor = 0.5
}
if input.MaxPathDepth == 0 {
input.MaxPathDepth = 3
}
if input.RankingProfile == "" {
input.RankingProfile = "default"
}
// Start Temporal workflow
temporalClient := s.TemporalClient // injected in NewServer
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
we, err := temporalClient.ExecuteWorkflow(
ctx,
client.StartWorkflowOptions{
ID: workflowID + "-query-" + time.Now().Format("20060102150405"),
TaskQueue: "workflow-tasks",
},
statemachine.WorkflowGraphQuery,
input,
)
if err != nil {
http.Error(w, "Failed to start workflow", http.StatusInternalServerError)
return
}
// Wait for result
var result statemachine.WorkflowGraphQueryOutput
if err := we.Get(ctx, &result); err != nil {
http.Error(w, "Workflow failed", http.StatusInternalServerError)
return
}
// Return result
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(result)
}
// GetWorkflowRelationVersions handles GET /workflows/{id}/relations/{edge_id}/versions
func (s *Server) GetWorkflowRelationVersions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
workflowID := r.PathValue("id")
edgeID := r.PathValue("edge_id")
if workflowID == "" || edgeID == "" {
http.Error(w, "Missing workflow or edge ID", http.StatusBadRequest)
return
}
// Query database for edge versions
query := `
SELECT version_num, operation, snapshot, changed_at, changed_by, fields_changed
FROM workflow_relation_versions
WHERE workflow_id = $1 AND edge_id = $2
ORDER BY version_num
`
rows, err := s.DB.Query(r.Context(), query, workflowID, edgeID)
if err != nil {
http.Error(w, "Failed to query versions", http.StatusInternalServerError)
return
}
defer rows.Close()
type Version struct {
VersionNum int `json:"version_num"`
Operation string `json:"operation"`
Snapshot interface{} `json:"snapshot"`
ChangedAt string `json:"changed_at"`
ChangedBy string `json:"changed_by"`
FieldsChanged []string `json:"fields_changed"`
}
versions := []Version{}
for rows.Next() {
v := Version{}
if err := rows.Scan(&v.VersionNum, &v.Operation, &v.Snapshot, &v.ChangedAt, &v.ChangedBy, &v.FieldsChanged); err != nil {
continue
}
versions = append(versions, v)
}
response := map[string]interface{}{
"edge_id": edgeID,
"workflow_id": workflowID,
"versions": versions,
"total_versions": len(versions),
"current_version": len(versions),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
6. Database Schema for Graph RAG
File: migrations/004_graph_rag_relations.sql
-- Versioned relations with wording
CREATE TABLE workflow_relations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workflow_id UUID NOT NULL,
version INT NOT NULL,
source_node_id VARCHAR(255) NOT NULL,
target_node_id VARCHAR(255) NOT NULL,
relation_type VARCHAR(100), -- data-flow, dependency, conditional, parallel
relation_label TEXT,
relation_wording JSONB, -- {verb, source_output, target_input, confidence, etc}
metadata JSONB,
created_at TIMESTAMP DEFAULT NOW(),
FOREIGN KEY (workflow_id, version) REFERENCES workflow_versions(workflow_id, version),
INDEX (workflow_id, version),
INDEX (relation_type),
UNIQUE (workflow_id, version, source_node_id, target_node_id)
);
-- Relation change history for versioning
CREATE TABLE workflow_relation_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workflow_id UUID NOT NULL,
edge_id UUID NOT NULL REFERENCES workflow_relations(id),
version_num INT NOT NULL,
operation VARCHAR(50), -- CREATE, UPDATE, DELETE
snapshot JSONB, -- Full relation state at this version
changed_at TIMESTAMP DEFAULT NOW(),
changed_by VARCHAR(255),
fields_changed TEXT[],
INDEX (workflow_id, version_num),
UNIQUE (workflow_id, edge_id, version_num)
);
-- Graph RAG indexing metadata
CREATE TABLE workflow_rag_index (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workflow_id UUID NOT NULL,
version INT NOT NULL,
indexed_entities JSONB, -- {node_ids, count}
indexed_edges JSONB, -- {edge_ids, count}
index_status VARCHAR(50), -- indexed, pending, failed
last_indexed_at TIMESTAMP,
embedding_model VARCHAR(100),
INDEX (workflow_id, version)
);
7. Worker Configuration
File: cmd/server/main.go (additions)
import (
"context"
"go.temporal.io/sdk/client"
"go.temporal.io/sdk/worker"
"github.com/rockliang/poimen/workflows/statemachine"
"github.com/rockliang/poimen/workflows/action"
)
func main() {
// ... existing setup ...
// Temporal client
c, err := client.Dial(client.Options{
HostPort: os.Getenv("TEMPORAL_HOST_PORT"), // localhost:7233
})
if err != nil {
log.Fatal(err)
}
defer c.Close()
// Worker
w := worker.New(c, "workflow-tasks", worker.Options{})
// Register workflows
w.RegisterWorkflow(statemachine.WorkflowGraphQuery)
// Register activities with DB connection in context
w.RegisterActivityWithOptions(
func(ctx context.Context, input interface{}) (interface{}, error) {
// Inject DB into context
ctx = context.WithValue(ctx, "db", dbConn)
ctx = context.WithValue(ctx, "jwt_token", os.Getenv("JWT_TOKEN"))
return nil, nil
},
activity.RegisterOptions{Name: "setup"},
)
w.RegisterActivity(action.FetchCanvasRelationsActivity)
w.RegisterActivity(action.QueryGraphRAGActivity)
w.RegisterActivity(action.FindRelationPathsActivity)
w.RegisterActivity(action.ExplainGraphResultsActivity)
// Start worker
if err := w.Start(); err != nil {
log.Fatal(err)
}
defer w.Stop()
// Register API handler
server := api.NewServer(dbConn, c)
http.HandleFunc("POST /workflows/{id}/query", server.QueryWorkflowGraph)
http.HandleFunc("GET /workflows/{id}/relations/{edge_id}/versions", server.GetWorkflowRelationVersions)
log.Fatal(http.ListenAndServe(":8081", nil))
}
8. Request/Response Flow Example
Request
curl -X POST http://localhost:8081/workflows/workflow-1/query \
-H "Content-Type: application/json" \
-d '{
"query": "how does code analysis flow into security scanning",
"search_type": "edges",
"relation_type": "data-flow",
"version": 3,
"confidence_floor": 0.7,
"top_k": 10,
"find_paths": true,
"target_node_id": "security-scan-1",
"max_path_depth": 3,
"ranking_profile": "default",
"include_reasoning": true
}'
Execution Flow (Temporal)
-
API Handler → Starts
WorkflowGraphQueryworkflow -
Workflow → Calls
FetchCanvasRelationsActivity- Query DB:
SELECT * FROM workflow_versions WHERE workflow_id=? AND version=? - Query DB:
SELECT * FROM workflow_relations WHERE workflow_id=? AND version=? - Return: Canvas + Relations data
- Query DB:
-
Workflow → Calls
QueryGraphRAGActivity- Build unified query for Memory System
- POST to
/memory/querywith edges search - Return: Ranked edges with wording
-
Workflow → If find_paths, calls
FindRelationPathsActivity- BFS pathfinding in relation graph
- Return: Query paths from sources to target
-
Workflow → If reasoning, calls
ExplainGraphResultsActivity- LLM explains why results match query
- Return: Reasoning narrative
-
Workflow → Aggregates results, returns to API
-
API Handler → Returns 200 + JSON
Response
{
"workflow_id": "workflow-1",
"query": "how does code analysis flow into security scanning",
"version": 3,
"execution_time_ms": 245,
"results": [
{
"id": "edge_analyze_scan",
"source": "analyze-code-1",
"target": "security-scan-1",
"relation_type": "data-flow",
"relation_label": "AnalyzeCode outputs metrics → SecurityScan requires code structure",
"relation_wording": {
"verb": "provides-input-for",
"source_output": "metrics (object)",
"target_input": "path (string)",
"confidence": 0.85
}
}
],
"paths": [
{
"source_id": "analyze-code-1",
"target_id": "security-scan-1",
"distance": 1,
"node_ids": ["analyze-code-1", "security-scan-1"]
}
],
"total_count": 1,
"has_more": false,
"ranking_profile": "default"
}
9. Error Handling
// Temporal retry policy
RetryPolicy: &temporal.RetryPolicy{
InitialInterval: 2 * time.Second,
BackoffCoefficient: 2.0,
MaxInterval: 10 * time.Second,
MaxAttempts: 3,
}
// Memory System errors propagate
if resp.StatusCode != 200 {
return fmt.Errorf("Memory System %d: %s", resp.StatusCode, body)
}
// Activity timeouts
StartToCloseTimeout: 120 * time.Second
10. Testing
File: tests/workflow_graph_query_test.go
func TestWorkflowGraphQuery(t *testing.T) {
// Setup Temporal test
testSuite := &testsuite.WorkflowTestSuite{}
env := testSuite.NewTestWorkflowEnvironment()
// Mock activities
env.OnActivity(action.FetchCanvasRelationsActivity, mock.MatchedBy(func(input interface{}) bool {
return true
})).Return(CanvasWithRelationsData{
Nodes: []db.WorkflowNode{...},
Edges: []EdgeWithWording{...},
}, nil)
env.OnActivity(action.QueryGraphRAGActivity, mock.MatchedBy(func(input interface{}) bool {
return true
})).Return(GraphRAGQueryOutput{
Edges: []EdgeWithWording{...},
TotalCount: 1,
}, nil)
// Execute
env.ExecuteWorkflow(statemachine.WorkflowGraphQuery, input)
// Assert
require.True(t, env.IsWorkflowCompleted())
require.NoError(t, env.GetWorkflowError())
var result statemachine.WorkflowGraphQueryOutput
err := env.GetWorkflowResult(&result)
require.NoError(t, err)
require.Equal(t, 1, result.TotalCount)
}