flowent 0.2.3 → 0.3.0

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 (49) hide show
  1. package/README.md +3 -3
  2. package/backend/README.md +3 -3
  3. package/backend/pyproject.toml +1 -1
  4. package/backend/src/flowent/agent.py +1 -1
  5. package/backend/src/flowent/api_models.py +103 -0
  6. package/backend/src/flowent/app.py +151 -0
  7. package/backend/src/flowent/cli.py +13 -4
  8. package/backend/src/flowent/compact.py +34 -13
  9. package/backend/src/flowent/llm.py +6 -8
  10. package/backend/src/flowent/logging.py +7 -1
  11. package/backend/src/flowent/main.py +18 -1989
  12. package/backend/src/flowent/mcp.py +231 -44
  13. package/backend/src/flowent/network.py +5 -0
  14. package/backend/src/flowent/permissions.py +5 -1
  15. package/backend/src/flowent/provider_connections.py +42 -0
  16. package/backend/src/flowent/routes/__init__.py +0 -0
  17. package/backend/src/flowent/routes/integrations.py +105 -0
  18. package/backend/src/flowent/routes/permissions.py +36 -0
  19. package/backend/src/flowent/routes/providers.py +30 -0
  20. package/backend/src/flowent/routes/system.py +49 -0
  21. package/backend/src/flowent/routes/workflow_routes.py +63 -0
  22. package/backend/src/flowent/routes/workspace.py +105 -0
  23. package/backend/src/flowent/sandbox.py +1 -1
  24. package/backend/src/flowent/state/__init__.py +53 -0
  25. package/backend/src/flowent/state/models.py +257 -0
  26. package/backend/src/flowent/state/schema.py +186 -0
  27. package/backend/src/flowent/state/store.py +1013 -0
  28. package/backend/src/flowent/static/assets/index-CvWZZMtK.css +2 -0
  29. package/backend/src/flowent/static/assets/index-ma2v8oW7.js +90 -0
  30. package/backend/src/flowent/static/index.html +2 -2
  31. package/backend/src/flowent/storage.py +52 -1254
  32. package/backend/src/flowent/system_tools.py +25 -0
  33. package/backend/src/flowent/tools.py +4 -2
  34. package/backend/src/flowent/usage.py +9 -4
  35. package/backend/src/flowent/workflows.py +282 -0
  36. package/backend/src/flowent/workspace/__init__.py +0 -0
  37. package/backend/src/flowent/workspace/context.py +249 -0
  38. package/backend/src/flowent/workspace/events.py +180 -0
  39. package/backend/src/flowent/workspace/output.py +274 -0
  40. package/backend/src/flowent/workspace/runtime.py +1041 -0
  41. package/backend/uv.lock +1 -1
  42. package/dist/frontend/assets/index-CvWZZMtK.css +2 -0
  43. package/dist/frontend/assets/index-ma2v8oW7.js +90 -0
  44. package/dist/frontend/index.html +2 -2
  45. package/package.json +1 -1
  46. package/backend/src/flowent/static/assets/index-D7t9qNrC.js +0 -82
  47. package/backend/src/flowent/static/assets/index-DufpDl8x.css +0 -2
  48. package/dist/frontend/assets/index-D7t9qNrC.js +0 -82
  49. package/dist/frontend/assets/index-DufpDl8x.css +0 -2
@@ -5,16 +5,23 @@ import json
5
5
  import logging
6
6
  import os
7
7
  import re
8
+ import sys
8
9
  from collections.abc import Callable
9
- from contextlib import AsyncExitStack
10
+ from contextlib import AsyncExitStack, suppress
11
+ from dataclasses import dataclass
10
12
  from importlib import import_module
11
- from typing import Any, Protocol
13
+ from typing import Any, Protocol, TextIO, cast
12
14
 
13
15
  from flowent.storage import StateStore, StoredMcpServer, StoredMcpTool
14
16
  from flowent.tools import ToolResult
15
17
 
16
18
  logger = logging.getLogger("flowent.mcp")
17
19
  MCP_CONNECT_TIMEOUT_SECONDS = 10
20
+ PYTHON_TRACEBACK_START = "Traceback (most recent call last):"
21
+ PYTHON_TRACEBACK_TERMINAL_PATTERN = re.compile(
22
+ r"^(?:[A-Za-z_][A-Za-z0-9_.]*(?:Error|Exception|Interrupt|Warning)|"
23
+ r"BaseExceptionGroup|ExceptionGroup)(?::|$)"
24
+ )
18
25
 
19
26
 
20
27
  class McpTransport(Protocol):
@@ -119,54 +126,230 @@ def expand_mcp_config(config: dict[str, object]) -> dict[str, object]:
119
126
  return expanded if isinstance(expanded, dict) else {}
120
127
 
121
128
 
129
+ @dataclass
130
+ class _McpConnection:
131
+ close_event: asyncio.Event
132
+ ready: asyncio.Future[list[dict[str, object]]]
133
+ owner_task: asyncio.Task[None] | None = None
134
+ session: Any = None
135
+
136
+
137
+ class _McpStdioErrorFilter:
138
+ def __init__(self, target: TextIO) -> None:
139
+ self.target = target
140
+ self.line_buffer = ""
141
+ self.traceback_lines: list[str] | None = None
142
+
143
+ def feed(self, text: str) -> None:
144
+ self.line_buffer += text
145
+ while "\n" in self.line_buffer:
146
+ line, self.line_buffer = self.line_buffer.split("\n", 1)
147
+ self.feed_line(f"{line}\n")
148
+
149
+ def finish(self) -> None:
150
+ if self.line_buffer:
151
+ self.feed_line(self.line_buffer)
152
+ self.line_buffer = ""
153
+ if self.traceback_lines is not None:
154
+ self.write("".join(self.traceback_lines))
155
+ self.traceback_lines = None
156
+
157
+ def feed_line(self, line: str) -> None:
158
+ stripped_line = line.rstrip("\r\n")
159
+ if self.traceback_lines is not None:
160
+ self.traceback_lines.append(line)
161
+ if stripped_line == "KeyboardInterrupt":
162
+ self.traceback_lines = None
163
+ return
164
+ if PYTHON_TRACEBACK_TERMINAL_PATTERN.match(stripped_line):
165
+ self.write("".join(self.traceback_lines))
166
+ self.traceback_lines = None
167
+ return
168
+ if stripped_line == PYTHON_TRACEBACK_START:
169
+ self.traceback_lines = [line]
170
+ return
171
+ self.write(line)
172
+
173
+ def write(self, text: str) -> None:
174
+ self.target.write(text)
175
+ self.target.flush()
176
+
177
+
178
+ class _McpStdioErrorLog:
179
+ def __init__(self, target: TextIO | None = None) -> None:
180
+ self.target = target or sys.stderr
181
+ self.filter = _McpStdioErrorFilter(self.target)
182
+ self.read_fd, write_fd = os.pipe()
183
+ self.write_file = os.fdopen(write_fd, "wb", buffering=0)
184
+ self.drain_task: asyncio.Task[None] | None = None
185
+
186
+ async def __aenter__(self) -> _McpStdioErrorLog:
187
+ self.drain_task = asyncio.create_task(self.drain())
188
+ return self
189
+
190
+ async def __aexit__(
191
+ self,
192
+ exc_type: type[BaseException] | None,
193
+ exc: BaseException | None,
194
+ traceback: object,
195
+ ) -> None:
196
+ self.close_write_file()
197
+ if self.drain_task is not None:
198
+ await asyncio.gather(self.drain_task, return_exceptions=True)
199
+ else:
200
+ self.close_read_fd()
201
+
202
+ def fileno(self) -> int:
203
+ return self.write_file.fileno()
204
+
205
+ async def drain(self) -> None:
206
+ try:
207
+ while True:
208
+ chunk = await asyncio.to_thread(os.read, self.read_fd, 4096)
209
+ if not chunk:
210
+ break
211
+ self.filter.feed(chunk.decode("utf-8", errors="replace"))
212
+ except OSError:
213
+ pass
214
+ finally:
215
+ self.filter.finish()
216
+ self.close_read_fd()
217
+
218
+ def close_write_file(self) -> None:
219
+ with suppress(OSError, ValueError):
220
+ self.write_file.close()
221
+
222
+ def close_read_fd(self) -> None:
223
+ with suppress(OSError):
224
+ os.close(self.read_fd)
225
+
226
+
122
227
  class DefaultMcpTransport:
123
228
  def __init__(self) -> None:
124
- self._sessions: dict[str, Any] = {}
125
- self._stacks: dict[str, AsyncExitStack] = {}
229
+ self._connections: dict[str, _McpConnection] = {}
126
230
 
127
231
  async def connect(self, server: StoredMcpServer) -> list[dict[str, object]]:
232
+ await self.disconnect(server.id)
233
+ loop = asyncio.get_running_loop()
234
+ connection = _McpConnection(
235
+ close_event=asyncio.Event(),
236
+ ready=loop.create_future(),
237
+ )
238
+ connection.owner_task = asyncio.create_task(
239
+ self._run_connection(server, connection)
240
+ )
241
+ self._connections[server.id] = connection
242
+ try:
243
+ return await asyncio.shield(connection.ready)
244
+ except asyncio.CancelledError:
245
+ await self._close_connection(
246
+ server.id,
247
+ connection,
248
+ cancel_owner=True,
249
+ suppress_errors=True,
250
+ )
251
+ raise
252
+ except Exception:
253
+ await self._close_connection(
254
+ server.id,
255
+ connection,
256
+ cancel_owner=False,
257
+ suppress_errors=True,
258
+ )
259
+ raise
260
+
261
+ async def _run_connection(
262
+ self,
263
+ server: StoredMcpServer,
264
+ connection: _McpConnection,
265
+ ) -> None:
128
266
  from mcp import ClientSession
129
267
  from mcp.client.stdio import stdio_client
130
268
 
131
- await self.disconnect(server.id)
132
269
  stack = AsyncExitStack()
133
- config = expand_mcp_config(server.config)
134
- if server.type == "url":
135
- http_module = import_module("mcp.client.streamable_http")
136
- http_headers = self._streamable_http_headers(config) or None
137
- if hasattr(http_module, "streamablehttp_client"):
138
- read_stream, write_stream, _ = await stack.enter_async_context(
139
- http_module.streamablehttp_client(
140
- server.url or str(config.get("url") or ""),
141
- headers=http_headers,
270
+ try:
271
+ async with stack:
272
+ config = expand_mcp_config(server.config)
273
+ if server.type == "url":
274
+ http_module = import_module("mcp.client.streamable_http")
275
+ http_headers = self._streamable_http_headers(config) or None
276
+ if hasattr(http_module, "streamablehttp_client"):
277
+ read_stream, write_stream, _ = await stack.enter_async_context(
278
+ http_module.streamablehttp_client(
279
+ server.url or str(config.get("url") or ""),
280
+ headers=http_headers,
281
+ )
282
+ )
283
+ else:
284
+ import httpx
285
+
286
+ http_client = await stack.enter_async_context(
287
+ httpx.AsyncClient(headers=http_headers)
288
+ )
289
+ read_stream, write_stream, _ = await stack.enter_async_context(
290
+ http_module.streamable_http_client(
291
+ server.url or str(config.get("url") or ""),
292
+ http_client=http_client,
293
+ )
294
+ )
295
+ else:
296
+ stdio_errlog = await stack.enter_async_context(_McpStdioErrorLog())
297
+ read_stream, write_stream = await stack.enter_async_context(
298
+ stdio_client(
299
+ self._stdio_parameters(server, config),
300
+ errlog=cast(TextIO, stdio_errlog),
301
+ )
142
302
  )
303
+ session = await stack.enter_async_context(
304
+ ClientSession(read_stream, write_stream)
143
305
  )
144
- else:
145
- import httpx
146
-
147
- http_client = await stack.enter_async_context(
148
- httpx.AsyncClient(headers=http_headers)
149
- )
150
- read_stream, write_stream, _ = await stack.enter_async_context(
151
- http_module.streamable_http_client(
152
- server.url or str(config.get("url") or ""),
153
- http_client=http_client,
306
+ await session.initialize()
307
+ result = await session.list_tools()
308
+ connection.session = session
309
+ if not connection.ready.done():
310
+ connection.ready.set_result(
311
+ [self._model_dump(tool) for tool in result.tools]
154
312
  )
155
- )
156
- else:
157
- read_stream, write_stream = await stack.enter_async_context(
158
- stdio_client(
159
- self._stdio_parameters(server, config),
160
- )
161
- )
162
- session = await stack.enter_async_context(
163
- ClientSession(read_stream, write_stream)
164
- )
165
- await session.initialize()
166
- result = await session.list_tools()
167
- self._sessions[server.id] = session
168
- self._stacks[server.id] = stack
169
- return [self._model_dump(tool) for tool in result.tools]
313
+ await connection.close_event.wait()
314
+ except asyncio.CancelledError:
315
+ if not connection.ready.done():
316
+ connection.ready.cancel()
317
+ raise
318
+ except Exception as error:
319
+ if not connection.ready.done():
320
+ connection.ready.set_exception(error)
321
+ raise
322
+ finally:
323
+ connection.session = None
324
+
325
+ async def _close_connection(
326
+ self,
327
+ server_id: str,
328
+ connection: _McpConnection,
329
+ *,
330
+ cancel_owner: bool,
331
+ suppress_errors: bool = False,
332
+ ) -> None:
333
+ if self._connections.get(server_id) is connection:
334
+ self._connections.pop(server_id, None)
335
+ connection.session = None
336
+ owner_task = connection.owner_task
337
+ if owner_task is None:
338
+ return
339
+ if not owner_task.done():
340
+ if cancel_owner:
341
+ owner_task.cancel()
342
+ else:
343
+ connection.close_event.set()
344
+ try:
345
+ await asyncio.shield(owner_task)
346
+ except asyncio.CancelledError:
347
+ if cancel_owner or suppress_errors:
348
+ return
349
+ raise
350
+ except Exception:
351
+ if not suppress_errors:
352
+ raise
170
353
 
171
354
  def _streamable_http_headers(self, config: dict[str, object]) -> dict[str, str]:
172
355
  headers: dict[str, str] = {}
@@ -238,10 +421,13 @@ class DefaultMcpTransport:
238
421
  )
239
422
 
240
423
  async def disconnect(self, server_id: str) -> None:
241
- stack = self._stacks.pop(server_id, None)
242
- self._sessions.pop(server_id, None)
243
- if stack is not None:
244
- await stack.aclose()
424
+ connection = self._connections.pop(server_id, None)
425
+ if connection is not None:
426
+ await self._close_connection(
427
+ server_id,
428
+ connection,
429
+ cancel_owner=not connection.ready.done(),
430
+ )
245
431
 
246
432
  async def call_tool(
247
433
  self,
@@ -249,7 +435,8 @@ class DefaultMcpTransport:
249
435
  tool_name: str,
250
436
  arguments: dict[str, object],
251
437
  ) -> dict[str, object]:
252
- session = self._sessions.get(server_id)
438
+ connection = self._connections.get(server_id)
439
+ session = connection.session if connection is not None else None
253
440
  if session is None:
254
441
  raise RuntimeError("Server is not connected.")
255
442
  result = await session.call_tool(tool_name, arguments=arguments)
@@ -0,0 +1,5 @@
1
+ from flowent._version import __version__
2
+
3
+
4
+ def flowent_user_agent() -> str:
5
+ return f"Flowent/{__version__}"
@@ -348,7 +348,11 @@ def tool_failure_text(result: ToolResult) -> str:
348
348
  stderr = str(result.data.get("stderr", "") or "").strip()
349
349
  stdout = str(result.data.get("stdout", "") or "").strip()
350
350
  content = result.content.strip()
351
- return "\n".join(part for part in [stderr, stdout, content] if part)
351
+ parts: list[str] = []
352
+ for part in [stderr, stdout, content]:
353
+ if part and part not in parts:
354
+ parts.append(part)
355
+ return "\n".join(parts)
352
356
 
353
357
 
354
358
  async def shell_command_without_sandbox(
@@ -0,0 +1,42 @@
1
+ import logging
2
+
3
+ from fastapi import HTTPException
4
+
5
+ from flowent.llm import ProviderConnection
6
+ from flowent.storage import StoredState
7
+
8
+ logger = logging.getLogger("flowent.provider_connections")
9
+
10
+
11
+ def selected_connection(state: StoredState) -> ProviderConnection:
12
+ provider = next(
13
+ (
14
+ stored_provider
15
+ for stored_provider in state.providers
16
+ if stored_provider.id == state.settings.selected_provider_id
17
+ ),
18
+ None,
19
+ )
20
+ if provider is None or not state.settings.selected_model:
21
+ logger.warning("Workspace request blocked because provider or model is missing")
22
+ raise HTTPException(
23
+ status_code=400,
24
+ detail="Choose a provider and model before sending.",
25
+ )
26
+ if not provider.api_key:
27
+ logger.warning("Workspace request blocked because selected provider has no key")
28
+ raise HTTPException(status_code=400, detail="Add a key before sending.")
29
+
30
+ logger.debug(
31
+ "Workspace request using provider=%s model=%s",
32
+ provider.name,
33
+ state.settings.selected_model,
34
+ )
35
+ return ProviderConnection(
36
+ base_url=provider.base_url or None,
37
+ model=state.settings.selected_model,
38
+ name=provider.name,
39
+ provider=provider.type,
40
+ reasoning_effort=state.settings.reasoning_effort,
41
+ secret_reference=provider.api_key,
42
+ )
File without changes
@@ -0,0 +1,105 @@
1
+ from pathlib import Path
2
+
3
+ from fastapi import FastAPI, HTTPException
4
+
5
+ from flowent.api_models import (
6
+ McpImportPreviewRequest,
7
+ McpImportRequest,
8
+ SkillSettingsRequest,
9
+ TelegramSessionApproveRequest,
10
+ )
11
+ from flowent.channels import TelegramBotManager
12
+ from flowent.mcp import McpManager
13
+ from flowent.mcp_import import McpImportDiscovery, discover_imported_mcp_servers
14
+ from flowent.skills import discover_skills, update_skill_enabled
15
+ from flowent.storage import (
16
+ StateStore,
17
+ StoredMcpServer,
18
+ StoredSkill,
19
+ StoredTelegramBot,
20
+ StoredTelegramSession,
21
+ )
22
+
23
+
24
+ def register_integration_routes(
25
+ app: FastAPI,
26
+ *,
27
+ cwd: Path,
28
+ mcp_manager: McpManager,
29
+ store: StateStore,
30
+ telegram_bot_manager: TelegramBotManager,
31
+ ) -> None:
32
+ @app.put("/api/mcp/servers")
33
+ async def save_mcp_server(server: StoredMcpServer) -> StoredMcpServer:
34
+ saved_server = store.save_mcp_server(server)
35
+ return await mcp_manager.sync_server(saved_server)
36
+
37
+ @app.post("/api/mcp/import/preview")
38
+ async def preview_mcp_import(
39
+ request: McpImportPreviewRequest,
40
+ ) -> McpImportDiscovery:
41
+ return discover_imported_mcp_servers(cwd, source=request.source)
42
+
43
+ @app.post("/api/mcp/import")
44
+ async def import_mcp_servers(request: McpImportRequest) -> list[StoredMcpServer]:
45
+ imported_servers = discover_imported_mcp_servers(
46
+ cwd,
47
+ source=request.source,
48
+ ).servers
49
+ existing_servers = {server.id for server in store.read_mcp_servers()}
50
+ for server in imported_servers:
51
+ if server.id != request.server_id:
52
+ continue
53
+ if server.id in existing_servers:
54
+ continue
55
+ store.save_mcp_server(server)
56
+ existing_servers.add(server.id)
57
+ return mcp_manager.servers_with_status(store.read_mcp_servers())
58
+
59
+ @app.delete("/api/mcp/servers/{server_id}")
60
+ async def delete_mcp_server(server_id: str) -> dict[str, bool]:
61
+ await mcp_manager.delete_server(server_id)
62
+ return {"ok": True}
63
+
64
+ @app.post("/api/mcp/servers/{server_id}/reconnect")
65
+ async def reconnect_mcp_server(server_id: str) -> StoredMcpServer:
66
+ try:
67
+ return await mcp_manager.reconnect_server(server_id)
68
+ except KeyError as error:
69
+ raise HTTPException(status_code=404, detail="Server not found.") from error
70
+
71
+ @app.post("/api/mcp/reload")
72
+ async def reload_mcp_servers() -> list[StoredMcpServer]:
73
+ return await mcp_manager.reload()
74
+
75
+ @app.post("/api/skills/reload")
76
+ async def reload_skills() -> list[StoredSkill]:
77
+ return discover_skills(cwd, store)
78
+
79
+ @app.put("/api/skills/{skill_id:path}")
80
+ async def save_skill_settings(
81
+ skill_id: str,
82
+ request: SkillSettingsRequest,
83
+ ) -> StoredSkill:
84
+ try:
85
+ return update_skill_enabled(cwd, store, skill_id, request.enabled)
86
+ except KeyError as error:
87
+ raise HTTPException(status_code=404, detail="Skill not found.") from error
88
+
89
+ @app.put("/api/telegram-bot")
90
+ async def save_telegram_bot(telegram_bot: StoredTelegramBot) -> StoredTelegramBot:
91
+ saved_bot = store.save_telegram_bot(telegram_bot)
92
+ await telegram_bot_manager.sync_bot(saved_bot)
93
+ return telegram_bot_manager.bot_with_status(saved_bot)
94
+
95
+ @app.post("/api/telegram-bot/approve")
96
+ async def approve_telegram_session(
97
+ request: TelegramSessionApproveRequest,
98
+ ) -> StoredTelegramSession:
99
+ try:
100
+ return store.approve_telegram_session(request.chat_id)
101
+ except KeyError as error:
102
+ raise HTTPException(
103
+ status_code=404,
104
+ detail="Conversation not found.",
105
+ ) from error
@@ -0,0 +1,36 @@
1
+ from pathlib import Path
2
+
3
+ from fastapi import FastAPI
4
+
5
+ from flowent.api_models import WritablePathListResponse, WritablePathRequest
6
+ from flowent.storage import StateStore, StoredWritablePath
7
+
8
+
9
+ def normalized_request_path(path: str, cwd: Path) -> Path:
10
+ raw_path = Path(path).expanduser()
11
+ if not raw_path.is_absolute():
12
+ raw_path = cwd / raw_path
13
+ return raw_path.resolve(strict=False)
14
+
15
+
16
+ def register_permission_routes(
17
+ app: FastAPI,
18
+ *,
19
+ cwd: Path,
20
+ store: StateStore,
21
+ ) -> None:
22
+ @app.post("/api/permissions/writable-paths")
23
+ async def save_writable_path(
24
+ request: WritablePathRequest,
25
+ ) -> StoredWritablePath:
26
+ return store.save_writable_path(normalized_request_path(request.path, cwd))
27
+
28
+ @app.delete("/api/permissions/writable-paths")
29
+ async def delete_writable_path(
30
+ request: WritablePathRequest,
31
+ ) -> WritablePathListResponse:
32
+ return WritablePathListResponse(
33
+ writable_paths=store.delete_writable_path(
34
+ normalized_request_path(request.path, cwd)
35
+ )
36
+ )
@@ -0,0 +1,30 @@
1
+ from fastapi import FastAPI
2
+
3
+ from flowent.api_models import ProviderModelsRequest, ProviderModelsResponse
4
+ from flowent.llm import list_provider_models
5
+ from flowent.storage import StateStore, StoredProvider, StoredSettings
6
+
7
+
8
+ def register_provider_routes(app: FastAPI, *, store: StateStore) -> None:
9
+ @app.post("/api/providers")
10
+ async def save_provider(provider: StoredProvider) -> StoredProvider:
11
+ return store.save_provider(provider)
12
+
13
+ @app.delete("/api/providers/{provider_id}")
14
+ async def delete_provider(provider_id: str) -> dict[str, bool]:
15
+ store.delete_provider(provider_id)
16
+ return {"ok": True}
17
+
18
+ @app.post("/api/providers/models")
19
+ async def provider_models(request: ProviderModelsRequest) -> ProviderModelsResponse:
20
+ return ProviderModelsResponse(
21
+ models=list_provider_models(
22
+ base_url=request.base_url,
23
+ provider=request.provider,
24
+ secret_reference=request.secret_reference,
25
+ ),
26
+ )
27
+
28
+ @app.put("/api/settings")
29
+ async def save_settings(settings: StoredSettings) -> StoredSettings:
30
+ return store.save_settings(settings)
@@ -0,0 +1,49 @@
1
+ from pathlib import Path
2
+
3
+ from fastapi import FastAPI
4
+
5
+ from flowent._version import __version__
6
+ from flowent.api_models import AboutResponse
7
+ from flowent.channels import TelegramBotManager
8
+ from flowent.mcp import McpManager
9
+ from flowent.skills import discover_skills
10
+ from flowent.storage import StateStore, StoredState
11
+ from flowent.workspace.context import state_with_current_model_context_window
12
+ from flowent.workspace.runtime import WorkspaceRuntime
13
+
14
+
15
+ def register_system_routes(
16
+ app: FastAPI,
17
+ *,
18
+ cwd: Path,
19
+ mcp_manager: McpManager,
20
+ runtime: WorkspaceRuntime,
21
+ store: StateStore,
22
+ telegram_bot_manager: TelegramBotManager,
23
+ ) -> None:
24
+ @app.get("/api/health")
25
+ async def health() -> dict[str, str]:
26
+ return {"status": "ok"}
27
+
28
+ @app.get("/api/state")
29
+ async def app_state() -> StoredState:
30
+ state = state_with_current_model_context_window(store.read_state())
31
+ active_run = runtime.active_run()
32
+ update: dict[str, object] = {
33
+ "active_run_event_index": active_run.latest_event_index
34
+ if active_run
35
+ else 0,
36
+ "active_run_id": active_run.id
37
+ if active_run and not active_run.is_done
38
+ else None,
39
+ "mcp_servers": mcp_manager.servers_with_status(state.mcp_servers),
40
+ "skills": discover_skills(cwd, store),
41
+ }
42
+ update["telegram_bot"] = telegram_bot_manager.bot_with_status(
43
+ state.telegram_bot
44
+ )
45
+ return state.model_copy(update=update)
46
+
47
+ @app.get("/api/about")
48
+ async def about() -> AboutResponse:
49
+ return AboutResponse(version=__version__)
@@ -0,0 +1,63 @@
1
+ from fastapi import FastAPI, HTTPException
2
+
3
+ from flowent.llm import CompletionCallable
4
+ from flowent.provider_connections import selected_connection
5
+ from flowent.storage import StateStore, StoredWorkflow
6
+ from flowent.workflows import (
7
+ WorkflowRunResponse,
8
+ run_workflow_definition,
9
+ validate_workflow_draft,
10
+ workflow_requires_connection,
11
+ )
12
+
13
+
14
+ def register_workflow_routes(
15
+ app: FastAPI,
16
+ *,
17
+ chat_completion: CompletionCallable | None,
18
+ store: StateStore,
19
+ ) -> None:
20
+ @app.put("/api/workflows")
21
+ async def save_workflow(workflow: StoredWorkflow) -> StoredWorkflow:
22
+ try:
23
+ return store.save_workflow(
24
+ validate_workflow_draft(
25
+ workflow.model_copy(
26
+ update={"name": workflow.name.strip() or "Untitled Workflow"}
27
+ )
28
+ )
29
+ )
30
+ except ValueError as error:
31
+ raise HTTPException(status_code=400, detail=str(error)) from error
32
+
33
+ @app.delete("/api/workflows/{workflow_id}")
34
+ async def delete_workflow(workflow_id: str) -> dict[str, bool]:
35
+ store.delete_workflow(workflow_id)
36
+ return {"ok": True}
37
+
38
+ @app.post("/api/workflows/{workflow_id}/run")
39
+ async def run_workflow(workflow_id: str) -> WorkflowRunResponse:
40
+ workflow = next(
41
+ (
42
+ current_workflow
43
+ for current_workflow in store.read_workflows()
44
+ if current_workflow.id == workflow_id
45
+ ),
46
+ None,
47
+ )
48
+ if workflow is None:
49
+ raise HTTPException(status_code=404, detail="Workflow not found.")
50
+ try:
51
+ connection = (
52
+ selected_connection(store.read_state())
53
+ if workflow_requires_connection(workflow.definition)
54
+ else None
55
+ )
56
+ return await run_workflow_definition(
57
+ completion=chat_completion,
58
+ connection=connection,
59
+ definition=workflow.definition,
60
+ workflow_id=workflow.id,
61
+ )
62
+ except ValueError as error:
63
+ raise HTTPException(status_code=400, detail=str(error)) from error