gobby 0.2.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gobby/__init__.py +3 -0
- gobby/adapters/__init__.py +30 -0
- gobby/adapters/base.py +93 -0
- gobby/adapters/claude_code.py +276 -0
- gobby/adapters/codex.py +1292 -0
- gobby/adapters/gemini.py +343 -0
- gobby/agents/__init__.py +37 -0
- gobby/agents/codex_session.py +120 -0
- gobby/agents/constants.py +112 -0
- gobby/agents/context.py +362 -0
- gobby/agents/definitions.py +133 -0
- gobby/agents/gemini_session.py +111 -0
- gobby/agents/registry.py +618 -0
- gobby/agents/runner.py +968 -0
- gobby/agents/session.py +259 -0
- gobby/agents/spawn.py +916 -0
- gobby/agents/spawners/__init__.py +77 -0
- gobby/agents/spawners/base.py +142 -0
- gobby/agents/spawners/cross_platform.py +266 -0
- gobby/agents/spawners/embedded.py +225 -0
- gobby/agents/spawners/headless.py +226 -0
- gobby/agents/spawners/linux.py +125 -0
- gobby/agents/spawners/macos.py +277 -0
- gobby/agents/spawners/windows.py +308 -0
- gobby/agents/tty_config.py +319 -0
- gobby/autonomous/__init__.py +32 -0
- gobby/autonomous/progress_tracker.py +447 -0
- gobby/autonomous/stop_registry.py +269 -0
- gobby/autonomous/stuck_detector.py +383 -0
- gobby/cli/__init__.py +67 -0
- gobby/cli/__main__.py +8 -0
- gobby/cli/agents.py +529 -0
- gobby/cli/artifacts.py +266 -0
- gobby/cli/daemon.py +329 -0
- gobby/cli/extensions.py +526 -0
- gobby/cli/github.py +263 -0
- gobby/cli/init.py +53 -0
- gobby/cli/install.py +614 -0
- gobby/cli/installers/__init__.py +37 -0
- gobby/cli/installers/antigravity.py +65 -0
- gobby/cli/installers/claude.py +363 -0
- gobby/cli/installers/codex.py +192 -0
- gobby/cli/installers/gemini.py +294 -0
- gobby/cli/installers/git_hooks.py +377 -0
- gobby/cli/installers/shared.py +737 -0
- gobby/cli/linear.py +250 -0
- gobby/cli/mcp.py +30 -0
- gobby/cli/mcp_proxy.py +698 -0
- gobby/cli/memory.py +304 -0
- gobby/cli/merge.py +384 -0
- gobby/cli/projects.py +79 -0
- gobby/cli/sessions.py +622 -0
- gobby/cli/tasks/__init__.py +30 -0
- gobby/cli/tasks/_utils.py +658 -0
- gobby/cli/tasks/ai.py +1025 -0
- gobby/cli/tasks/commits.py +169 -0
- gobby/cli/tasks/crud.py +685 -0
- gobby/cli/tasks/deps.py +135 -0
- gobby/cli/tasks/labels.py +63 -0
- gobby/cli/tasks/main.py +273 -0
- gobby/cli/tasks/search.py +178 -0
- gobby/cli/tui.py +34 -0
- gobby/cli/utils.py +513 -0
- gobby/cli/workflows.py +927 -0
- gobby/cli/worktrees.py +481 -0
- gobby/config/__init__.py +129 -0
- gobby/config/app.py +551 -0
- gobby/config/extensions.py +167 -0
- gobby/config/features.py +472 -0
- gobby/config/llm_providers.py +98 -0
- gobby/config/logging.py +66 -0
- gobby/config/mcp.py +346 -0
- gobby/config/persistence.py +247 -0
- gobby/config/servers.py +141 -0
- gobby/config/sessions.py +250 -0
- gobby/config/tasks.py +784 -0
- gobby/hooks/__init__.py +104 -0
- gobby/hooks/artifact_capture.py +213 -0
- gobby/hooks/broadcaster.py +243 -0
- gobby/hooks/event_handlers.py +723 -0
- gobby/hooks/events.py +218 -0
- gobby/hooks/git.py +169 -0
- gobby/hooks/health_monitor.py +171 -0
- gobby/hooks/hook_manager.py +856 -0
- gobby/hooks/hook_types.py +575 -0
- gobby/hooks/plugins.py +813 -0
- gobby/hooks/session_coordinator.py +396 -0
- gobby/hooks/verification_runner.py +268 -0
- gobby/hooks/webhooks.py +339 -0
- gobby/install/claude/commands/gobby/bug.md +51 -0
- gobby/install/claude/commands/gobby/chore.md +51 -0
- gobby/install/claude/commands/gobby/epic.md +52 -0
- gobby/install/claude/commands/gobby/eval.md +235 -0
- gobby/install/claude/commands/gobby/feat.md +49 -0
- gobby/install/claude/commands/gobby/nit.md +52 -0
- gobby/install/claude/commands/gobby/ref.md +52 -0
- gobby/install/claude/hooks/HOOK_SCHEMAS.md +632 -0
- gobby/install/claude/hooks/hook_dispatcher.py +364 -0
- gobby/install/claude/hooks/validate_settings.py +102 -0
- gobby/install/claude/hooks-template.json +118 -0
- gobby/install/codex/hooks/hook_dispatcher.py +153 -0
- gobby/install/codex/prompts/forget.md +7 -0
- gobby/install/codex/prompts/memories.md +7 -0
- gobby/install/codex/prompts/recall.md +7 -0
- gobby/install/codex/prompts/remember.md +13 -0
- gobby/install/gemini/hooks/hook_dispatcher.py +268 -0
- gobby/install/gemini/hooks-template.json +138 -0
- gobby/install/shared/plugins/code_guardian.py +456 -0
- gobby/install/shared/plugins/example_notify.py +331 -0
- gobby/integrations/__init__.py +10 -0
- gobby/integrations/github.py +145 -0
- gobby/integrations/linear.py +145 -0
- gobby/llm/__init__.py +40 -0
- gobby/llm/base.py +120 -0
- gobby/llm/claude.py +578 -0
- gobby/llm/claude_executor.py +503 -0
- gobby/llm/codex.py +322 -0
- gobby/llm/codex_executor.py +513 -0
- gobby/llm/executor.py +316 -0
- gobby/llm/factory.py +34 -0
- gobby/llm/gemini.py +258 -0
- gobby/llm/gemini_executor.py +339 -0
- gobby/llm/litellm.py +287 -0
- gobby/llm/litellm_executor.py +303 -0
- gobby/llm/resolver.py +499 -0
- gobby/llm/service.py +236 -0
- gobby/mcp_proxy/__init__.py +29 -0
- gobby/mcp_proxy/actions.py +175 -0
- gobby/mcp_proxy/daemon_control.py +198 -0
- gobby/mcp_proxy/importer.py +436 -0
- gobby/mcp_proxy/lazy.py +325 -0
- gobby/mcp_proxy/manager.py +798 -0
- gobby/mcp_proxy/metrics.py +609 -0
- gobby/mcp_proxy/models.py +139 -0
- gobby/mcp_proxy/registries.py +215 -0
- gobby/mcp_proxy/schema_hash.py +381 -0
- gobby/mcp_proxy/semantic_search.py +706 -0
- gobby/mcp_proxy/server.py +549 -0
- gobby/mcp_proxy/services/__init__.py +0 -0
- gobby/mcp_proxy/services/fallback.py +306 -0
- gobby/mcp_proxy/services/recommendation.py +224 -0
- gobby/mcp_proxy/services/server_mgmt.py +214 -0
- gobby/mcp_proxy/services/system.py +72 -0
- gobby/mcp_proxy/services/tool_filter.py +231 -0
- gobby/mcp_proxy/services/tool_proxy.py +309 -0
- gobby/mcp_proxy/stdio.py +565 -0
- gobby/mcp_proxy/tools/__init__.py +27 -0
- gobby/mcp_proxy/tools/agents.py +1103 -0
- gobby/mcp_proxy/tools/artifacts.py +207 -0
- gobby/mcp_proxy/tools/hub.py +335 -0
- gobby/mcp_proxy/tools/internal.py +337 -0
- gobby/mcp_proxy/tools/memory.py +543 -0
- gobby/mcp_proxy/tools/merge.py +422 -0
- gobby/mcp_proxy/tools/metrics.py +283 -0
- gobby/mcp_proxy/tools/orchestration/__init__.py +23 -0
- gobby/mcp_proxy/tools/orchestration/cleanup.py +619 -0
- gobby/mcp_proxy/tools/orchestration/monitor.py +380 -0
- gobby/mcp_proxy/tools/orchestration/orchestrate.py +746 -0
- gobby/mcp_proxy/tools/orchestration/review.py +736 -0
- gobby/mcp_proxy/tools/orchestration/utils.py +16 -0
- gobby/mcp_proxy/tools/session_messages.py +1056 -0
- gobby/mcp_proxy/tools/task_dependencies.py +219 -0
- gobby/mcp_proxy/tools/task_expansion.py +591 -0
- gobby/mcp_proxy/tools/task_github.py +393 -0
- gobby/mcp_proxy/tools/task_linear.py +379 -0
- gobby/mcp_proxy/tools/task_orchestration.py +77 -0
- gobby/mcp_proxy/tools/task_readiness.py +522 -0
- gobby/mcp_proxy/tools/task_sync.py +351 -0
- gobby/mcp_proxy/tools/task_validation.py +843 -0
- gobby/mcp_proxy/tools/tasks/__init__.py +25 -0
- gobby/mcp_proxy/tools/tasks/_context.py +112 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +516 -0
- gobby/mcp_proxy/tools/tasks/_factory.py +176 -0
- gobby/mcp_proxy/tools/tasks/_helpers.py +129 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +517 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +301 -0
- gobby/mcp_proxy/tools/tasks/_resolution.py +55 -0
- gobby/mcp_proxy/tools/tasks/_search.py +215 -0
- gobby/mcp_proxy/tools/tasks/_session.py +125 -0
- gobby/mcp_proxy/tools/workflows.py +973 -0
- gobby/mcp_proxy/tools/worktrees.py +1264 -0
- gobby/mcp_proxy/transports/__init__.py +0 -0
- gobby/mcp_proxy/transports/base.py +95 -0
- gobby/mcp_proxy/transports/factory.py +44 -0
- gobby/mcp_proxy/transports/http.py +139 -0
- gobby/mcp_proxy/transports/stdio.py +213 -0
- gobby/mcp_proxy/transports/websocket.py +136 -0
- gobby/memory/backends/__init__.py +116 -0
- gobby/memory/backends/mem0.py +408 -0
- gobby/memory/backends/memu.py +485 -0
- gobby/memory/backends/null.py +111 -0
- gobby/memory/backends/openmemory.py +537 -0
- gobby/memory/backends/sqlite.py +304 -0
- gobby/memory/context.py +87 -0
- gobby/memory/manager.py +1001 -0
- gobby/memory/protocol.py +451 -0
- gobby/memory/search/__init__.py +66 -0
- gobby/memory/search/text.py +127 -0
- gobby/memory/viz.py +258 -0
- gobby/prompts/__init__.py +13 -0
- gobby/prompts/defaults/expansion/system.md +119 -0
- gobby/prompts/defaults/expansion/user.md +48 -0
- gobby/prompts/defaults/external_validation/agent.md +72 -0
- gobby/prompts/defaults/external_validation/external.md +63 -0
- gobby/prompts/defaults/external_validation/spawn.md +83 -0
- gobby/prompts/defaults/external_validation/system.md +6 -0
- gobby/prompts/defaults/features/import_mcp.md +22 -0
- gobby/prompts/defaults/features/import_mcp_github.md +17 -0
- gobby/prompts/defaults/features/import_mcp_search.md +16 -0
- gobby/prompts/defaults/features/recommend_tools.md +32 -0
- gobby/prompts/defaults/features/recommend_tools_hybrid.md +35 -0
- gobby/prompts/defaults/features/recommend_tools_llm.md +30 -0
- gobby/prompts/defaults/features/server_description.md +20 -0
- gobby/prompts/defaults/features/server_description_system.md +6 -0
- gobby/prompts/defaults/features/task_description.md +31 -0
- gobby/prompts/defaults/features/task_description_system.md +6 -0
- gobby/prompts/defaults/features/tool_summary.md +17 -0
- gobby/prompts/defaults/features/tool_summary_system.md +6 -0
- gobby/prompts/defaults/research/step.md +58 -0
- gobby/prompts/defaults/validation/criteria.md +47 -0
- gobby/prompts/defaults/validation/validate.md +38 -0
- gobby/prompts/loader.py +346 -0
- gobby/prompts/models.py +113 -0
- gobby/py.typed +0 -0
- gobby/runner.py +488 -0
- gobby/search/__init__.py +23 -0
- gobby/search/protocol.py +104 -0
- gobby/search/tfidf.py +232 -0
- gobby/servers/__init__.py +7 -0
- gobby/servers/http.py +636 -0
- gobby/servers/models.py +31 -0
- gobby/servers/routes/__init__.py +23 -0
- gobby/servers/routes/admin.py +416 -0
- gobby/servers/routes/dependencies.py +118 -0
- gobby/servers/routes/mcp/__init__.py +24 -0
- gobby/servers/routes/mcp/hooks.py +135 -0
- gobby/servers/routes/mcp/plugins.py +121 -0
- gobby/servers/routes/mcp/tools.py +1337 -0
- gobby/servers/routes/mcp/webhooks.py +159 -0
- gobby/servers/routes/sessions.py +582 -0
- gobby/servers/websocket.py +766 -0
- gobby/sessions/__init__.py +13 -0
- gobby/sessions/analyzer.py +322 -0
- gobby/sessions/lifecycle.py +240 -0
- gobby/sessions/manager.py +563 -0
- gobby/sessions/processor.py +225 -0
- gobby/sessions/summary.py +532 -0
- gobby/sessions/transcripts/__init__.py +41 -0
- gobby/sessions/transcripts/base.py +125 -0
- gobby/sessions/transcripts/claude.py +386 -0
- gobby/sessions/transcripts/codex.py +143 -0
- gobby/sessions/transcripts/gemini.py +195 -0
- gobby/storage/__init__.py +21 -0
- gobby/storage/agents.py +409 -0
- gobby/storage/artifact_classifier.py +341 -0
- gobby/storage/artifacts.py +285 -0
- gobby/storage/compaction.py +67 -0
- gobby/storage/database.py +357 -0
- gobby/storage/inter_session_messages.py +194 -0
- gobby/storage/mcp.py +680 -0
- gobby/storage/memories.py +562 -0
- gobby/storage/merge_resolutions.py +550 -0
- gobby/storage/migrations.py +860 -0
- gobby/storage/migrations_legacy.py +1359 -0
- gobby/storage/projects.py +166 -0
- gobby/storage/session_messages.py +251 -0
- gobby/storage/session_tasks.py +97 -0
- gobby/storage/sessions.py +817 -0
- gobby/storage/task_dependencies.py +223 -0
- gobby/storage/tasks/__init__.py +42 -0
- gobby/storage/tasks/_aggregates.py +180 -0
- gobby/storage/tasks/_crud.py +449 -0
- gobby/storage/tasks/_id.py +104 -0
- gobby/storage/tasks/_lifecycle.py +311 -0
- gobby/storage/tasks/_manager.py +889 -0
- gobby/storage/tasks/_models.py +300 -0
- gobby/storage/tasks/_ordering.py +119 -0
- gobby/storage/tasks/_path_cache.py +110 -0
- gobby/storage/tasks/_queries.py +343 -0
- gobby/storage/tasks/_search.py +143 -0
- gobby/storage/workflow_audit.py +393 -0
- gobby/storage/worktrees.py +547 -0
- gobby/sync/__init__.py +29 -0
- gobby/sync/github.py +333 -0
- gobby/sync/linear.py +304 -0
- gobby/sync/memories.py +284 -0
- gobby/sync/tasks.py +641 -0
- gobby/tasks/__init__.py +8 -0
- gobby/tasks/build_verification.py +193 -0
- gobby/tasks/commits.py +633 -0
- gobby/tasks/context.py +747 -0
- gobby/tasks/criteria.py +342 -0
- gobby/tasks/enhanced_validator.py +226 -0
- gobby/tasks/escalation.py +263 -0
- gobby/tasks/expansion.py +626 -0
- gobby/tasks/external_validator.py +764 -0
- gobby/tasks/issue_extraction.py +171 -0
- gobby/tasks/prompts/expand.py +327 -0
- gobby/tasks/research.py +421 -0
- gobby/tasks/tdd.py +352 -0
- gobby/tasks/tree_builder.py +263 -0
- gobby/tasks/validation.py +712 -0
- gobby/tasks/validation_history.py +357 -0
- gobby/tasks/validation_models.py +89 -0
- gobby/tools/__init__.py +0 -0
- gobby/tools/summarizer.py +170 -0
- gobby/tui/__init__.py +5 -0
- gobby/tui/api_client.py +281 -0
- gobby/tui/app.py +327 -0
- gobby/tui/screens/__init__.py +25 -0
- gobby/tui/screens/agents.py +333 -0
- gobby/tui/screens/chat.py +450 -0
- gobby/tui/screens/dashboard.py +377 -0
- gobby/tui/screens/memory.py +305 -0
- gobby/tui/screens/metrics.py +231 -0
- gobby/tui/screens/orchestrator.py +904 -0
- gobby/tui/screens/sessions.py +412 -0
- gobby/tui/screens/tasks.py +442 -0
- gobby/tui/screens/workflows.py +289 -0
- gobby/tui/screens/worktrees.py +174 -0
- gobby/tui/widgets/__init__.py +21 -0
- gobby/tui/widgets/chat.py +210 -0
- gobby/tui/widgets/conductor.py +104 -0
- gobby/tui/widgets/menu.py +132 -0
- gobby/tui/widgets/message_panel.py +160 -0
- gobby/tui/widgets/review_gate.py +224 -0
- gobby/tui/widgets/task_tree.py +99 -0
- gobby/tui/widgets/token_budget.py +166 -0
- gobby/tui/ws_client.py +258 -0
- gobby/utils/__init__.py +3 -0
- gobby/utils/daemon_client.py +235 -0
- gobby/utils/git.py +222 -0
- gobby/utils/id.py +38 -0
- gobby/utils/json_helpers.py +161 -0
- gobby/utils/logging.py +376 -0
- gobby/utils/machine_id.py +135 -0
- gobby/utils/metrics.py +589 -0
- gobby/utils/project_context.py +182 -0
- gobby/utils/project_init.py +263 -0
- gobby/utils/status.py +256 -0
- gobby/utils/validation.py +80 -0
- gobby/utils/version.py +23 -0
- gobby/workflows/__init__.py +4 -0
- gobby/workflows/actions.py +1310 -0
- gobby/workflows/approval_flow.py +138 -0
- gobby/workflows/artifact_actions.py +103 -0
- gobby/workflows/audit_helpers.py +110 -0
- gobby/workflows/autonomous_actions.py +286 -0
- gobby/workflows/context_actions.py +394 -0
- gobby/workflows/definitions.py +130 -0
- gobby/workflows/detection_helpers.py +208 -0
- gobby/workflows/engine.py +485 -0
- gobby/workflows/evaluator.py +669 -0
- gobby/workflows/git_utils.py +96 -0
- gobby/workflows/hooks.py +169 -0
- gobby/workflows/lifecycle_evaluator.py +613 -0
- gobby/workflows/llm_actions.py +70 -0
- gobby/workflows/loader.py +333 -0
- gobby/workflows/mcp_actions.py +60 -0
- gobby/workflows/memory_actions.py +272 -0
- gobby/workflows/premature_stop.py +164 -0
- gobby/workflows/session_actions.py +139 -0
- gobby/workflows/state_actions.py +123 -0
- gobby/workflows/state_manager.py +104 -0
- gobby/workflows/stop_signal_actions.py +163 -0
- gobby/workflows/summary_actions.py +344 -0
- gobby/workflows/task_actions.py +249 -0
- gobby/workflows/task_enforcement_actions.py +901 -0
- gobby/workflows/templates.py +52 -0
- gobby/workflows/todo_actions.py +84 -0
- gobby/workflows/webhook.py +223 -0
- gobby/workflows/webhook_executor.py +399 -0
- gobby/worktrees/__init__.py +5 -0
- gobby/worktrees/git.py +690 -0
- gobby/worktrees/merge/__init__.py +20 -0
- gobby/worktrees/merge/conflict_parser.py +177 -0
- gobby/worktrees/merge/resolver.py +485 -0
- gobby-0.2.5.dist-info/METADATA +351 -0
- gobby-0.2.5.dist-info/RECORD +383 -0
- gobby-0.2.5.dist-info/WHEEL +5 -0
- gobby-0.2.5.dist-info/entry_points.txt +2 -0
- gobby-0.2.5.dist-info/licenses/LICENSE.md +193 -0
- gobby-0.2.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from .definitions import WorkflowState
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .webhook_executor import WebhookExecutor
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
# Approval keywords (case-insensitive)
|
|
16
|
+
APPROVAL_KEYWORDS = {"yes", "approve", "approved", "proceed", "continue", "ok", "okay", "y"}
|
|
17
|
+
REJECTION_KEYWORDS = {"no", "reject", "rejected", "stop", "cancel", "abort", "n"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_task_complete(task: Any) -> bool:
|
|
21
|
+
"""
|
|
22
|
+
Check if a task counts as complete for workflow purposes.
|
|
23
|
+
|
|
24
|
+
A task is complete if:
|
|
25
|
+
- status is 'closed', OR
|
|
26
|
+
- status is 'review' AND requires_user_review is False
|
|
27
|
+
(agent marked for visibility but doesn't need user sign-off)
|
|
28
|
+
|
|
29
|
+
Tasks in 'review' with requires_user_review=True are NOT complete
|
|
30
|
+
because they're awaiting user approval.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
task: Task object with status and requires_user_review attributes
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
True if task is complete, False otherwise
|
|
37
|
+
"""
|
|
38
|
+
if task.status == "closed":
|
|
39
|
+
return True
|
|
40
|
+
requires_user_review = getattr(task, "requires_user_review", False)
|
|
41
|
+
if task.status == "review" and not requires_user_review:
|
|
42
|
+
return True
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def task_needs_user_review(task_manager: Any, task_id: str | None) -> bool:
|
|
47
|
+
"""
|
|
48
|
+
Check if a task is awaiting user review (in review + HITL flag).
|
|
49
|
+
|
|
50
|
+
Used in workflow transition conditions like:
|
|
51
|
+
when: "task_needs_user_review(variables.session_task)"
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
task_manager: LocalTaskManager instance for querying tasks
|
|
55
|
+
task_id: Task ID to check
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
True if task is in 'review' status AND has requires_user_review=True.
|
|
59
|
+
Returns False if task_id is None or task not found.
|
|
60
|
+
"""
|
|
61
|
+
if not task_id or not task_manager:
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
task = task_manager.get_task(task_id)
|
|
65
|
+
if not task:
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
return bool(task.status == "review" and getattr(task, "requires_user_review", False))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def task_tree_complete(task_manager: Any, task_id: str | list[str] | None) -> bool:
|
|
72
|
+
"""
|
|
73
|
+
Check if a task and all its subtasks are complete.
|
|
74
|
+
|
|
75
|
+
A task is complete if:
|
|
76
|
+
- status is 'closed', OR
|
|
77
|
+
- status is 'review' AND requires_user_review is False
|
|
78
|
+
|
|
79
|
+
Used in workflow transition conditions like:
|
|
80
|
+
when: "task_tree_complete(variables.session_task)"
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
task_manager: LocalTaskManager instance for querying tasks
|
|
84
|
+
task_id: Single task ID, list of task IDs, or None
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
True if all tasks and their subtasks are complete, False otherwise.
|
|
88
|
+
Returns True if task_id is None (no task to check).
|
|
89
|
+
"""
|
|
90
|
+
if not task_id:
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
if not task_manager:
|
|
94
|
+
logger.warning("task_tree_complete: No task_manager available")
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
# Normalize to list
|
|
98
|
+
task_ids = [task_id] if isinstance(task_id, str) else task_id
|
|
99
|
+
|
|
100
|
+
for tid in task_ids:
|
|
101
|
+
# Get the task itself
|
|
102
|
+
task = task_manager.get_task(tid)
|
|
103
|
+
if not task:
|
|
104
|
+
logger.warning(f"task_tree_complete: Task '{tid}' not found")
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
# Check if main task is complete
|
|
108
|
+
if not is_task_complete(task):
|
|
109
|
+
logger.debug(f"task_tree_complete: Task '{tid}' is not complete (status={task.status})")
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
# Check all subtasks recursively
|
|
113
|
+
if not _subtasks_complete(task_manager, tid):
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _subtasks_complete(task_manager: Any, parent_id: str) -> bool:
|
|
120
|
+
"""Recursively check if all subtasks are complete."""
|
|
121
|
+
subtasks = task_manager.list_tasks(parent_task_id=parent_id)
|
|
122
|
+
|
|
123
|
+
for subtask in subtasks:
|
|
124
|
+
if not is_task_complete(subtask):
|
|
125
|
+
logger.debug(
|
|
126
|
+
f"_subtasks_complete: Subtask '{subtask.id}' is not complete (status={subtask.status})"
|
|
127
|
+
)
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
# Recursively check subtasks of this subtask
|
|
131
|
+
if not _subtasks_complete(task_manager, subtask.id):
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
return True
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class ApprovalCheckResult:
|
|
139
|
+
"""Result of checking a user_approval condition."""
|
|
140
|
+
|
|
141
|
+
needs_approval: bool = False # True if we need to request approval
|
|
142
|
+
is_approved: bool = False # True if user approved
|
|
143
|
+
is_rejected: bool = False # True if user rejected
|
|
144
|
+
is_timed_out: bool = False # True if approval timed out
|
|
145
|
+
condition_id: str | None = None # ID of the condition
|
|
146
|
+
prompt: str | None = None # Prompt to show user
|
|
147
|
+
timeout_seconds: int | None = None # Timeout value
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def check_approval_response(user_input: str) -> str | None:
|
|
151
|
+
"""
|
|
152
|
+
Check if user input contains an approval or rejection keyword.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
"approved" if approval keyword found
|
|
156
|
+
"rejected" if rejection keyword found
|
|
157
|
+
None if no keyword found
|
|
158
|
+
"""
|
|
159
|
+
# Normalize input - check if entire input is a keyword or starts with one
|
|
160
|
+
normalized = user_input.strip().lower()
|
|
161
|
+
|
|
162
|
+
# Check exact match first
|
|
163
|
+
if normalized in APPROVAL_KEYWORDS:
|
|
164
|
+
return "approved"
|
|
165
|
+
if normalized in REJECTION_KEYWORDS:
|
|
166
|
+
return "rejected"
|
|
167
|
+
|
|
168
|
+
# Check if starts with keyword (e.g., "yes, let's proceed")
|
|
169
|
+
# Strip common punctuation from first word
|
|
170
|
+
first_word = normalized.split()[0].rstrip(",.!?:;") if normalized else ""
|
|
171
|
+
if first_word in APPROVAL_KEYWORDS:
|
|
172
|
+
return "approved"
|
|
173
|
+
if first_word in REJECTION_KEYWORDS:
|
|
174
|
+
return "rejected"
|
|
175
|
+
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class ConditionEvaluator:
|
|
180
|
+
"""
|
|
181
|
+
Evaluates 'when' conditions in workflows.
|
|
182
|
+
Supports simple boolean logic and variable access.
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
def __init__(self) -> None:
|
|
186
|
+
"""Initialize the condition evaluator."""
|
|
187
|
+
self._plugin_conditions: dict[str, Any] = {}
|
|
188
|
+
self._task_manager: Any = None
|
|
189
|
+
self._stop_registry: Any = None
|
|
190
|
+
self._webhook_executor: WebhookExecutor | None = None
|
|
191
|
+
|
|
192
|
+
def register_task_manager(self, task_manager: Any) -> None:
|
|
193
|
+
"""
|
|
194
|
+
Register a task manager for task-related condition helpers.
|
|
195
|
+
|
|
196
|
+
This enables the task_tree_complete() function in workflow conditions.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
task_manager: LocalTaskManager instance
|
|
200
|
+
"""
|
|
201
|
+
self._task_manager = task_manager
|
|
202
|
+
logger.debug("ConditionEvaluator: task_manager registered")
|
|
203
|
+
|
|
204
|
+
def register_stop_registry(self, stop_registry: Any) -> None:
|
|
205
|
+
"""
|
|
206
|
+
Register a stop registry for stop signal condition helpers.
|
|
207
|
+
|
|
208
|
+
This enables the has_stop_signal() function in workflow conditions.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
stop_registry: StopRegistry instance
|
|
212
|
+
"""
|
|
213
|
+
self._stop_registry = stop_registry
|
|
214
|
+
logger.debug("ConditionEvaluator: stop_registry registered")
|
|
215
|
+
|
|
216
|
+
def register_webhook_executor(self, webhook_executor: WebhookExecutor | None) -> None:
|
|
217
|
+
"""
|
|
218
|
+
Register a webhook executor for webhook condition evaluation.
|
|
219
|
+
|
|
220
|
+
This enables webhook conditions in workflow transitions.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
webhook_executor: WebhookExecutor instance
|
|
224
|
+
"""
|
|
225
|
+
self._webhook_executor = webhook_executor
|
|
226
|
+
logger.debug("ConditionEvaluator: webhook_executor registered")
|
|
227
|
+
|
|
228
|
+
def register_plugin_conditions(self, plugin_registry: Any) -> None:
|
|
229
|
+
"""
|
|
230
|
+
Register conditions from loaded plugins.
|
|
231
|
+
|
|
232
|
+
Conditions are registered with the naming convention:
|
|
233
|
+
plugin_<plugin_name>_<condition_name>
|
|
234
|
+
|
|
235
|
+
These can be called in 'when' clauses like:
|
|
236
|
+
when: "plugin_my_plugin_passes_lint()"
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
plugin_registry: PluginRegistry instance containing loaded plugins.
|
|
240
|
+
"""
|
|
241
|
+
if plugin_registry is None:
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
for plugin_name, plugin in plugin_registry._plugins.items():
|
|
245
|
+
# Sanitize plugin name for use as identifier
|
|
246
|
+
safe_name = plugin_name.replace("-", "_").replace(".", "_")
|
|
247
|
+
for condition_name, evaluator in plugin._conditions.items():
|
|
248
|
+
full_name = f"plugin_{safe_name}_{condition_name}"
|
|
249
|
+
self._plugin_conditions[full_name] = evaluator
|
|
250
|
+
logger.debug(f"Registered plugin condition: {full_name}")
|
|
251
|
+
|
|
252
|
+
def evaluate(self, condition: str, context: dict[str, Any]) -> bool:
|
|
253
|
+
"""
|
|
254
|
+
Evaluate a condition string against a context dictionary.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
condition: The condition string (e.g., "phase_action_count > 5")
|
|
258
|
+
context: Dictionary containing Available variables (state, event, etc.)
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
Boolean result of the evaluation.
|
|
262
|
+
"""
|
|
263
|
+
if not condition:
|
|
264
|
+
return True
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
# SAFETY: Using eval() is risky but standard for this type of flexibility until
|
|
268
|
+
# we implement a proper expression parser. We restrict globals to builtins logic.
|
|
269
|
+
# In a production environment, we should use a safer parser like `simpleeval` or `jinja2`.
|
|
270
|
+
# For this MVP, we rely on the context being controlled.
|
|
271
|
+
|
|
272
|
+
# Simple sanitization/safety check could go here
|
|
273
|
+
|
|
274
|
+
# Allow common helpers
|
|
275
|
+
allowed_globals = {
|
|
276
|
+
"__builtins__": {},
|
|
277
|
+
"len": len,
|
|
278
|
+
"bool": bool,
|
|
279
|
+
"str": str,
|
|
280
|
+
"int": int,
|
|
281
|
+
"list": list,
|
|
282
|
+
"dict": dict,
|
|
283
|
+
"None": None,
|
|
284
|
+
"True": True,
|
|
285
|
+
"False": False,
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
# Add plugin conditions as callable functions
|
|
289
|
+
allowed_globals.update(self._plugin_conditions)
|
|
290
|
+
|
|
291
|
+
# Add task-related helpers (bind task_manager via closure)
|
|
292
|
+
if self._task_manager:
|
|
293
|
+
|
|
294
|
+
def _task_tree_complete_wrapper(task_id: str | list[str] | None) -> bool:
|
|
295
|
+
# Helper wrapper to match types
|
|
296
|
+
return task_tree_complete(self._task_manager, task_id)
|
|
297
|
+
|
|
298
|
+
allowed_globals["task_tree_complete"] = _task_tree_complete_wrapper
|
|
299
|
+
|
|
300
|
+
def _task_needs_user_review_wrapper(task_id: str | None) -> bool:
|
|
301
|
+
# Helper wrapper for HITL check
|
|
302
|
+
return task_needs_user_review(self._task_manager, task_id)
|
|
303
|
+
|
|
304
|
+
allowed_globals["task_needs_user_review"] = _task_needs_user_review_wrapper
|
|
305
|
+
else:
|
|
306
|
+
# Provide no-ops when no task_manager
|
|
307
|
+
allowed_globals["task_tree_complete"] = lambda task_id: True
|
|
308
|
+
allowed_globals["task_needs_user_review"] = lambda task_id: False
|
|
309
|
+
|
|
310
|
+
# Add stop signal helpers (bind stop_registry via closure)
|
|
311
|
+
if self._stop_registry:
|
|
312
|
+
allowed_globals["has_stop_signal"] = lambda session_id: (
|
|
313
|
+
self._stop_registry.has_pending_signal(session_id)
|
|
314
|
+
)
|
|
315
|
+
else:
|
|
316
|
+
# Provide a no-op that returns False when no stop_registry
|
|
317
|
+
allowed_globals["has_stop_signal"] = lambda session_id: False
|
|
318
|
+
|
|
319
|
+
# Add MCP call tracking helper (for meeseeks workflow gates)
|
|
320
|
+
def _mcp_called(server: str, tool: str | None = None) -> bool:
|
|
321
|
+
"""Check if MCP tool was called successfully.
|
|
322
|
+
|
|
323
|
+
Used in workflow conditions like:
|
|
324
|
+
when: "mcp_called('gobby-memory', 'recall')"
|
|
325
|
+
when: "mcp_called('context7')" # Any tool on server
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
server: MCP server name (e.g., "gobby-memory", "context7")
|
|
329
|
+
tool: Optional specific tool name (e.g., "recall", "remember")
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
True if the server (and optionally tool) was called.
|
|
333
|
+
"""
|
|
334
|
+
variables = context.get("variables", {})
|
|
335
|
+
if isinstance(variables, dict):
|
|
336
|
+
mcp_calls = variables.get("mcp_calls", {})
|
|
337
|
+
else:
|
|
338
|
+
# SimpleNamespace from workflow engine
|
|
339
|
+
mcp_calls = getattr(variables, "mcp_calls", {})
|
|
340
|
+
|
|
341
|
+
# Ensure mcp_calls is a dict (could be None or other type)
|
|
342
|
+
if not isinstance(mcp_calls, dict):
|
|
343
|
+
mcp_calls = {}
|
|
344
|
+
|
|
345
|
+
if tool:
|
|
346
|
+
return tool in mcp_calls.get(server, [])
|
|
347
|
+
return bool(mcp_calls.get(server))
|
|
348
|
+
|
|
349
|
+
allowed_globals["mcp_called"] = _mcp_called
|
|
350
|
+
|
|
351
|
+
# eval used with restricted allowed_globals for workflow conditions
|
|
352
|
+
# nosec B307: eval is intentional here for DSL evaluation with
|
|
353
|
+
# restricted globals (__builtins__={}) and controlled workflow conditions
|
|
354
|
+
return bool(eval(condition, allowed_globals, context)) # nosec B307
|
|
355
|
+
except Exception as e:
|
|
356
|
+
logger.warning(f"Condition evaluation failed: '{condition}'. Error: {e}")
|
|
357
|
+
return False
|
|
358
|
+
|
|
359
|
+
def check_exit_conditions(self, conditions: list[dict[str, Any]], state: WorkflowState) -> bool:
|
|
360
|
+
"""
|
|
361
|
+
Check if all exit conditions are met. (AND logic)
|
|
362
|
+
"""
|
|
363
|
+
context = {
|
|
364
|
+
"workflow_state": state,
|
|
365
|
+
"state": state, # alias
|
|
366
|
+
# Flatten state for easier access
|
|
367
|
+
"step_action_count": state.step_action_count,
|
|
368
|
+
"total_action_count": state.total_action_count,
|
|
369
|
+
"variables": state.variables,
|
|
370
|
+
"task_list": state.task_list,
|
|
371
|
+
}
|
|
372
|
+
# Add variables safely to avoid shadowing internal context keys
|
|
373
|
+
for key, value in state.variables.items():
|
|
374
|
+
if key in context:
|
|
375
|
+
# Log warning or namespace? For now just skip or simple duplicate warn
|
|
376
|
+
logger.debug(
|
|
377
|
+
f"Variable '{key}' shadows internal context key, skipping direct merge"
|
|
378
|
+
)
|
|
379
|
+
continue
|
|
380
|
+
context[key] = value
|
|
381
|
+
|
|
382
|
+
for condition in conditions:
|
|
383
|
+
cond_type = condition.get("type")
|
|
384
|
+
|
|
385
|
+
if cond_type == "variable_set":
|
|
386
|
+
var_name = condition.get("variable")
|
|
387
|
+
if not var_name or var_name not in state.variables:
|
|
388
|
+
return False
|
|
389
|
+
|
|
390
|
+
elif cond_type == "user_approval":
|
|
391
|
+
# User approval condition - check if approval has been granted
|
|
392
|
+
condition_id = condition.get("id", f"approval_{hash(str(condition)) % 10000}")
|
|
393
|
+
approved_var = f"_approval_{condition_id}_granted"
|
|
394
|
+
|
|
395
|
+
# Check if this specific approval has been granted
|
|
396
|
+
if not state.variables.get(approved_var, False):
|
|
397
|
+
return False
|
|
398
|
+
|
|
399
|
+
elif cond_type == "expression":
|
|
400
|
+
expr = condition.get("expression")
|
|
401
|
+
if expr and not self.evaluate(expr, context):
|
|
402
|
+
return False
|
|
403
|
+
|
|
404
|
+
elif cond_type == "webhook":
|
|
405
|
+
# Webhook condition - check pre-evaluated result stored in variables
|
|
406
|
+
# The async evaluate_webhook_conditions method must be called first
|
|
407
|
+
condition_id = condition.get("id", f"webhook_{hash(str(condition)) % 10000}")
|
|
408
|
+
result_var = f"_webhook_{condition_id}_result"
|
|
409
|
+
|
|
410
|
+
# Get pre-evaluated webhook result from state
|
|
411
|
+
webhook_result = state.variables.get(result_var)
|
|
412
|
+
if webhook_result is None:
|
|
413
|
+
# Webhook hasn't been evaluated yet
|
|
414
|
+
logger.warning(
|
|
415
|
+
f"Webhook condition '{condition_id}' not pre-evaluated. "
|
|
416
|
+
"Call evaluate_webhook_conditions() first."
|
|
417
|
+
)
|
|
418
|
+
return False
|
|
419
|
+
|
|
420
|
+
# Check based on configured criteria
|
|
421
|
+
if not self._check_webhook_result(condition, webhook_result):
|
|
422
|
+
return False
|
|
423
|
+
|
|
424
|
+
return True
|
|
425
|
+
|
|
426
|
+
def _check_webhook_result(self, condition: dict[str, Any], result: dict[str, Any]) -> bool:
|
|
427
|
+
"""Check if webhook result matches the condition criteria.
|
|
428
|
+
|
|
429
|
+
Args:
|
|
430
|
+
condition: Webhook condition configuration
|
|
431
|
+
result: Pre-evaluated webhook result stored in state
|
|
432
|
+
|
|
433
|
+
Returns:
|
|
434
|
+
True if condition is satisfied
|
|
435
|
+
"""
|
|
436
|
+
if not isinstance(result, dict):
|
|
437
|
+
return False
|
|
438
|
+
|
|
439
|
+
# Check success (default: require success)
|
|
440
|
+
expect_success = condition.get("expect_success", True)
|
|
441
|
+
if expect_success and not result.get("success", False):
|
|
442
|
+
return False
|
|
443
|
+
if not expect_success and result.get("success", False):
|
|
444
|
+
return False
|
|
445
|
+
|
|
446
|
+
# Check status code if specified
|
|
447
|
+
expected_status = condition.get("status_code")
|
|
448
|
+
if expected_status is not None:
|
|
449
|
+
actual_status = result.get("status_code")
|
|
450
|
+
if isinstance(expected_status, list):
|
|
451
|
+
if actual_status not in expected_status:
|
|
452
|
+
return False
|
|
453
|
+
elif actual_status != expected_status:
|
|
454
|
+
return False
|
|
455
|
+
|
|
456
|
+
# Check body contains string if specified
|
|
457
|
+
body_contains = condition.get("body_contains")
|
|
458
|
+
if body_contains:
|
|
459
|
+
body = result.get("body", "")
|
|
460
|
+
if body_contains not in body:
|
|
461
|
+
return False
|
|
462
|
+
|
|
463
|
+
# Check JSON body field if specified (dot notation: "data.approved")
|
|
464
|
+
json_field = condition.get("json_field")
|
|
465
|
+
if json_field:
|
|
466
|
+
json_body = result.get("json_body", {})
|
|
467
|
+
expected_value = condition.get("json_value")
|
|
468
|
+
actual_value = self._get_nested_value(json_body, json_field)
|
|
469
|
+
|
|
470
|
+
if expected_value is not None:
|
|
471
|
+
if actual_value != expected_value:
|
|
472
|
+
return False
|
|
473
|
+
else:
|
|
474
|
+
# Just check field exists and is truthy
|
|
475
|
+
if not actual_value:
|
|
476
|
+
return False
|
|
477
|
+
|
|
478
|
+
return True
|
|
479
|
+
|
|
480
|
+
def _get_nested_value(self, obj: dict[str, Any], path: str) -> Any:
|
|
481
|
+
"""Get a nested value from a dict using dot notation.
|
|
482
|
+
|
|
483
|
+
Args:
|
|
484
|
+
obj: Dictionary to traverse
|
|
485
|
+
path: Dot-separated path (e.g., "data.user.name")
|
|
486
|
+
|
|
487
|
+
Returns:
|
|
488
|
+
Value at path, or None if not found
|
|
489
|
+
"""
|
|
490
|
+
parts = path.split(".")
|
|
491
|
+
current: Any = obj
|
|
492
|
+
for part in parts:
|
|
493
|
+
if not isinstance(current, dict):
|
|
494
|
+
return None
|
|
495
|
+
current = current.get(part)
|
|
496
|
+
if current is None:
|
|
497
|
+
return None
|
|
498
|
+
return current
|
|
499
|
+
|
|
500
|
+
def check_pending_approval(
|
|
501
|
+
self, conditions: list[dict[str, Any]], state: WorkflowState
|
|
502
|
+
) -> ApprovalCheckResult | None:
|
|
503
|
+
"""
|
|
504
|
+
Check if any user_approval condition needs attention.
|
|
505
|
+
|
|
506
|
+
Returns:
|
|
507
|
+
ApprovalCheckResult if there's an approval condition that needs handling,
|
|
508
|
+
None if no approval conditions or all are already granted.
|
|
509
|
+
"""
|
|
510
|
+
for condition in conditions:
|
|
511
|
+
if condition.get("type") != "user_approval":
|
|
512
|
+
continue
|
|
513
|
+
|
|
514
|
+
condition_id = condition.get("id", f"approval_{hash(str(condition)) % 10000}")
|
|
515
|
+
approved_var = f"_approval_{condition_id}_granted"
|
|
516
|
+
rejected_var = f"_approval_{condition_id}_rejected"
|
|
517
|
+
|
|
518
|
+
# Check if already approved
|
|
519
|
+
if state.variables.get(approved_var, False):
|
|
520
|
+
continue
|
|
521
|
+
|
|
522
|
+
# Check if rejected
|
|
523
|
+
if state.variables.get(rejected_var, False):
|
|
524
|
+
return ApprovalCheckResult(
|
|
525
|
+
is_rejected=True,
|
|
526
|
+
condition_id=condition_id,
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
# Check timeout if approval is pending
|
|
530
|
+
timeout = condition.get("timeout")
|
|
531
|
+
if state.approval_pending and state.approval_condition_id == condition_id:
|
|
532
|
+
if timeout and state.approval_requested_at:
|
|
533
|
+
elapsed = (datetime.now(UTC) - state.approval_requested_at).total_seconds()
|
|
534
|
+
if elapsed > timeout:
|
|
535
|
+
return ApprovalCheckResult(
|
|
536
|
+
is_timed_out=True,
|
|
537
|
+
condition_id=condition_id,
|
|
538
|
+
timeout_seconds=timeout,
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
# Need to request approval
|
|
542
|
+
prompt = condition.get("prompt", "Do you approve this action? (yes/no)")
|
|
543
|
+
return ApprovalCheckResult(
|
|
544
|
+
needs_approval=True,
|
|
545
|
+
condition_id=condition_id,
|
|
546
|
+
prompt=prompt,
|
|
547
|
+
timeout_seconds=timeout,
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
return None
|
|
551
|
+
|
|
552
|
+
async def evaluate_webhook_conditions(
|
|
553
|
+
self, conditions: list[dict[str, Any]], state: WorkflowState
|
|
554
|
+
) -> dict[str, Any]:
|
|
555
|
+
"""
|
|
556
|
+
Pre-evaluate webhook conditions and store results in state variables.
|
|
557
|
+
|
|
558
|
+
This async method must be called before check_exit_conditions() for
|
|
559
|
+
workflows that include webhook conditions. Results are stored in
|
|
560
|
+
state.variables with keys like "_webhook_<id>_result".
|
|
561
|
+
|
|
562
|
+
Args:
|
|
563
|
+
conditions: List of condition dicts from workflow definition
|
|
564
|
+
state: Current workflow state (will be modified)
|
|
565
|
+
|
|
566
|
+
Returns:
|
|
567
|
+
Dict with evaluation summary:
|
|
568
|
+
- evaluated: Number of webhook conditions evaluated
|
|
569
|
+
- results: Dict mapping condition_id to webhook result
|
|
570
|
+
- errors: List of any errors encountered
|
|
571
|
+
|
|
572
|
+
Example webhook condition config:
|
|
573
|
+
{
|
|
574
|
+
"type": "webhook",
|
|
575
|
+
"id": "approval_check",
|
|
576
|
+
"url": "https://api.example.com/approve",
|
|
577
|
+
"method": "POST", # Optional, default POST
|
|
578
|
+
"headers": {"Authorization": "Bearer ${secrets.API_KEY}"},
|
|
579
|
+
"payload": {"session_id": "{{ session_id }}"},
|
|
580
|
+
"timeout": 30, # Optional, default 30s
|
|
581
|
+
"expect_success": true, # Check response is 2xx
|
|
582
|
+
"status_code": 200, # Or [200, 201] for multiple
|
|
583
|
+
"body_contains": "approved", # Check body contains string
|
|
584
|
+
"json_field": "data.approved", # Check JSON field
|
|
585
|
+
"json_value": true, # Expected value (optional)
|
|
586
|
+
"store_as": "approval_response" # Store full result in variable
|
|
587
|
+
}
|
|
588
|
+
"""
|
|
589
|
+
if not self._webhook_executor:
|
|
590
|
+
logger.warning("No webhook_executor registered for condition evaluation")
|
|
591
|
+
return {"evaluated": 0, "results": {}, "errors": ["No webhook executor"]}
|
|
592
|
+
|
|
593
|
+
evaluated = 0
|
|
594
|
+
results: dict[str, dict[str, Any]] = {}
|
|
595
|
+
errors: list[str] = []
|
|
596
|
+
|
|
597
|
+
for condition in conditions:
|
|
598
|
+
if condition.get("type") != "webhook":
|
|
599
|
+
continue
|
|
600
|
+
|
|
601
|
+
condition_id = condition.get("id", f"webhook_{hash(str(condition)) % 10000}")
|
|
602
|
+
|
|
603
|
+
try:
|
|
604
|
+
# Execute the webhook
|
|
605
|
+
webhook_result = await self._webhook_executor.execute(
|
|
606
|
+
url=condition.get("url", ""),
|
|
607
|
+
method=condition.get("method", "POST"),
|
|
608
|
+
headers=condition.get("headers"),
|
|
609
|
+
payload=condition.get("payload"),
|
|
610
|
+
timeout=condition.get("timeout", 30),
|
|
611
|
+
context={
|
|
612
|
+
"session_id": state.session_id,
|
|
613
|
+
"workflow_name": state.workflow_name,
|
|
614
|
+
"step": state.step,
|
|
615
|
+
"variables": state.variables,
|
|
616
|
+
},
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
# Convert result to storable dict
|
|
620
|
+
try:
|
|
621
|
+
json_body = webhook_result.json_body()
|
|
622
|
+
except Exception:
|
|
623
|
+
json_body = None
|
|
624
|
+
|
|
625
|
+
result_dict: dict[str, Any] = {
|
|
626
|
+
"success": webhook_result.success,
|
|
627
|
+
"status_code": webhook_result.status_code,
|
|
628
|
+
"body": webhook_result.body,
|
|
629
|
+
"error": webhook_result.error,
|
|
630
|
+
"json_body": json_body,
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
# Store result in state variables
|
|
634
|
+
result_var = f"_webhook_{condition_id}_result"
|
|
635
|
+
state.variables[result_var] = result_dict
|
|
636
|
+
|
|
637
|
+
# Also store in named variable if specified
|
|
638
|
+
store_as = condition.get("store_as")
|
|
639
|
+
if store_as:
|
|
640
|
+
state.variables[store_as] = result_dict
|
|
641
|
+
|
|
642
|
+
results[condition_id] = result_dict
|
|
643
|
+
evaluated += 1
|
|
644
|
+
|
|
645
|
+
logger.debug(
|
|
646
|
+
f"Webhook condition '{condition_id}' evaluated: "
|
|
647
|
+
f"status={webhook_result.status_code}, success={webhook_result.success}"
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
except Exception as e:
|
|
651
|
+
error_msg = f"Webhook condition '{condition_id}' failed: {e}"
|
|
652
|
+
logger.error(error_msg)
|
|
653
|
+
errors.append(error_msg)
|
|
654
|
+
|
|
655
|
+
# Store error result
|
|
656
|
+
result_var = f"_webhook_{condition_id}_result"
|
|
657
|
+
state.variables[result_var] = {
|
|
658
|
+
"success": False,
|
|
659
|
+
"status_code": None,
|
|
660
|
+
"body": None,
|
|
661
|
+
"error": str(e),
|
|
662
|
+
"json_body": None,
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
return {
|
|
666
|
+
"evaluated": evaluated,
|
|
667
|
+
"results": results,
|
|
668
|
+
"errors": errors,
|
|
669
|
+
}
|