flowly-code 1.0.0__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.
Files changed (86) hide show
  1. flowly_code/__init__.py +30 -0
  2. flowly_code/__main__.py +8 -0
  3. flowly_code/activity/__init__.py +1 -0
  4. flowly_code/activity/bus.py +91 -0
  5. flowly_code/activity/events.py +40 -0
  6. flowly_code/agent/__init__.py +8 -0
  7. flowly_code/agent/context.py +485 -0
  8. flowly_code/agent/loop.py +1349 -0
  9. flowly_code/agent/memory.py +109 -0
  10. flowly_code/agent/skills.py +259 -0
  11. flowly_code/agent/subagent.py +249 -0
  12. flowly_code/agent/tools/__init__.py +6 -0
  13. flowly_code/agent/tools/base.py +55 -0
  14. flowly_code/agent/tools/delegate.py +194 -0
  15. flowly_code/agent/tools/dispatch.py +840 -0
  16. flowly_code/agent/tools/docker.py +609 -0
  17. flowly_code/agent/tools/filesystem.py +280 -0
  18. flowly_code/agent/tools/mcp.py +85 -0
  19. flowly_code/agent/tools/message.py +235 -0
  20. flowly_code/agent/tools/registry.py +257 -0
  21. flowly_code/agent/tools/screenshot.py +444 -0
  22. flowly_code/agent/tools/shell.py +166 -0
  23. flowly_code/agent/tools/spawn.py +65 -0
  24. flowly_code/agent/tools/system.py +917 -0
  25. flowly_code/agent/tools/trello.py +420 -0
  26. flowly_code/agent/tools/web.py +139 -0
  27. flowly_code/agent/tools/x.py +399 -0
  28. flowly_code/bus/__init__.py +6 -0
  29. flowly_code/bus/events.py +37 -0
  30. flowly_code/bus/queue.py +81 -0
  31. flowly_code/channels/__init__.py +6 -0
  32. flowly_code/channels/base.py +121 -0
  33. flowly_code/channels/manager.py +135 -0
  34. flowly_code/channels/telegram.py +1132 -0
  35. flowly_code/cli/__init__.py +1 -0
  36. flowly_code/cli/commands.py +1831 -0
  37. flowly_code/cli/setup.py +1356 -0
  38. flowly_code/compaction/__init__.py +39 -0
  39. flowly_code/compaction/estimator.py +88 -0
  40. flowly_code/compaction/pruning.py +223 -0
  41. flowly_code/compaction/service.py +297 -0
  42. flowly_code/compaction/summarizer.py +384 -0
  43. flowly_code/compaction/types.py +71 -0
  44. flowly_code/config/__init__.py +6 -0
  45. flowly_code/config/loader.py +102 -0
  46. flowly_code/config/schema.py +324 -0
  47. flowly_code/exec/__init__.py +39 -0
  48. flowly_code/exec/approvals.py +288 -0
  49. flowly_code/exec/executor.py +184 -0
  50. flowly_code/exec/safety.py +247 -0
  51. flowly_code/exec/types.py +88 -0
  52. flowly_code/gateway/__init__.py +5 -0
  53. flowly_code/gateway/server.py +103 -0
  54. flowly_code/heartbeat/__init__.py +5 -0
  55. flowly_code/heartbeat/service.py +130 -0
  56. flowly_code/multiagent/README.md +248 -0
  57. flowly_code/multiagent/__init__.py +1 -0
  58. flowly_code/multiagent/invoke.py +210 -0
  59. flowly_code/multiagent/orchestrator.py +156 -0
  60. flowly_code/multiagent/router.py +156 -0
  61. flowly_code/multiagent/setup.py +171 -0
  62. flowly_code/pairing/__init__.py +21 -0
  63. flowly_code/pairing/store.py +343 -0
  64. flowly_code/providers/__init__.py +6 -0
  65. flowly_code/providers/base.py +69 -0
  66. flowly_code/providers/litellm_provider.py +178 -0
  67. flowly_code/providers/transcription.py +64 -0
  68. flowly_code/session/__init__.py +5 -0
  69. flowly_code/session/manager.py +249 -0
  70. flowly_code/skills/README.md +24 -0
  71. flowly_code/skills/compact/SKILL.md +27 -0
  72. flowly_code/skills/github/SKILL.md +48 -0
  73. flowly_code/skills/skill-creator/SKILL.md +371 -0
  74. flowly_code/skills/summarize/SKILL.md +67 -0
  75. flowly_code/skills/tmux/SKILL.md +121 -0
  76. flowly_code/skills/tmux/scripts/find-sessions.sh +112 -0
  77. flowly_code/skills/tmux/scripts/wait-for-text.sh +83 -0
  78. flowly_code/skills/weather/SKILL.md +49 -0
  79. flowly_code/utils/__init__.py +5 -0
  80. flowly_code/utils/helpers.py +91 -0
  81. flowly_code-1.0.0.dist-info/METADATA +724 -0
  82. flowly_code-1.0.0.dist-info/RECORD +86 -0
  83. flowly_code-1.0.0.dist-info/WHEEL +4 -0
  84. flowly_code-1.0.0.dist-info/entry_points.txt +2 -0
  85. flowly_code-1.0.0.dist-info/licenses/LICENSE +191 -0
  86. flowly_code-1.0.0.dist-info/licenses/NOTICE +74 -0
@@ -0,0 +1,184 @@
1
+ """Command executor with security checks."""
2
+
3
+ import asyncio
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from loguru import logger
9
+
10
+ from flowly_code.exec.types import (
11
+ ExecRequest,
12
+ ExecResult,
13
+ ExecConfig,
14
+ )
15
+ from flowly_code.exec.safety import analyze_command
16
+ from flowly_code.exec.approvals import (
17
+ ExecApprovalStore,
18
+ check_allowlist,
19
+ requires_approval,
20
+ )
21
+
22
+
23
+ async def execute_command(
24
+ request: ExecRequest,
25
+ config: ExecConfig,
26
+ store: ExecApprovalStore,
27
+ ) -> ExecResult:
28
+ """
29
+ Execute a command with full security checks.
30
+
31
+ Security flow:
32
+ 1. Check if exec is enabled
33
+ 2. Analyze command for safety
34
+ 3. Check security mode (deny/allowlist/full)
35
+ 4. Check allowlist if in allowlist mode
36
+ 5. Request approval if needed
37
+ 6. Execute command with timeout
38
+ """
39
+ # Check if enabled
40
+ if not config.enabled:
41
+ return ExecResult(
42
+ success=False,
43
+ denied=True,
44
+ error="Command execution is disabled"
45
+ )
46
+
47
+ # Analyze command
48
+ analysis = analyze_command(request.command)
49
+
50
+ # Check for dangerous patterns
51
+ if analysis.has_dangerous_chars:
52
+ return ExecResult(
53
+ success=False,
54
+ denied=True,
55
+ error=f"Command rejected: {analysis.reason}"
56
+ )
57
+
58
+ # Get store config
59
+ store_config = store.config
60
+
61
+ # Security mode check
62
+ if store_config.security == "deny":
63
+ return ExecResult(
64
+ success=False,
65
+ denied=True,
66
+ error="Command execution denied by security policy"
67
+ )
68
+
69
+ # Allowlist mode
70
+ if store_config.security == "allowlist":
71
+ allowlist_ok = check_allowlist(store, analysis.resolved_path, analysis.executable)
72
+
73
+ # Check if approval is required
74
+ if requires_approval(store_config, analysis.ok, allowlist_ok):
75
+ # Create pending approval
76
+ pending = store.create_pending(request, config.approval_timeout_seconds)
77
+
78
+ # Request approval via callback (Telegram)
79
+ decision = await store.request_approval(pending)
80
+
81
+ if decision is None:
82
+ # Timeout or no callback
83
+ return ExecResult(
84
+ success=False,
85
+ denied=True,
86
+ error="Approval timed out or not available"
87
+ )
88
+
89
+ if decision == "deny":
90
+ store.resolve_pending(pending.id, decision)
91
+ return ExecResult(
92
+ success=False,
93
+ denied=True,
94
+ error="Command denied by user"
95
+ )
96
+
97
+ # Allow-once or allow-always
98
+ store.resolve_pending(pending.id, decision)
99
+
100
+ elif not allowlist_ok and not analysis.is_safe_bin:
101
+ # Not in allowlist and not a safe bin
102
+ return ExecResult(
103
+ success=False,
104
+ denied=True,
105
+ error=f"Command not in allowlist: {analysis.executable}"
106
+ )
107
+
108
+ # Execute the command
109
+ timeout = request.timeout or config.timeout_seconds
110
+ cwd = request.cwd or str(Path.home())
111
+
112
+ try:
113
+ # Build environment
114
+ env = None
115
+ if request.env:
116
+ import os
117
+ env = os.environ.copy()
118
+ env.update(request.env)
119
+
120
+ # Background mode: start process and return immediately with PID
121
+ if request.background:
122
+ process = await asyncio.create_subprocess_shell(
123
+ request.command,
124
+ stdout=asyncio.subprocess.DEVNULL,
125
+ stderr=asyncio.subprocess.DEVNULL,
126
+ cwd=cwd,
127
+ env=env,
128
+ start_new_session=True,
129
+ )
130
+ logger.info(f"Background process started: PID={process.pid}, cmd={request.command[:80]}")
131
+ return ExecResult(
132
+ success=True,
133
+ pid=process.pid,
134
+ stdout=f"Process started in background (PID: {process.pid})",
135
+ )
136
+
137
+ # Foreground mode: run and wait for completion
138
+ process = await asyncio.create_subprocess_shell(
139
+ request.command,
140
+ stdout=asyncio.subprocess.PIPE,
141
+ stderr=asyncio.subprocess.PIPE,
142
+ cwd=cwd,
143
+ env=env,
144
+ )
145
+
146
+ try:
147
+ stdout, stderr = await asyncio.wait_for(
148
+ process.communicate(),
149
+ timeout=timeout
150
+ )
151
+ except asyncio.TimeoutError:
152
+ process.kill()
153
+ await process.wait()
154
+ return ExecResult(
155
+ success=False,
156
+ exit_code=-1,
157
+ error=f"Command timed out after {timeout} seconds",
158
+ timed_out=True
159
+ )
160
+
161
+ # Decode output
162
+ stdout_str = stdout.decode('utf-8', errors='replace')
163
+ stderr_str = stderr.decode('utf-8', errors='replace')
164
+
165
+ # Truncate if too long
166
+ max_output = config.max_output_chars
167
+ if len(stdout_str) > max_output:
168
+ stdout_str = stdout_str[:max_output] + f"\n... (truncated, {len(stdout_str)} total chars)"
169
+ if len(stderr_str) > max_output:
170
+ stderr_str = stderr_str[:max_output] + f"\n... (truncated, {len(stderr_str)} total chars)"
171
+
172
+ return ExecResult(
173
+ success=process.returncode == 0,
174
+ exit_code=process.returncode,
175
+ stdout=stdout_str,
176
+ stderr=stderr_str,
177
+ )
178
+
179
+ except Exception as e:
180
+ logger.error(f"Command execution error: {e}")
181
+ return ExecResult(
182
+ success=False,
183
+ error=str(e)
184
+ )
@@ -0,0 +1,247 @@
1
+ """Command safety analysis and validation."""
2
+
3
+ import platform
4
+ import re
5
+ import shlex
6
+ import shutil
7
+ from pathlib import Path
8
+
9
+ from flowly_code.exec.types import CommandAnalysis
10
+
11
+ # Safe binaries that only operate on stdin (no file args)
12
+ DEFAULT_SAFE_BINS = frozenset([
13
+ "jq", "grep", "cut", "sort", "uniq", "head", "tail", "tr", "wc",
14
+ "cat", "echo", "date", "whoami", "pwd", "hostname", "uname",
15
+ ])
16
+
17
+ # Dangerous shell metacharacters
18
+ SHELL_METACHARS = re.compile(r'[;&|`$<>]')
19
+ CONTROL_CHARS = re.compile(r'[\r\n\x00]')
20
+ QUOTE_CHARS = re.compile(r'["\']')
21
+
22
+ # Patterns for dangerous commands (Unix)
23
+ _UNIX_DANGEROUS_PATTERNS = [
24
+ re.compile(r'\brm\s+(-[rf]+\s+)*/', re.IGNORECASE), # rm -rf /
25
+ re.compile(r'\bsudo\b', re.IGNORECASE),
26
+ re.compile(r'\bchmod\s+777', re.IGNORECASE),
27
+ re.compile(r'\bchown\b.*root', re.IGNORECASE),
28
+ re.compile(r'\bmkfs\b', re.IGNORECASE),
29
+ re.compile(r'\bdd\b.*of=/', re.IGNORECASE),
30
+ re.compile(r'>\s*/dev/', re.IGNORECASE),
31
+ re.compile(r'\bcurl\b.*\|\s*(ba)?sh', re.IGNORECASE), # curl | sh
32
+ re.compile(r'\bwget\b.*\|\s*(ba)?sh', re.IGNORECASE), # wget | sh
33
+ re.compile(r':(){.*};:', re.IGNORECASE), # Fork bomb
34
+ ]
35
+
36
+ # Patterns for dangerous commands (Windows)
37
+ _WINDOWS_DANGEROUS_PATTERNS = [
38
+ re.compile(r'\bformat\b\s+[a-z]:', re.IGNORECASE), # format C:
39
+ re.compile(r'\bdiskpart\b', re.IGNORECASE),
40
+ re.compile(r'\breg\b\s+delete', re.IGNORECASE), # reg delete
41
+ re.compile(r'\bcipher\b\s+/w', re.IGNORECASE), # cipher /w (wipe)
42
+ re.compile(r'\bdel\b\s+/[sfq]', re.IGNORECASE), # del /s /f /q
43
+ re.compile(r'\brd\b\s+/s', re.IGNORECASE), # rd /s (recursive delete)
44
+ re.compile(r'\brmdir\b\s+/s', re.IGNORECASE), # rmdir /s
45
+ re.compile(r'\bnet\b\s+user\b.*\b/delete\b', re.IGNORECASE), # net user /delete
46
+ re.compile(r'\bbcdedit\b', re.IGNORECASE), # boot config
47
+ re.compile(r'\brunas\b\s+/user:administrator', re.IGNORECASE),
48
+ ]
49
+
50
+ DANGEROUS_PATTERNS = (
51
+ _UNIX_DANGEROUS_PATTERNS + _WINDOWS_DANGEROUS_PATTERNS
52
+ if platform.system() == "Windows"
53
+ else _UNIX_DANGEROUS_PATTERNS
54
+ )
55
+
56
+ # Pipeline operators that are not allowed in allowlist mode
57
+ DISALLOWED_PIPELINE_OPS = {'||', '|&', '`', '$(', '\n', '\r', '(', ')'}
58
+
59
+
60
+ def is_safe_executable(value: str | None) -> bool:
61
+ """Check if a string is safe to use as an executable name."""
62
+ if not value:
63
+ return False
64
+
65
+ trimmed = value.strip()
66
+ if not trimmed:
67
+ return False
68
+
69
+ # Check for dangerous characters
70
+ if '\0' in trimmed:
71
+ return False
72
+ if CONTROL_CHARS.search(trimmed):
73
+ return False
74
+ if SHELL_METACHARS.search(trimmed):
75
+ return False
76
+
77
+ return True
78
+
79
+
80
+ def resolve_executable(name: str) -> str | None:
81
+ """Resolve an executable name to its full path."""
82
+ import os
83
+
84
+ # If it's already a path (check both Unix and Windows separators)
85
+ if '/' in name or '\\' in name or os.sep in name:
86
+ path = Path(name).expanduser().resolve()
87
+ if path.exists() and path.is_file():
88
+ return str(path)
89
+ return None
90
+
91
+ # Use shutil.which to find in PATH
92
+ return shutil.which(name)
93
+
94
+
95
+ def is_safe_bin(executable: str, args: list[str]) -> bool:
96
+ """Check if command is a safe stdin-only binary with safe args."""
97
+ import os
98
+
99
+ # Get basename (handle both Unix and Windows paths)
100
+ name = Path(executable).name if ('/' in executable or '\\' in executable or os.sep in executable) else executable
101
+
102
+ if name not in DEFAULT_SAFE_BINS:
103
+ return False
104
+
105
+ # Check args don't reference files
106
+ for arg in args:
107
+ # Skip flags
108
+ if arg.startswith('-'):
109
+ continue
110
+ # Check if arg looks like a path
111
+ if '/' in arg or '\\' in arg or arg.startswith('~'):
112
+ return False
113
+ # Check if arg is an existing file
114
+ if Path(arg).exists():
115
+ return False
116
+
117
+ return True
118
+
119
+
120
+ def has_dangerous_pattern(command: str) -> bool:
121
+ """Check if command matches any dangerous patterns."""
122
+ for pattern in DANGEROUS_PATTERNS:
123
+ if pattern.search(command):
124
+ return True
125
+ return False
126
+
127
+
128
+ def split_pipeline(command: str) -> tuple[bool, str | None, list[str]]:
129
+ """
130
+ Split a command into pipeline segments.
131
+
132
+ Returns (ok, reason, segments).
133
+ """
134
+ # Check for disallowed operators
135
+ for op in DISALLOWED_PIPELINE_OPS:
136
+ if op in command:
137
+ return False, f"Disallowed operator: {op}", []
138
+
139
+ # Check for command substitution
140
+ if '$(' in command or '`' in command:
141
+ return False, "Command substitution not allowed", []
142
+
143
+ # Check for redirection
144
+ if re.search(r'[<>]', command):
145
+ return False, "Redirection not allowed in allowlist mode", []
146
+
147
+ # Split by pipe
148
+ segments = [s.strip() for s in command.split('|')]
149
+
150
+ # Validate each segment
151
+ for seg in segments:
152
+ if not seg:
153
+ return False, "Empty pipeline segment", []
154
+
155
+ return True, None, segments
156
+
157
+
158
+ def parse_command(command: str) -> tuple[str | None, list[str]]:
159
+ """Parse a command into executable and arguments."""
160
+ try:
161
+ parts = shlex.split(command)
162
+ if not parts:
163
+ return None, []
164
+ return parts[0], parts[1:]
165
+ except ValueError:
166
+ return None, []
167
+
168
+
169
+ def analyze_command(command: str) -> CommandAnalysis:
170
+ """
171
+ Analyze a shell command for safety.
172
+
173
+ Returns a CommandAnalysis with details about the command.
174
+ """
175
+ command = command.strip()
176
+
177
+ if not command:
178
+ return CommandAnalysis(ok=False, reason="Empty command")
179
+
180
+ # Check for control characters
181
+ if CONTROL_CHARS.search(command):
182
+ return CommandAnalysis(
183
+ ok=False,
184
+ reason="Control characters not allowed",
185
+ has_dangerous_chars=True
186
+ )
187
+
188
+ # Check for dangerous patterns
189
+ if has_dangerous_pattern(command):
190
+ return CommandAnalysis(
191
+ ok=False,
192
+ reason="Command matches dangerous pattern",
193
+ has_dangerous_chars=True
194
+ )
195
+
196
+ # Check if it's a pipeline
197
+ is_pipeline = '|' in command and '||' not in command
198
+
199
+ if is_pipeline:
200
+ ok, reason, segments = split_pipeline(command)
201
+ if not ok:
202
+ return CommandAnalysis(
203
+ ok=False,
204
+ reason=reason,
205
+ is_pipeline=True,
206
+ has_dangerous_chars=True
207
+ )
208
+
209
+ # For pipelines, analyze the first segment
210
+ executable, args = parse_command(segments[0])
211
+ resolved = resolve_executable(executable) if executable else None
212
+
213
+ return CommandAnalysis(
214
+ ok=True,
215
+ executable=executable,
216
+ resolved_path=resolved,
217
+ args=args,
218
+ is_pipeline=True,
219
+ segments=segments,
220
+ is_safe_bin=is_safe_bin(executable, args) if executable else False
221
+ )
222
+
223
+ # Single command
224
+ executable, args = parse_command(command)
225
+
226
+ if not executable:
227
+ return CommandAnalysis(ok=False, reason="Could not parse command")
228
+
229
+ # Check for shell metachars in the executable
230
+ if SHELL_METACHARS.search(executable):
231
+ return CommandAnalysis(
232
+ ok=False,
233
+ reason="Shell metacharacters in executable",
234
+ has_dangerous_chars=True
235
+ )
236
+
237
+ # Resolve the executable path
238
+ resolved = resolve_executable(executable)
239
+
240
+ return CommandAnalysis(
241
+ ok=True,
242
+ executable=executable,
243
+ resolved_path=resolved,
244
+ args=args,
245
+ is_pipeline=False,
246
+ is_safe_bin=is_safe_bin(executable, args)
247
+ )
@@ -0,0 +1,88 @@
1
+ """Type definitions for secure command execution."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Literal
5
+
6
+ # Security modes
7
+ ExecSecurity = Literal["deny", "allowlist", "full"]
8
+
9
+ # Ask modes for approval
10
+ ExecAsk = Literal["off", "on-miss", "always"]
11
+
12
+ # Execution host
13
+ ExecHost = Literal["local", "sandbox"]
14
+
15
+ # Approval decisions
16
+ ExecApprovalDecision = Literal["allow-once", "allow-always", "deny"]
17
+
18
+
19
+ @dataclass
20
+ class ExecConfig:
21
+ """Configuration for command execution."""
22
+ enabled: bool = False
23
+ security: ExecSecurity = "deny"
24
+ ask: ExecAsk = "on-miss"
25
+ ask_fallback: ExecSecurity = "deny"
26
+ host: ExecHost = "local"
27
+ timeout_seconds: int = 300 # 5 minutes default
28
+ max_output_chars: int = 200_000 # 200KB
29
+ approval_timeout_seconds: int = 120 # 2 minutes to approve
30
+
31
+
32
+ @dataclass
33
+ class AllowlistEntry:
34
+ """An entry in the exec allowlist."""
35
+ pattern: str
36
+ last_used_at: int | None = None
37
+ last_used_command: str | None = None
38
+ last_resolved_path: str | None = None
39
+
40
+
41
+ @dataclass
42
+ class ExecRequest:
43
+ """A request to execute a command."""
44
+ command: str
45
+ cwd: str | None = None
46
+ timeout: int | None = None
47
+ env: dict[str, str] | None = None
48
+ session_key: str | None = None
49
+ background: bool = False
50
+
51
+
52
+ @dataclass
53
+ class ExecResult:
54
+ """Result of command execution."""
55
+ success: bool
56
+ exit_code: int | None = None
57
+ stdout: str = ""
58
+ stderr: str = ""
59
+ error: str | None = None
60
+ timed_out: bool = False
61
+ denied: bool = False
62
+ approval_required: bool = False
63
+ pid: int | None = None
64
+
65
+
66
+ @dataclass
67
+ class CommandAnalysis:
68
+ """Analysis of a shell command for safety."""
69
+ ok: bool
70
+ reason: str | None = None
71
+ executable: str | None = None
72
+ resolved_path: str | None = None
73
+ args: list[str] = field(default_factory=list)
74
+ is_pipeline: bool = False
75
+ segments: list[str] = field(default_factory=list)
76
+ has_dangerous_chars: bool = False
77
+ is_safe_bin: bool = False
78
+
79
+
80
+ @dataclass
81
+ class PendingApproval:
82
+ """A pending approval request."""
83
+ id: str
84
+ request: ExecRequest
85
+ created_at: float
86
+ expires_at: float
87
+ session_key: str | None = None
88
+ resolved_path: str | None = None
@@ -0,0 +1,5 @@
1
+ """Gateway API for external integrations."""
2
+
3
+ from flowly_code.gateway.server import GatewayServer
4
+
5
+ __all__ = ["GatewayServer"]
@@ -0,0 +1,103 @@
1
+ """HTTP API server for gateway integrations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+
8
+ from aiohttp import web
9
+ from loguru import logger
10
+
11
+ from flowly_code.activity.bus import ActivityBus
12
+
13
+ # Maximum allowed request body size (1MB)
14
+ _MAX_BODY_SIZE = 1024 * 1024
15
+
16
+
17
+ class GatewayServer:
18
+ """
19
+ HTTP API server for health check and integrations.
20
+
21
+ Provides endpoints for:
22
+ - Health check (GET /health)
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ host: str = "127.0.0.1",
28
+ port: int = 18790,
29
+ activity_bus: ActivityBus | None = None,
30
+ ):
31
+ """
32
+ Initialize the gateway server.
33
+
34
+ Args:
35
+ host: Host to bind to.
36
+ port: Port to listen on.
37
+ activity_bus: Optional activity bus for real-time event streaming.
38
+ """
39
+ self.host = host
40
+ self.port = port
41
+ self.activity_bus = activity_bus
42
+ self._app: web.Application | None = None
43
+ self._runner: web.AppRunner | None = None
44
+ self._site: web.TCPSite | None = None
45
+
46
+ def _create_app(self) -> web.Application:
47
+ """Create the aiohttp application."""
48
+ app = web.Application(client_max_size=_MAX_BODY_SIZE)
49
+ app.router.add_get("/health", self._handle_health)
50
+ if self.activity_bus is not None:
51
+ app.router.add_get(
52
+ "/api/activity/stream", self._handle_activity_stream
53
+ )
54
+ return app
55
+
56
+ async def _handle_health(self, request: web.Request) -> web.Response:
57
+ """Health check endpoint."""
58
+ return web.json_response({"status": "ok"})
59
+
60
+ async def _handle_activity_stream(
61
+ self, request: web.Request
62
+ ) -> web.StreamResponse:
63
+ """SSE endpoint for real-time agent activity events."""
64
+ response = web.StreamResponse(
65
+ status=200,
66
+ reason="OK",
67
+ headers={
68
+ "Content-Type": "text/event-stream",
69
+ "Cache-Control": "no-cache",
70
+ "Connection": "keep-alive",
71
+ "Access-Control-Allow-Origin": "http://127.0.0.1:7777",
72
+ },
73
+ )
74
+ await response.prepare(request)
75
+
76
+ sub_id, events = await self.activity_bus.subscribe()
77
+ try:
78
+ async for event in events:
79
+ data = json.dumps(event.to_dict())
80
+ await response.write(f"data: {data}\n\n".encode("utf-8"))
81
+ except (ConnectionResetError, asyncio.CancelledError):
82
+ pass
83
+ finally:
84
+ await self.activity_bus.unsubscribe(sub_id)
85
+
86
+ return response
87
+
88
+ async def start(self) -> None:
89
+ """Start the HTTP server."""
90
+ self._app = self._create_app()
91
+ self._runner = web.AppRunner(self._app)
92
+ await self._runner.setup()
93
+ self._site = web.TCPSite(self._runner, self.host, self.port)
94
+ await self._site.start()
95
+ logger.info(f"Gateway API listening on http://{self.host}:{self.port}")
96
+
97
+ async def stop(self) -> None:
98
+ """Stop the HTTP server."""
99
+ if self._site:
100
+ await self._site.stop()
101
+ if self._runner:
102
+ await self._runner.cleanup()
103
+ logger.info("Gateway API stopped")
@@ -0,0 +1,5 @@
1
+ """Heartbeat service for periodic agent wake-ups."""
2
+
3
+ from flowly_code.heartbeat.service import HeartbeatService
4
+
5
+ __all__ = ["HeartbeatService"]