flock-core 0.4.510__py3-none-any.whl → 0.4.512__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.
Potentially problematic release.
This version of flock-core might be problematic. Click here for more details.
- flock/core/config/flock_agent_config.py +11 -0
- flock/core/config/scheduled_agent_config.py +40 -0
- flock/core/flock_agent.py +6 -0
- flock/core/flock_factory.py +44 -0
- flock/core/flock_scheduler.py +166 -0
- flock/webapp/app/main.py +66 -11
- flock/webapp/app/services/sharing_store.py +173 -0
- flock/webapp/run.py +3 -1
- flock/webapp/static/css/two-pane.css +48 -0
- flock/webapp/templates/base.html +10 -10
- flock/webapp/templates/chat.html +7 -10
- flock/webapp/templates/chat_settings.html +3 -4
- flock/webapp/templates/flock_editor.html +1 -2
- flock/webapp/templates/index.html +1 -1
- flock/webapp/templates/partials/_agent_detail_form.html +7 -13
- flock/webapp/templates/partials/_agent_list.html +1 -2
- flock/webapp/templates/partials/_agent_manager_view.html +2 -3
- flock/webapp/templates/partials/_chat_container.html +2 -2
- flock/webapp/templates/partials/_chat_settings_form.html +6 -8
- flock/webapp/templates/partials/_create_flock_form.html +2 -4
- flock/webapp/templates/partials/_dashboard_flock_detail.html +2 -3
- flock/webapp/templates/partials/_dashboard_flock_file_list.html +1 -2
- flock/webapp/templates/partials/_dashboard_flock_properties_preview.html +2 -3
- flock/webapp/templates/partials/_dashboard_upload_flock_form.html +1 -2
- flock/webapp/templates/partials/_env_vars_table.html +2 -4
- flock/webapp/templates/partials/_execution_form.html +12 -10
- flock/webapp/templates/partials/_execution_view_container.html +14 -6
- flock/webapp/templates/partials/_flock_file_list.html +2 -3
- flock/webapp/templates/partials/_flock_properties_form.html +2 -2
- flock/webapp/templates/partials/_flock_upload_form.html +1 -2
- flock/webapp/templates/partials/_load_manager_view.html +2 -3
- flock/webapp/templates/partials/_registry_viewer_content.html +4 -5
- flock/webapp/templates/partials/_settings_env_content.html +2 -3
- flock/webapp/templates/partials/_settings_theme_content.html +2 -2
- flock/webapp/templates/partials/_settings_view.html +2 -2
- flock/webapp/templates/partials/_sidebar.html +27 -39
- flock/webapp/templates/registry_viewer.html +7 -10
- flock/webapp/templates/shared_run_page.html +7 -10
- {flock_core-0.4.510.dist-info → flock_core-0.4.512.dist-info}/METADATA +3 -1
- {flock_core-0.4.510.dist-info → flock_core-0.4.512.dist-info}/RECORD +43 -39
- {flock_core-0.4.510.dist-info → flock_core-0.4.512.dist-info}/WHEEL +0 -0
- {flock_core-0.4.510.dist-info → flock_core-0.4.512.dist-info}/entry_points.txt +0 -0
- {flock_core-0.4.510.dist-info → flock_core-0.4.512.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FlockAgentConfig(BaseModel):
|
|
7
|
+
"""FlockAgentConfig is a class that holds the configuration for a Flock agent.
|
|
8
|
+
It is used to store various settings and parameters that can be accessed throughout the agent's lifecycle.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
pass
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from pydantic import Field
|
|
5
|
+
|
|
6
|
+
from flock.core.config.flock_agent_config import FlockAgentConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ScheduledAgentConfig(FlockAgentConfig):
|
|
10
|
+
"""Configuration specific to agents that run on a schedule."""
|
|
11
|
+
schedule_expression: str = Field(
|
|
12
|
+
...,
|
|
13
|
+
description="Defines when the agent should run. "
|
|
14
|
+
"Examples: 'every 60m', 'every 1h', 'daily at 02:00', '0 */2 * * *' (cron expression)"
|
|
15
|
+
)
|
|
16
|
+
enabled: bool = Field(
|
|
17
|
+
True,
|
|
18
|
+
description="Whether the scheduled agent is enabled. "
|
|
19
|
+
"If False, the agent will not run even if the schedule expression is valid."
|
|
20
|
+
)
|
|
21
|
+
initial_run: bool = Field(
|
|
22
|
+
False,
|
|
23
|
+
description="If True, the agent will run immediately after being scheduled, "
|
|
24
|
+
"regardless of the schedule expression."
|
|
25
|
+
)
|
|
26
|
+
max_runs: int = Field(
|
|
27
|
+
0,
|
|
28
|
+
description="Maximum number of times the agent can run. "
|
|
29
|
+
"0 means unlimited runs. If set, the agent will stop running after reaching this limit."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def __init__(self, **kwargs):
|
|
33
|
+
super().__init__(**kwargs)
|
|
34
|
+
# Ensure schedule_expression is always set
|
|
35
|
+
if 'schedule_expression' not in kwargs:
|
|
36
|
+
raise ValueError("schedule_expression is required for ScheduledAgentConfig")
|
|
37
|
+
|
|
38
|
+
# Validate initial_run and max_runs
|
|
39
|
+
if self.initial_run and self.max_runs > 0:
|
|
40
|
+
raise ValueError("Cannot set initial_run to True if max_runs is greater than 0")
|
flock/core/flock_agent.py
CHANGED
|
@@ -10,6 +10,7 @@ from collections.abc import Callable
|
|
|
10
10
|
from datetime import datetime
|
|
11
11
|
from typing import TYPE_CHECKING, Any, TypeVar
|
|
12
12
|
|
|
13
|
+
from flock.core.config.flock_agent_config import FlockAgentConfig
|
|
13
14
|
from flock.core.mcp.flock_mcp_server import FlockMCPServerBase
|
|
14
15
|
from flock.core.serialization.json_encoder import FlockJSONEncoder
|
|
15
16
|
from flock.workflow.temporal_config import TemporalActivityConfig
|
|
@@ -124,6 +125,11 @@ class FlockAgent(BaseModel, Serializable, DSPyIntegrationMixin, ABC):
|
|
|
124
125
|
description="Dictionary of FlockModules attached to this agent.",
|
|
125
126
|
)
|
|
126
127
|
|
|
128
|
+
config: FlockAgentConfig = Field(
|
|
129
|
+
default_factory=lambda: FlockAgentConfig(),
|
|
130
|
+
description="Configuration for this agent, holding various settings and parameters.",
|
|
131
|
+
)
|
|
132
|
+
|
|
127
133
|
# --- Temporal Configuration (Optional) ---
|
|
128
134
|
temporal_activity_config: TemporalActivityConfig | None = Field(
|
|
129
135
|
default=None,
|
flock/core/flock_factory.py
CHANGED
|
@@ -7,6 +7,7 @@ from typing import Any, Literal
|
|
|
7
7
|
|
|
8
8
|
from pydantic import AnyUrl, BaseModel, Field, FileUrl
|
|
9
9
|
|
|
10
|
+
from flock.core.config.scheduled_agent_config import ScheduledAgentConfig
|
|
10
11
|
from flock.core.flock_agent import FlockAgent, SignatureType
|
|
11
12
|
from flock.core.logging.formatters.themes import OutputTheme
|
|
12
13
|
from flock.core.mcp.flock_mcp_server import FlockMCPServerBase
|
|
@@ -381,3 +382,46 @@ class FlockFactory:
|
|
|
381
382
|
agent.add_module(output_module)
|
|
382
383
|
agent.add_module(metrics_module)
|
|
383
384
|
return agent
|
|
385
|
+
|
|
386
|
+
@staticmethod
|
|
387
|
+
def create_scheduled_agent(
|
|
388
|
+
name: str,
|
|
389
|
+
schedule_expression: str, # e.g., "every 1h", "0 0 * * *"
|
|
390
|
+
description: str | Callable[..., str] | None = None,
|
|
391
|
+
model: str | Callable[..., str] | None = None,
|
|
392
|
+
input: SignatureType = None, # Input might be implicit or none
|
|
393
|
+
output: SignatureType = None, # Input might be implicit or none
|
|
394
|
+
tools: list[Callable[..., Any] | Any] | None = None,
|
|
395
|
+
servers: list[str | FlockMCPServerBase] | None = None,
|
|
396
|
+
use_cache: bool = False, # Whether to cache results
|
|
397
|
+
temperature: float = 0.7, # Temperature for model responses
|
|
398
|
+
# ... other common agent params from create_default_agent ...
|
|
399
|
+
temporal_activity_config: TemporalActivityConfig | None = None, # If you want scheduled tasks to be Temporal activities
|
|
400
|
+
**kwargs # Forward other standard agent params
|
|
401
|
+
) -> FlockAgent:
|
|
402
|
+
"""Creates a FlockAgent configured to run on a schedule."""
|
|
403
|
+
agent_config = ScheduledAgentConfig( # Use the new config type
|
|
404
|
+
schedule_expression=schedule_expression,
|
|
405
|
+
enabled=True,
|
|
406
|
+
initial_run=True,
|
|
407
|
+
max_runs=0,
|
|
408
|
+
**kwargs
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
agent = FlockFactory.create_default_agent( # Reuse your existing factory
|
|
413
|
+
name=name,
|
|
414
|
+
description=description,
|
|
415
|
+
model=model,
|
|
416
|
+
input=input + ", trigger_time: str | Time of scheduled execution",
|
|
417
|
+
output=output,
|
|
418
|
+
tools=tools,
|
|
419
|
+
servers=servers,
|
|
420
|
+
temporal_activity_config=temporal_activity_config,
|
|
421
|
+
use_cache=use_cache,
|
|
422
|
+
temperature=temperature,
|
|
423
|
+
**kwargs
|
|
424
|
+
)
|
|
425
|
+
agent.config = agent_config # Assign the scheduled agent config
|
|
426
|
+
|
|
427
|
+
return agent
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# src/flock/core/scheduler.py (new file or inside flock.py)
|
|
2
|
+
import asyncio
|
|
3
|
+
import traceback
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
|
|
6
|
+
from croniter import croniter # pip install croniter
|
|
7
|
+
|
|
8
|
+
from flock.core.flock import Flock
|
|
9
|
+
from flock.core.flock_agent import FlockAgent # For type hinting
|
|
10
|
+
from flock.core.logging.logging import get_logger
|
|
11
|
+
|
|
12
|
+
logger = get_logger("flock.scheduler")
|
|
13
|
+
|
|
14
|
+
class FlockScheduler:
|
|
15
|
+
def __init__(self, flock_instance: Flock):
|
|
16
|
+
self.flock = flock_instance
|
|
17
|
+
self._scheduled_tasks: list[tuple[FlockAgent, croniter | timedelta, datetime | None]] = []
|
|
18
|
+
self._stop_event = asyncio.Event()
|
|
19
|
+
self._is_running = False
|
|
20
|
+
|
|
21
|
+
def _parse_schedule_expression(self, expression: str) -> croniter | timedelta:
|
|
22
|
+
"""Parses a schedule expression.
|
|
23
|
+
Supports cron syntax and simple intervals like 'every Xm', 'every Xh', 'every Xd'.
|
|
24
|
+
"""
|
|
25
|
+
expression = expression.strip().lower()
|
|
26
|
+
if expression.startswith("every "):
|
|
27
|
+
try:
|
|
28
|
+
parts = expression.split(" ")
|
|
29
|
+
value = int(parts[1][:-1])
|
|
30
|
+
unit = parts[1][-1]
|
|
31
|
+
if unit == 's': return timedelta(seconds=value)
|
|
32
|
+
if unit == 'm': return timedelta(minutes=value)
|
|
33
|
+
elif unit == 'h': return timedelta(hours=value)
|
|
34
|
+
elif unit == 'd': return timedelta(days=value)
|
|
35
|
+
else: raise ValueError(f"Invalid time unit: {unit}")
|
|
36
|
+
except Exception as e:
|
|
37
|
+
logger.error(f"Invalid interval expression '{expression}': {e}")
|
|
38
|
+
raise ValueError(f"Invalid interval expression: {expression}") from e
|
|
39
|
+
else:
|
|
40
|
+
# Assume cron expression
|
|
41
|
+
if not croniter.is_valid(expression):
|
|
42
|
+
raise ValueError(f"Invalid cron expression: {expression}")
|
|
43
|
+
return croniter(expression, datetime.now(timezone.utc))
|
|
44
|
+
|
|
45
|
+
def add_agent(self, agent: FlockAgent):
|
|
46
|
+
"""Adds an agent to the scheduler if it has a schedule expression."""
|
|
47
|
+
# Assuming schedule_expression is stored in agent._flock_config or a dedicated field
|
|
48
|
+
schedule_expression = None
|
|
49
|
+
if hasattr(agent, '_flock_config') and isinstance(agent._flock_config, dict):
|
|
50
|
+
schedule_expression = agent._flock_config.get('schedule_expression')
|
|
51
|
+
elif hasattr(agent, 'schedule_expression'): # If directly on agent
|
|
52
|
+
schedule_expression = agent.schedule_expression
|
|
53
|
+
elif hasattr(agent, 'config') and hasattr(agent.config, 'schedule_expression'): # If on agent.config
|
|
54
|
+
schedule_expression = agent.config.schedule_expression
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if not schedule_expression:
|
|
58
|
+
logger.warning(f"Agent '{agent.name}' has no schedule_expression. Skipping.")
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
parsed_schedule = self._parse_schedule_expression(schedule_expression)
|
|
63
|
+
self._scheduled_tasks.append((agent, parsed_schedule, None)) # agent, schedule, last_run_utc
|
|
64
|
+
logger.info(f"Scheduled agent '{agent.name}' with expression: {schedule_expression}")
|
|
65
|
+
except ValueError as e:
|
|
66
|
+
logger.error(f"Could not schedule agent '{agent.name}': {e}")
|
|
67
|
+
|
|
68
|
+
def _load_scheduled_agents_from_flock(self):
|
|
69
|
+
"""Scans the Flock instance for agents with scheduling configuration."""
|
|
70
|
+
self._scheduled_tasks = [] # Clear existing before loading
|
|
71
|
+
for agent_name, agent_instance in self.flock.agents.items():
|
|
72
|
+
self.add_agent(agent_instance)
|
|
73
|
+
logger.info(f"Loaded {len(self._scheduled_tasks)} scheduled agents from Flock '{self.flock.name}'.")
|
|
74
|
+
|
|
75
|
+
async def _run_agent_task(self, agent: FlockAgent, trigger_time: datetime):
|
|
76
|
+
logger.info(f"Triggering scheduled agent '{agent.name}' at {trigger_time.isoformat()}")
|
|
77
|
+
try:
|
|
78
|
+
# Input for a scheduled agent could include the trigger time
|
|
79
|
+
await self.flock.run_async(start_agent=agent.name, input={"trigger_time": trigger_time})
|
|
80
|
+
logger.info(f"Scheduled agent '{agent.name}' finished successfully.")
|
|
81
|
+
except Exception as e:
|
|
82
|
+
logger.error(f"Error running scheduled agent '{agent.name}': {e}\n{traceback.format_exc()}")
|
|
83
|
+
|
|
84
|
+
async def _scheduler_loop(self):
|
|
85
|
+
self._is_running = True
|
|
86
|
+
logger.info("FlockScheduler loop started.")
|
|
87
|
+
while not self._stop_event.is_set():
|
|
88
|
+
now_utc = datetime.now(timezone.utc)
|
|
89
|
+
tasks_to_run_this_cycle = []
|
|
90
|
+
|
|
91
|
+
for i, (agent, schedule, last_run_utc) in enumerate(self._scheduled_tasks):
|
|
92
|
+
should_run = False
|
|
93
|
+
next_run_utc = None
|
|
94
|
+
|
|
95
|
+
if isinstance(schedule, croniter):
|
|
96
|
+
# For cron, get the next scheduled time AFTER the last run (or now if never run)
|
|
97
|
+
base_time = last_run_utc if last_run_utc else now_utc
|
|
98
|
+
# Croniter's get_next gives the next time *after* the base_time.
|
|
99
|
+
# If last_run_utc is None (first run), we check if the *current* now_utc
|
|
100
|
+
# is past the first scheduled time.
|
|
101
|
+
# A simpler check: is `now_utc` >= `schedule.get_next(datetime, base_time=last_run_utc if last_run_utc else now_utc - timedelta(seconds=1))` ?
|
|
102
|
+
# Let's refine croniter check to be more precise.
|
|
103
|
+
# If we are past the *next* scheduled time since *last check*
|
|
104
|
+
if last_run_utc is None: # First run check
|
|
105
|
+
next_run_utc = schedule.get_next(datetime, start_time=now_utc - timedelta(seconds=1)) # Check if first run is due
|
|
106
|
+
if next_run_utc <= now_utc:
|
|
107
|
+
should_run = True
|
|
108
|
+
else:
|
|
109
|
+
next_run_utc = schedule.get_next(datetime, start_time=last_run_utc)
|
|
110
|
+
if next_run_utc <= now_utc:
|
|
111
|
+
should_run = True
|
|
112
|
+
|
|
113
|
+
elif isinstance(schedule, timedelta): # Simple interval
|
|
114
|
+
if last_run_utc is None or (now_utc - last_run_utc >= schedule):
|
|
115
|
+
should_run = True
|
|
116
|
+
next_run_utc = now_utc # Or now_utc + schedule for next interval start
|
|
117
|
+
else:
|
|
118
|
+
next_run_utc = last_run_utc + schedule
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
if should_run:
|
|
122
|
+
tasks_to_run_this_cycle.append(self._run_agent_task(agent, now_utc))
|
|
123
|
+
# Update last_run_utc for this agent *before* awaiting its execution
|
|
124
|
+
# For cron, advance the iterator to the *current* scheduled time that triggered it.
|
|
125
|
+
if isinstance(schedule, croniter):
|
|
126
|
+
current_cron_trigger = schedule.get_current(datetime) # This is the time it *should* have run
|
|
127
|
+
self._scheduled_tasks[i] = (agent, schedule, current_cron_trigger)
|
|
128
|
+
|
|
129
|
+
elif isinstance(schedule, timedelta):
|
|
130
|
+
self._scheduled_tasks[i] = (agent, schedule, now_utc) # Mark as run now
|
|
131
|
+
|
|
132
|
+
if tasks_to_run_this_cycle:
|
|
133
|
+
await asyncio.gather(*tasks_to_run_this_cycle, return_exceptions=True)
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
# Sleep for a short interval, e.g., 10 seconds, or until stop_event is set
|
|
137
|
+
await asyncio.wait_for(self._stop_event.wait(), timeout=10.0)
|
|
138
|
+
except asyncio.TimeoutError:
|
|
139
|
+
pass # Timeout is normal, means continue loop
|
|
140
|
+
self._is_running = False
|
|
141
|
+
logger.info("FlockScheduler loop stopped.")
|
|
142
|
+
|
|
143
|
+
async def start(self) -> asyncio.Task | None: # Modified to return Task or None
|
|
144
|
+
if self._is_running:
|
|
145
|
+
logger.warning("Scheduler is already running.")
|
|
146
|
+
return None # Or return the existing task if you store it
|
|
147
|
+
|
|
148
|
+
self._load_scheduled_agents_from_flock()
|
|
149
|
+
if not self._scheduled_tasks:
|
|
150
|
+
logger.info("No scheduled agents found. Scheduler will not start a loop task.")
|
|
151
|
+
return None # Return None if no tasks to schedule
|
|
152
|
+
|
|
153
|
+
self._stop_event.clear()
|
|
154
|
+
loop_task = asyncio.create_task(self._scheduler_loop())
|
|
155
|
+
# Store the task if you need to reference it, e.g., for forced cancellation beyond _stop_event
|
|
156
|
+
# self._loop_task = loop_task
|
|
157
|
+
return loop_task # Return the created task
|
|
158
|
+
|
|
159
|
+
async def stop(self):
|
|
160
|
+
if not self._is_running and not self._stop_event.is_set(): # Check if stop already called
|
|
161
|
+
logger.info("Scheduler is not running or already signaled to stop.")
|
|
162
|
+
return
|
|
163
|
+
logger.info("Stopping FlockScheduler...")
|
|
164
|
+
self._stop_event.set()
|
|
165
|
+
# If you stored self._loop_task, you can await it here or in the lifespan manager
|
|
166
|
+
# await self._loop_task # (This might block if loop doesn't exit quickly)
|
flock/webapp/app/main.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# src/flock/webapp/app/main.py
|
|
2
|
+
import asyncio
|
|
2
3
|
import json
|
|
3
4
|
import os # Added import
|
|
4
5
|
import shutil
|
|
@@ -8,6 +9,7 @@ import urllib.parse
|
|
|
8
9
|
import uuid
|
|
9
10
|
from contextlib import asynccontextmanager
|
|
10
11
|
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
11
13
|
|
|
12
14
|
import markdown2 # Import markdown2
|
|
13
15
|
from fastapi import (
|
|
@@ -24,13 +26,13 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
|
|
24
26
|
from fastapi.staticfiles import StaticFiles
|
|
25
27
|
from fastapi.templating import Jinja2Templates
|
|
26
28
|
from pydantic import BaseModel
|
|
27
|
-
from typing import Any
|
|
28
29
|
|
|
29
30
|
from flock.core.api.endpoints import create_api_router
|
|
30
31
|
from flock.core.api.run_store import RunStore
|
|
31
32
|
|
|
32
33
|
# Import core Flock components and API related modules
|
|
33
34
|
from flock.core.flock import Flock # For type hinting
|
|
35
|
+
from flock.core.flock_scheduler import FlockScheduler
|
|
34
36
|
from flock.core.logging.logging import get_logger # For logging
|
|
35
37
|
from flock.core.util.spliter import parse_schema
|
|
36
38
|
|
|
@@ -44,7 +46,6 @@ from flock.webapp.app.api import (
|
|
|
44
46
|
from flock.webapp.app.config import (
|
|
45
47
|
DEFAULT_THEME_NAME,
|
|
46
48
|
FLOCK_FILES_DIR,
|
|
47
|
-
SHARED_LINKS_DB_PATH,
|
|
48
49
|
THEMES_DIR,
|
|
49
50
|
get_current_theme_name,
|
|
50
51
|
)
|
|
@@ -72,7 +73,7 @@ from flock.webapp.app.services.flock_service import (
|
|
|
72
73
|
from flock.webapp.app.services.sharing_models import SharedLinkConfig
|
|
73
74
|
from flock.webapp.app.services.sharing_store import (
|
|
74
75
|
SharedLinkStoreInterface,
|
|
75
|
-
|
|
76
|
+
create_shared_link_store,
|
|
76
77
|
)
|
|
77
78
|
from flock.webapp.app.theme_mapper import alacritty_to_pico
|
|
78
79
|
|
|
@@ -138,23 +139,21 @@ async def lifespan(app: FastAPI):
|
|
|
138
139
|
logger.info("FastAPI application starting up...")
|
|
139
140
|
# Flock instance and RunStore are expected to be set on app.state
|
|
140
141
|
# by `start_unified_server` in `webapp/run.py` *before* uvicorn starts the app.
|
|
141
|
-
# The call to `set_global_flock_services` also happens there.
|
|
142
|
-
|
|
143
|
-
# Initialize and set the SharedLinkStore
|
|
142
|
+
# The call to `set_global_flock_services` also happens there. # Initialize and set the SharedLinkStore
|
|
144
143
|
try:
|
|
145
|
-
logger.info(
|
|
146
|
-
shared_link_store =
|
|
144
|
+
logger.info("Initializing SharedLinkStore using factory...")
|
|
145
|
+
shared_link_store = create_shared_link_store()
|
|
147
146
|
await shared_link_store.initialize() # Create tables if they don't exist
|
|
148
147
|
set_global_shared_link_store(shared_link_store)
|
|
149
148
|
logger.info("SharedLinkStore initialized and set globally.")
|
|
150
149
|
except Exception as e:
|
|
151
|
-
logger.error(f"Failed to initialize SharedLinkStore: {e}", exc_info=True)
|
|
150
|
+
logger.error(f"Failed to initialize SharedLinkStore: {e}", exc_info=True)# Configure chat features with clear precedence:
|
|
152
151
|
# 1. Value set by start_unified_server (programmatic)
|
|
153
152
|
# 2. Environment variables (standalone mode)
|
|
154
153
|
programmatic_chat_enabled = getattr(app.state, "chat_enabled", None)
|
|
155
154
|
env_start_mode = os.environ.get("FLOCK_START_MODE")
|
|
156
155
|
env_chat_enabled = os.environ.get("FLOCK_CHAT_ENABLED", "false").lower() == "true"
|
|
157
|
-
|
|
156
|
+
|
|
158
157
|
if programmatic_chat_enabled is not None:
|
|
159
158
|
# Programmatic setting takes precedence (from start_unified_server)
|
|
160
159
|
should_enable_chat_routes = programmatic_chat_enabled
|
|
@@ -235,11 +234,66 @@ async def lifespan(app: FastAPI):
|
|
|
235
234
|
logger.info(f"Lifespan: Added {len(pending_endpoints)} custom API routes to main app.")
|
|
236
235
|
else:
|
|
237
236
|
logger.warning("Lifespan: Pending custom endpoints found, but no Flock instance in app.state. Cannot add custom routes.")
|
|
237
|
+
|
|
238
|
+
# --- Add Scheduler Startup Logic ---
|
|
239
|
+
flock_instance_from_state: Flock | None = getattr(app.state, "flock_instance", None)
|
|
240
|
+
if flock_instance_from_state:
|
|
241
|
+
# Create and start the scheduler
|
|
242
|
+
scheduler = FlockScheduler(flock_instance_from_state)
|
|
243
|
+
app.state.flock_scheduler = scheduler # Store for access during shutdown
|
|
244
|
+
|
|
245
|
+
scheduler_loop_task = await scheduler.start() # Start returns the task
|
|
246
|
+
if scheduler_loop_task:
|
|
247
|
+
app.state.flock_scheduler_task = scheduler_loop_task # Store the task
|
|
248
|
+
logger.info("FlockScheduler background task started.")
|
|
249
|
+
else:
|
|
250
|
+
app.state.flock_scheduler_task = None
|
|
251
|
+
logger.info("FlockScheduler initialized, but no scheduled agents found or loop not started.")
|
|
252
|
+
else:
|
|
253
|
+
app.state.flock_scheduler = None
|
|
254
|
+
app.state.flock_scheduler_task = None
|
|
255
|
+
logger.warning("No Flock instance found in app.state; FlockScheduler not started.")
|
|
256
|
+
# --- End Scheduler Startup Logic ---
|
|
257
|
+
|
|
238
258
|
yield
|
|
239
259
|
logger.info("FastAPI application shutting down...")
|
|
240
260
|
|
|
241
|
-
|
|
261
|
+
# --- Add Scheduler Shutdown Logic ---
|
|
262
|
+
logger.info("FastAPI application initiating shutdown...")
|
|
263
|
+
scheduler_to_stop: FlockScheduler | None = getattr(app.state, "flock_scheduler", None)
|
|
264
|
+
scheduler_task_to_await: asyncio.Task | None = getattr(app.state, "flock_scheduler_task", None)
|
|
242
265
|
|
|
266
|
+
if scheduler_to_stop:
|
|
267
|
+
logger.info("Attempting to stop FlockScheduler...")
|
|
268
|
+
await scheduler_to_stop.stop() # Signal the scheduler loop to stop
|
|
269
|
+
|
|
270
|
+
if scheduler_task_to_await and not scheduler_task_to_await.done():
|
|
271
|
+
logger.info("Waiting for FlockScheduler task to complete...")
|
|
272
|
+
try:
|
|
273
|
+
await asyncio.wait_for(scheduler_task_to_await, timeout=10.0) # Wait for graceful exit
|
|
274
|
+
logger.info("FlockScheduler task completed gracefully.")
|
|
275
|
+
except asyncio.TimeoutError:
|
|
276
|
+
logger.warning("FlockScheduler task did not complete in time, cancelling.")
|
|
277
|
+
scheduler_task_to_await.cancel()
|
|
278
|
+
try:
|
|
279
|
+
await scheduler_task_to_await # Await cancellation
|
|
280
|
+
except asyncio.CancelledError:
|
|
281
|
+
logger.info("FlockScheduler task cancelled.")
|
|
282
|
+
except Exception as e:
|
|
283
|
+
logger.error(f"Error during FlockScheduler task finalization: {e}", exc_info=True)
|
|
284
|
+
elif scheduler_task_to_await and scheduler_task_to_await.done():
|
|
285
|
+
logger.info("FlockScheduler task was already done.")
|
|
286
|
+
else:
|
|
287
|
+
logger.info("FlockScheduler instance found, but no running task was stored to await.")
|
|
288
|
+
else:
|
|
289
|
+
logger.info("No active FlockScheduler found to stop.")
|
|
290
|
+
|
|
291
|
+
logger.info("FastAPI application finished shutdown sequence.")
|
|
292
|
+
# --- End Scheduler Shutdown Logic ---
|
|
293
|
+
|
|
294
|
+
app = FastAPI(title="Flock Web UI & API", lifespan=lifespan, docs_url="/docs",
|
|
295
|
+
openapi_url="/openapi.json", root_path=os.getenv("FLOCK_ROOT_PATH", ""))
|
|
296
|
+
logger.info("FastAPI booting complete.")
|
|
243
297
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
244
298
|
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
|
|
245
299
|
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
|
@@ -826,6 +880,7 @@ async def ui_create_flock_action(request: Request, flock_name: str = Form(...),
|
|
|
826
880
|
context = get_base_context_web(request, success=success_msg_text, ui_mode=ui_mode_query)
|
|
827
881
|
return templates.TemplateResponse("partials/_execution_view_container.html", context, headers=response_headers)
|
|
828
882
|
|
|
883
|
+
|
|
829
884
|
# --- Settings Page & Endpoints ---
|
|
830
885
|
@app.get("/ui/settings", response_class=HTMLResponse, tags=["UI Pages"])
|
|
831
886
|
async def page_settings(request: Request, error: str = None, success: str = None, ui_mode: str = Query("standalone")):
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"""Shared link and feedback storage implementations supporting SQLite and Azure Table Storage."""
|
|
2
|
+
|
|
1
3
|
import logging
|
|
2
4
|
import sqlite3
|
|
3
5
|
from abc import ABC, abstractmethod
|
|
@@ -10,6 +12,17 @@ from flock.webapp.app.services.sharing_models import (
|
|
|
10
12
|
SharedLinkConfig,
|
|
11
13
|
)
|
|
12
14
|
|
|
15
|
+
# Azure Table Storage imports - will be conditionally imported
|
|
16
|
+
try:
|
|
17
|
+
from azure.core.exceptions import ResourceExistsError, ResourceNotFoundError
|
|
18
|
+
from azure.data.tables.aio import TableServiceClient
|
|
19
|
+
AZURE_AVAILABLE = True
|
|
20
|
+
except ImportError:
|
|
21
|
+
AZURE_AVAILABLE = False
|
|
22
|
+
TableServiceClient = None
|
|
23
|
+
ResourceNotFoundError = None
|
|
24
|
+
ResourceExistsError = None
|
|
25
|
+
|
|
13
26
|
# Get a logger instance
|
|
14
27
|
logger = logging.getLogger(__name__)
|
|
15
28
|
|
|
@@ -46,6 +59,7 @@ class SQLiteSharedLinkStore(SharedLinkStoreInterface):
|
|
|
46
59
|
"""SQLite implementation for storing and retrieving shared link configurations."""
|
|
47
60
|
|
|
48
61
|
def __init__(self, db_path: str):
|
|
62
|
+
"""Initialize SQLite store with database path."""
|
|
49
63
|
self.db_path = Path(db_path)
|
|
50
64
|
self.db_path.parent.mkdir(parents=True, exist_ok=True) # Ensure directory exists
|
|
51
65
|
logger.info(f"SQLiteSharedLinkStore initialized with db_path: {self.db_path}")
|
|
@@ -213,3 +227,162 @@ class SQLiteSharedLinkStore(SharedLinkStoreInterface):
|
|
|
213
227
|
except sqlite3.Error as e:
|
|
214
228
|
logger.error(f"SQLite error saving feedback {record.feedback_id}: {e}", exc_info=True)
|
|
215
229
|
raise
|
|
230
|
+
|
|
231
|
+
class AzureTableSharedLinkStore(SharedLinkStoreInterface):
|
|
232
|
+
"""Azure Table Storage implementation for storing and retrieving shared link configurations."""
|
|
233
|
+
|
|
234
|
+
def __init__(self, connection_string: str):
|
|
235
|
+
"""Initialize Azure Table Storage store with connection string."""
|
|
236
|
+
if not AZURE_AVAILABLE:
|
|
237
|
+
raise ImportError("Azure Table Storage dependencies not available. Install with: pip install azure-data-tables")
|
|
238
|
+
|
|
239
|
+
self.connection_string = connection_string
|
|
240
|
+
self.table_service_client = TableServiceClient.from_connection_string(connection_string)
|
|
241
|
+
self.shared_links_table_name = "flocksharedlinks"
|
|
242
|
+
self.feedback_table_name = "flockfeedback"
|
|
243
|
+
logger.info("AzureTableSharedLinkStore initialized")
|
|
244
|
+
|
|
245
|
+
async def initialize(self) -> None:
|
|
246
|
+
"""Initializes the Azure Tables (creates them if they don't exist)."""
|
|
247
|
+
try:
|
|
248
|
+
# Create shared_links table
|
|
249
|
+
try:
|
|
250
|
+
await self.table_service_client.create_table(self.shared_links_table_name)
|
|
251
|
+
logger.info(f"Created Azure Table: {self.shared_links_table_name}")
|
|
252
|
+
except ResourceExistsError:
|
|
253
|
+
logger.debug(f"Azure Table already exists: {self.shared_links_table_name}")
|
|
254
|
+
|
|
255
|
+
# Create feedback table
|
|
256
|
+
try:
|
|
257
|
+
await self.table_service_client.create_table(self.feedback_table_name)
|
|
258
|
+
logger.info(f"Created Azure Table: {self.feedback_table_name}")
|
|
259
|
+
except ResourceExistsError:
|
|
260
|
+
logger.debug(f"Azure Table already exists: {self.feedback_table_name}")
|
|
261
|
+
|
|
262
|
+
logger.info("Azure Table Storage initialized successfully")
|
|
263
|
+
except Exception as e:
|
|
264
|
+
logger.error(f"Error initializing Azure Table Storage: {e}", exc_info=True)
|
|
265
|
+
raise
|
|
266
|
+
|
|
267
|
+
async def save_config(self, config: SharedLinkConfig) -> SharedLinkConfig:
|
|
268
|
+
"""Saves a shared link configuration to Azure Table Storage."""
|
|
269
|
+
try:
|
|
270
|
+
table_client = self.table_service_client.get_table_client(self.shared_links_table_name)
|
|
271
|
+
|
|
272
|
+
entity = {
|
|
273
|
+
"PartitionKey": "shared_links", # Use a fixed partition key for simplicity
|
|
274
|
+
"RowKey": config.share_id,
|
|
275
|
+
"share_id": config.share_id,
|
|
276
|
+
"agent_name": config.agent_name,
|
|
277
|
+
"flock_definition": config.flock_definition,
|
|
278
|
+
"created_at": config.created_at.isoformat(),
|
|
279
|
+
"share_type": config.share_type,
|
|
280
|
+
"chat_message_key": config.chat_message_key,
|
|
281
|
+
"chat_history_key": config.chat_history_key,
|
|
282
|
+
"chat_response_key": config.chat_response_key,
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
await table_client.upsert_entity(entity)
|
|
286
|
+
logger.info(f"Saved shared link config to Azure Table Storage for ID: {config.share_id} with type: {config.share_type}")
|
|
287
|
+
return config
|
|
288
|
+
except Exception as e:
|
|
289
|
+
logger.error(f"Error saving config to Azure Table Storage for ID {config.share_id}: {e}", exc_info=True)
|
|
290
|
+
raise
|
|
291
|
+
|
|
292
|
+
async def get_config(self, share_id: str) -> SharedLinkConfig | None:
|
|
293
|
+
"""Retrieves a shared link configuration from Azure Table Storage by its ID."""
|
|
294
|
+
try:
|
|
295
|
+
table_client = self.table_service_client.get_table_client(self.shared_links_table_name)
|
|
296
|
+
|
|
297
|
+
entity = await table_client.get_entity(partition_key="shared_links", row_key=share_id)
|
|
298
|
+
|
|
299
|
+
logger.debug(f"Retrieved shared link config from Azure Table Storage for ID: {share_id}")
|
|
300
|
+
return SharedLinkConfig(
|
|
301
|
+
share_id=entity["share_id"],
|
|
302
|
+
agent_name=entity["agent_name"],
|
|
303
|
+
created_at=entity["created_at"], # Pydantic will parse from ISO format
|
|
304
|
+
flock_definition=entity["flock_definition"],
|
|
305
|
+
share_type=entity.get("share_type", "agent_run"),
|
|
306
|
+
chat_message_key=entity.get("chat_message_key"),
|
|
307
|
+
chat_history_key=entity.get("chat_history_key"),
|
|
308
|
+
chat_response_key=entity.get("chat_response_key"),
|
|
309
|
+
)
|
|
310
|
+
except ResourceNotFoundError:
|
|
311
|
+
logger.debug(f"No shared link config found in Azure Table Storage for ID: {share_id}")
|
|
312
|
+
return None
|
|
313
|
+
except Exception as e:
|
|
314
|
+
logger.error(f"Error retrieving config from Azure Table Storage for ID {share_id}: {e}", exc_info=True)
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
async def delete_config(self, share_id: str) -> bool:
|
|
318
|
+
"""Deletes a shared link configuration from Azure Table Storage by its ID."""
|
|
319
|
+
try:
|
|
320
|
+
table_client = self.table_service_client.get_table_client(self.shared_links_table_name)
|
|
321
|
+
|
|
322
|
+
await table_client.delete_entity(partition_key="shared_links", row_key=share_id)
|
|
323
|
+
logger.info(f"Deleted shared link config from Azure Table Storage for ID: {share_id}")
|
|
324
|
+
return True
|
|
325
|
+
except ResourceNotFoundError:
|
|
326
|
+
logger.info(f"Attempted to delete non-existent shared link config from Azure Table Storage for ID: {share_id}")
|
|
327
|
+
return False
|
|
328
|
+
except Exception as e:
|
|
329
|
+
logger.error(f"Error deleting config from Azure Table Storage for ID {share_id}: {e}", exc_info=True)
|
|
330
|
+
return False
|
|
331
|
+
|
|
332
|
+
# ----------------------- Feedback methods -----------------------
|
|
333
|
+
|
|
334
|
+
async def save_feedback(self, record: FeedbackRecord) -> FeedbackRecord:
|
|
335
|
+
"""Persist a feedback record to Azure Table Storage."""
|
|
336
|
+
try:
|
|
337
|
+
table_client = self.table_service_client.get_table_client(self.feedback_table_name)
|
|
338
|
+
|
|
339
|
+
entity = {
|
|
340
|
+
"PartitionKey": "feedback", # Use a fixed partition key for simplicity
|
|
341
|
+
"RowKey": record.feedback_id,
|
|
342
|
+
"feedback_id": record.feedback_id,
|
|
343
|
+
"share_id": record.share_id,
|
|
344
|
+
"context_type": record.context_type,
|
|
345
|
+
"reason": record.reason,
|
|
346
|
+
"expected_response": record.expected_response,
|
|
347
|
+
"actual_response": record.actual_response,
|
|
348
|
+
"flock_name": record.flock_name,
|
|
349
|
+
"agent_name": record.agent_name,
|
|
350
|
+
"flock_definition": record.flock_definition,
|
|
351
|
+
"created_at": record.created_at.isoformat(),
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
await table_client.upsert_entity(entity)
|
|
355
|
+
logger.info(f"Saved feedback to Azure Table Storage: {record.feedback_id} (share={record.share_id})")
|
|
356
|
+
return record
|
|
357
|
+
except Exception as e:
|
|
358
|
+
logger.error(f"Error saving feedback to Azure Table Storage {record.feedback_id}: {e}", exc_info=True)
|
|
359
|
+
raise
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# ----------------------- Factory Function -----------------------
|
|
363
|
+
|
|
364
|
+
def create_shared_link_store(store_type: str | None = None, connection_string: str | None = None) -> SharedLinkStoreInterface:
|
|
365
|
+
"""Factory function to create the appropriate shared link store based on configuration.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
store_type: Type of store to create ("local" for SQLite, "azure-storage" for Azure Table Storage)
|
|
369
|
+
connection_string: Connection string for the store (file path for SQLite, connection string for Azure)
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
Configured SharedLinkStoreInterface implementation
|
|
373
|
+
"""
|
|
374
|
+
import os
|
|
375
|
+
|
|
376
|
+
# Get values from environment if not provided
|
|
377
|
+
if store_type is None:
|
|
378
|
+
store_type = os.getenv("FLOCK_WEBAPP_STORE", "local").lower()
|
|
379
|
+
|
|
380
|
+
if connection_string is None:
|
|
381
|
+
connection_string = os.getenv("FLOCK_WEBAPP_STORE_CONNECTION", ".flock/shared_links.db")
|
|
382
|
+
|
|
383
|
+
if store_type == "local":
|
|
384
|
+
return SQLiteSharedLinkStore(connection_string)
|
|
385
|
+
elif store_type == "azure-storage":
|
|
386
|
+
return AzureTableSharedLinkStore(connection_string)
|
|
387
|
+
else:
|
|
388
|
+
raise ValueError(f"Unsupported store type: {store_type}. Supported types: 'local', 'azure-storage'")
|
flock/webapp/run.py
CHANGED
|
@@ -142,7 +142,8 @@ def start_unified_server(
|
|
|
142
142
|
"flock.webapp.app.main:app",
|
|
143
143
|
host=host,
|
|
144
144
|
port=port,
|
|
145
|
-
reload=False # Critical for programmatically set state like flock_instance
|
|
145
|
+
reload=False, # Critical for programmatically set state like flock_instance
|
|
146
|
+
# root_path=os.getenv("FLOCK_ROOT_PATH", "")
|
|
146
147
|
)
|
|
147
148
|
|
|
148
149
|
except ImportError as e:
|
|
@@ -206,6 +207,7 @@ def main():
|
|
|
206
207
|
host=host,
|
|
207
208
|
port=port,
|
|
208
209
|
reload=webapp_reload,
|
|
210
|
+
# root_path=os.getenv("FLOCK_ROOT_PATH", "")
|
|
209
211
|
)
|
|
210
212
|
|
|
211
213
|
|