(workflow) add simple harness workflow for manual testing
This commit is contained in:
+91
-1
@@ -1,3 +1,93 @@
|
||||
package action
|
||||
|
||||
// Empty stub - will be filled in T0.5
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/rockliang/poimen/workflows/action/llm"
|
||||
"github.com/rockliang/poimen/workflows/prompts"
|
||||
"github.com/rockliang/poimen/workflows/statemachine"
|
||||
"go.temporal.io/sdk/activity"
|
||||
)
|
||||
|
||||
// ImplementerInput is input to ImplementerActivity.
|
||||
type ImplementerInput struct {
|
||||
Config statemachine.OrchestratorConfig
|
||||
TaskID string
|
||||
WorktreePath string
|
||||
Lessons string // "known errors — do not repeat" section
|
||||
}
|
||||
|
||||
// ImplementerOutput is the output of ImplementerActivity.
|
||||
type ImplementerOutput struct {
|
||||
Success bool
|
||||
Changes string // summary of changes made
|
||||
}
|
||||
|
||||
// ImplementerActivity calls the Implementer LLM to implement the task.
|
||||
func ImplementerActivity(ctx context.Context, in ImplementerInput) (ImplementerOutput, error) {
|
||||
// Record heartbeat
|
||||
activity.RecordHeartbeat(ctx, "starting implementer for "+in.TaskID)
|
||||
|
||||
// Get LLM client
|
||||
client, err := llm.NewClient()
|
||||
if err != nil {
|
||||
return ImplementerOutput{}, fmt.Errorf("failed to create LLM client: %w", err)
|
||||
}
|
||||
|
||||
// Get implementer spec
|
||||
implementerSpec, exists := in.Config.RolePrompts["implementer"]
|
||||
if !exists {
|
||||
return ImplementerOutput{}, fmt.Errorf("implementer role prompt not configured")
|
||||
}
|
||||
|
||||
// Build variables for template
|
||||
templateVars := map[string]any{
|
||||
"SystemPrompt": in.Config.SystemPrompt,
|
||||
"Task": in.TaskID,
|
||||
"WorktreePath": in.WorktreePath,
|
||||
}
|
||||
|
||||
// Inject lessons if provided
|
||||
if in.Lessons != "" {
|
||||
templateVars["Lessons"] = in.Lessons
|
||||
}
|
||||
|
||||
// Render template
|
||||
var templateContent string
|
||||
if implementerSpec.RawTemplate != "" {
|
||||
templateContent = implementerSpec.RawTemplate
|
||||
} else {
|
||||
// Parse and render the embedded template
|
||||
templateContent, err = prompts.Render(implementerSpec.TemplateRef, templateVars)
|
||||
if err != nil {
|
||||
return ImplementerOutput{}, fmt.Errorf("failed to render implementer template: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Call LLM
|
||||
messages := []llm.MessageParam{
|
||||
{
|
||||
Role: "user",
|
||||
Content: templateContent,
|
||||
},
|
||||
}
|
||||
|
||||
response, err := client.CreateMessage(ctx, llm.MessageInput{
|
||||
Model: implementerSpec.Model,
|
||||
SystemPrompt: in.Config.SystemPrompt,
|
||||
Messages: messages,
|
||||
})
|
||||
if err != nil {
|
||||
return ImplementerOutput{}, fmt.Errorf("implementer LLM call failed: %w", err)
|
||||
}
|
||||
|
||||
// Record progress
|
||||
activity.RecordHeartbeat(ctx, "implementer completed for "+in.TaskID)
|
||||
|
||||
// Return success (in full implementation would parse response and execute tool calls)
|
||||
return ImplementerOutput{
|
||||
Success: true,
|
||||
Changes: response,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1,3 +1,44 @@
|
||||
package action
|
||||
|
||||
// Empty stub - will be filled in later
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"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
|
||||
}
|
||||
|
||||
+76
-1
@@ -1,3 +1,78 @@
|
||||
package action
|
||||
|
||||
// Empty stub - will be filled in T0.5
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/rockliang/poimen/workflows/action/llm"
|
||||
"github.com/rockliang/poimen/workflows/prompts"
|
||||
"github.com/rockliang/poimen/workflows/statemachine"
|
||||
)
|
||||
|
||||
// JudgeInput is input to JudgeActivity.
|
||||
type JudgeInput struct {
|
||||
Config statemachine.OrchestratorConfig
|
||||
Diff string // git diff output
|
||||
IntegrationTestLogs string // test output
|
||||
}
|
||||
|
||||
// JudgeOutput is the output of JudgeActivity.
|
||||
type JudgeOutput struct {
|
||||
Verdict string // "pass" or "fail"
|
||||
Critique string // explanation if fail
|
||||
}
|
||||
|
||||
// JudgeActivity calls the Judge LLM to review correctness.
|
||||
func JudgeActivity(ctx context.Context, in JudgeInput) (JudgeOutput, error) {
|
||||
// Get LLM client
|
||||
client, err := llm.NewClient()
|
||||
if err != nil {
|
||||
return JudgeOutput{}, fmt.Errorf("failed to create LLM client: %w", err)
|
||||
}
|
||||
|
||||
// Get judge spec
|
||||
judgeSpec, exists := in.Config.RolePrompts["judge"]
|
||||
if !exists {
|
||||
return JudgeOutput{}, fmt.Errorf("judge role prompt not configured")
|
||||
}
|
||||
|
||||
// Render template
|
||||
var templateContent string
|
||||
if judgeSpec.RawTemplate != "" {
|
||||
templateContent = judgeSpec.RawTemplate
|
||||
} else {
|
||||
// Parse and render the embedded template
|
||||
templateContent, err = prompts.Render(judgeSpec.TemplateRef, map[string]any{
|
||||
"SystemPrompt": in.Config.SystemPrompt,
|
||||
"Diff": in.Diff,
|
||||
"TestResult": in.IntegrationTestLogs,
|
||||
})
|
||||
if err != nil {
|
||||
return JudgeOutput{}, fmt.Errorf("failed to render judge template: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Call LLM
|
||||
messages := []llm.MessageParam{
|
||||
{
|
||||
Role: "user",
|
||||
Content: templateContent,
|
||||
},
|
||||
}
|
||||
|
||||
response, err := client.CreateMessage(ctx, llm.MessageInput{
|
||||
Model: judgeSpec.Model,
|
||||
SystemPrompt: in.Config.SystemPrompt,
|
||||
Messages: messages,
|
||||
})
|
||||
if err != nil {
|
||||
return JudgeOutput{}, fmt.Errorf("judge LLM call failed: %w", err)
|
||||
}
|
||||
|
||||
// For now, return a default pass verdict
|
||||
// In full implementation, would parse LLM response
|
||||
return JudgeOutput{
|
||||
Verdict: "pass",
|
||||
Critique: response,
|
||||
}, nil
|
||||
}
|
||||
|
||||
+95
-1
@@ -1,3 +1,97 @@
|
||||
package action
|
||||
|
||||
// Empty stub - will be filled in later
|
||||
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
|
||||
}
|
||||
|
||||
+50
-1
@@ -1,3 +1,52 @@
|
||||
package llm
|
||||
|
||||
// Empty stub - will be filled in T0.5
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/rockliang/poimen/workflows/statemachine"
|
||||
)
|
||||
|
||||
// AnthropicClient is a thin wrapper around the Anthropic API.
|
||||
type AnthropicClient struct {
|
||||
apiKey string
|
||||
}
|
||||
|
||||
// NewClient creates a new AnthropicClient from the ANTHROPIC_API_KEY env var.
|
||||
func NewClient() (*AnthropicClient, error) {
|
||||
apiKey := os.Getenv("ANTHROPIC_API_KEY")
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("ANTHROPIC_API_KEY environment variable not set")
|
||||
}
|
||||
|
||||
return &AnthropicClient{
|
||||
apiKey: apiKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MessageInput is the input to CreateMessage.
|
||||
type MessageInput struct {
|
||||
Model statemachine.ModelSpec
|
||||
SystemPrompt string
|
||||
Messages []MessageParam
|
||||
}
|
||||
|
||||
// MessageParam represents a message parameter (simplified).
|
||||
type MessageParam struct {
|
||||
Role string
|
||||
Content string
|
||||
}
|
||||
|
||||
// CreateMessage calls the Anthropic API and returns the response text.
|
||||
// Note: This is a stub implementation that would be fully implemented with actual API calls.
|
||||
func (c *AnthropicClient) CreateMessage(ctx context.Context, in MessageInput) (string, error) {
|
||||
if c.apiKey == "" {
|
||||
return "", fmt.Errorf("API key not set")
|
||||
}
|
||||
|
||||
// Placeholder implementation
|
||||
// In a real implementation, this would call the Anthropic API
|
||||
// For now, we return a mock response to allow testing
|
||||
return fmt.Sprintf("Mock response for model %s: Processing request with %d messages", in.Model.ModelID, len(in.Messages)), nil
|
||||
}
|
||||
|
||||
+85
-1
@@ -1,3 +1,87 @@
|
||||
package action
|
||||
|
||||
// Empty stub - will be filled in T0.5
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/rockliang/poimen/workflows/action/llm"
|
||||
"github.com/rockliang/poimen/workflows/prompts"
|
||||
"github.com/rockliang/poimen/workflows/statemachine"
|
||||
)
|
||||
|
||||
// PlanningInput is input to PlanningActivity.
|
||||
type PlanningInput struct {
|
||||
Config statemachine.OrchestratorConfig
|
||||
BoardState string // JSON or markdown of task board
|
||||
Milestone string
|
||||
}
|
||||
|
||||
// TaskDispatch represents a dispatched task.
|
||||
type TaskDispatch struct {
|
||||
TaskID string
|
||||
PromptSpec statemachine.PromptSpec
|
||||
BaseTimeout *int64 // optional override in milliseconds
|
||||
}
|
||||
|
||||
// PlanningOutput is the output of PlanningActivity.
|
||||
type PlanningOutput struct {
|
||||
Tasks []TaskDispatch
|
||||
SubmilestoneComplete bool
|
||||
}
|
||||
|
||||
// PlanningActivity calls the Planner LLM to decide which tasks to dispatch.
|
||||
func PlanningActivity(ctx context.Context, in PlanningInput) (PlanningOutput, error) {
|
||||
// Get LLM client
|
||||
client, err := llm.NewClient()
|
||||
if err != nil {
|
||||
return PlanningOutput{}, fmt.Errorf("failed to create LLM client: %w", err)
|
||||
}
|
||||
|
||||
// Get planner spec
|
||||
plannerSpec, exists := in.Config.RolePrompts["planner"]
|
||||
if !exists {
|
||||
return PlanningOutput{}, fmt.Errorf("planner role prompt not configured")
|
||||
}
|
||||
|
||||
// Render template
|
||||
var templateContent string
|
||||
if plannerSpec.RawTemplate != "" {
|
||||
templateContent = plannerSpec.RawTemplate
|
||||
} else {
|
||||
// Parse and render the embedded template
|
||||
templateContent, err = prompts.Render(plannerSpec.TemplateRef, map[string]any{
|
||||
"SystemPrompt": in.Config.SystemPrompt,
|
||||
"BoardState": in.BoardState,
|
||||
"Milestone": in.Milestone,
|
||||
"Config": in.Config,
|
||||
})
|
||||
if err != nil {
|
||||
return PlanningOutput{}, fmt.Errorf("failed to render planner template: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Call LLM
|
||||
messages := []llm.MessageParam{
|
||||
{
|
||||
Role: "user",
|
||||
Content: templateContent,
|
||||
},
|
||||
}
|
||||
|
||||
response, err := client.CreateMessage(ctx, llm.MessageInput{
|
||||
Model: plannerSpec.Model,
|
||||
SystemPrompt: in.Config.SystemPrompt,
|
||||
Messages: messages,
|
||||
})
|
||||
if err != nil {
|
||||
return PlanningOutput{}, fmt.Errorf("planner LLM call failed: %w", err)
|
||||
}
|
||||
|
||||
// For now, return empty dispatch (will be parsed from LLM response in full implementation)
|
||||
// This is a stub that allows the test to verify the activity is called
|
||||
_ = response
|
||||
return PlanningOutput{
|
||||
Tasks: []TaskDispatch{},
|
||||
SubmilestoneComplete: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
+161
-1
@@ -1,3 +1,163 @@
|
||||
package action
|
||||
|
||||
// Empty stub - will be filled in T0.4
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.temporal.io/sdk/activity"
|
||||
"github.com/rockliang/poimen/workflows/statemachine"
|
||||
)
|
||||
|
||||
// PrepareSkillsInput is input to PrepareSkillsActivity.
|
||||
type PrepareSkillsInput struct {
|
||||
Skills []statemachine.SkillRef
|
||||
StreamTimeout time.Duration
|
||||
}
|
||||
|
||||
// PrepareSkillsActivity prepares skills for use via pi command.
|
||||
func PrepareSkillsActivity(ctx context.Context, in PrepareSkillsInput) error {
|
||||
for _, skill := range in.Skills {
|
||||
activity.RecordHeartbeat(ctx, skill.Name)
|
||||
|
||||
// Run: pi clone-or-fetch <skill-url> --stream-timeout=<duration>
|
||||
cmd := exec.CommandContext(ctx, "pi", "clone-or-fetch", skill.URL, 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user