Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,4 +10,5 @@ test-results/
.e2e-flaky
dist/
*.egg-info/
.worktrees/
.worktrees/
uv.lock
15 changes: 12 additions & 3 deletions Makefile
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
.DEFAULT_GOAL := help

.PHONY: help test smoke
.PHONY: help test smoke install-ui build-ui

PYTHON := $(if $(wildcard .venv/bin/python),.venv/bin/python,python3)
FRONTEND_DIR := agentflow/web/frontend

help:
@printf '%s\n' \
'Available targets:' \
' test Run the test suite' \
' smoke Run the default smoke pipeline'
' test Run the test suite' \
' smoke Run the default smoke pipeline' \
' install-ui Install frontend dependencies' \
' build-ui Build the frontend dashboard'

test:
$(PYTHON) -m pytest -q

smoke:
$(PYTHON) -m agentflow run examples/airflow_like.py --output summary

install-ui:
cd $(FRONTEND_DIR) && npm install

build-ui:
cd $(FRONTEND_DIR) && npm run build
6 changes: 6 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,12 @@ python3 -m venv .venv && . .venv/bin/activate
pip install -e .[dev]
```

To build the dashboard (optional, requires Node.js):
```bash
make install-ui
make build-ui
```

## Quick Start

```python
Expand Down
28 changes: 28 additions & 0 deletions agentflow/app.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,11 +84,28 @@ def create_app(*, store: RunStore | None = None, orchestrator: Orchestrator | No
app.state.orchestrator = orchestrator

base_dir = os.path.join(os.path.dirname(__file__), "web")
frontend_dist = os.path.join(base_dir, "frontend", "dist")

# Mount Vite static assets
if os.path.isdir(frontend_dist):
assets_dir = os.path.join(frontend_dist, "assets")
if os.path.isdir(assets_dir):
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")

# Public folder/static files inside dist
app.mount("/public", StaticFiles(directory=frontend_dist), name="public-dist")

# Legacy mounts and templates
templates = Jinja2Templates(directory=os.path.join(base_dir, "templates"))
app.mount("/static", StaticFiles(directory=os.path.join(base_dir, "static")), name="static")

@app.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse:
react_index = os.path.join(frontend_dist, "index.html")
if os.path.isfile(react_index):
with open(react_index, "r") as f:
return HTMLResponse(content=f.read(), status_code=200)

return templates.TemplateResponse(
"index.html",
{"request": request, "example": _load_default_web_example(), "base_dir": os.getcwd()},
Expand DownExpand Up@@ -153,6 +170,17 @@ async def get_artifact(run_id: str, node_id: str, name: str) -> PlainTextRespons
raise HTTPException(status_code=404, detail="artifact not found") from exc
return PlainTextResponse(content)

@app.get("/api/runs/{run_id}/scratchboard")
async def get_scratchboard(run_id: str) -> PlainTextResponse:
from agentflow.scratchboard import SCRATCHBOARD_FILENAME
try:
path = app.state.store.run_dir(run_id) / SCRATCHBOARD_FILENAME

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This read-only endpoint has a write side effect for arbitrary run_ids. run_dir(run_id) creates the directory if it does not exist, so GET /api/runs/<missing>/scratchboard returns 200 with an empty body and leaves a new run directory on disk instead of returning 404 like the other per-run endpoints.

I reproduced this with a nonexistent run id and ended up with a new runs/<id>/ directory after the request. Please validate that the run exists before calling run_dir().

if not path.exists():
return PlainTextResponse("")
return PlainTextResponse(path.read_text(encoding="utf-8"))
except Exception as exc:
raise HTTPException(status_code=404, detail="scratchboard not found") from exc

@app.get("/api/runs/{run_id}/stream")
async def stream_run(run_id: str):
if run_id not in {run.id for run in app.state.store.list_runs()}:
Expand Down
91 changes: 69 additions & 22 deletions agentflow/store.py
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import json
import logging
import queue
import threading
from collections import defaultdict
Expand All@@ -12,6 +13,9 @@
from agentflow.specs import RunEvent, RunRecord
from agentflow.utils import ensure_dir

# Set up a dedicated logger for sync issues
sync_logger = logging.getLogger("agentflow.sync")


class RunStore:
def __init__(self, base_dir: str | Path = ".agentflow/runs") -> None:
Expand All@@ -20,24 +24,48 @@ def __init__(self, base_dir: str | Path = ".agentflow/runs") -> None:
self._locks: defaultdict[str, threading.Lock] = defaultdict(threading.Lock)
self._subscribers: defaultdict[str, set[queue.Queue[RunEvent]]] = defaultdict(set)
self._events_cache: defaultdict[str, list[RunEvent]] = defaultdict(list)
self._load_existing_runs()

def _load_existing_runs(self) -> None:
for run_file in sorted(self.base_dir.glob("*/run.json")):
run_id = run_file.parent.name
try:
run = RunRecord.model_validate_json(run_file.read_text(encoding="utf-8"))
self._runs[run_id] = run
events_path = run_file.parent / "events.jsonl"
if events_path.exists():
events = [
RunEvent.model_validate_json(line)
for line in events_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
self._events_cache[run_id] = events
except (OSError, ValidationError, json.JSONDecodeError, KeyError):
continue
self._mtimes: dict[str, float] = {}
self._sync_runs()

def _sync_runs(self) -> None:
"""Synchronize in-memory cache with the filesystem."""
try:
run_files = list(self.base_dir.glob("*/run.json"))
for run_file in sorted(run_files):
run_id = run_file.parent.name
try:
mtime = run_file.stat().st_mtime
if run_id not in self._runs or self._mtimes.get(run_id, 0) < mtime:
content = run_file.read_text(encoding="utf-8")
if not content.strip():
continue

run = RunRecord.model_validate_json(content)
# Only update if status changed or it's new
if run_id not in self._runs or self._runs[run_id].status != run.status:
sync_logger.debug(f"Syncing run {run_id}: status {run.status}")

self._runs[run_id] = run

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This swaps the live RunRecord object out from under the active orchestrator. orchestrator.run() captures the original record once and keeps mutating it, but _sync_runs() replaces self._runs[run_id] with a brand-new model whenever the file on disk changes. After a cross-process update like cancel() from a fresh RunStore, the in-flight run keeps updating the stale object while the store keeps returning the replacement object, so terminal state never settles correctly.

tests/test_orchestrator.py::test_orchestrator_honors_cancel_request_from_fresh_instance now times out on this branch, and it passes on master. Please avoid replacing live run objects here, or limit disk sync to inactive runs.

self._mtimes[run_id] = mtime

# Sync events
events_path = run_file.parent / "events.jsonl"
if events_path.exists():
event_mtime = events_path.stat().st_mtime
if self._mtimes.get(f"{run_id}_events", 0) < event_mtime:
events = [
RunEvent.model_validate_json(line)
for line in events_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
self._events_cache[run_id] = events
self._mtimes[f"{run_id}_events"] = event_mtime
except (OSError, ValidationError, json.JSONDecodeError, KeyError) as e:
sync_logger.error(f"Failed to sync {run_id}: {e}")
continue
except OSError as e:
sync_logger.error(f"Sync error: {e}")
pass

async def create_run(self, record: RunRecord | None = None) -> RunRecord:
if record is None:
Expand All@@ -63,10 +91,26 @@ def cancel_request_path(self, run_id: str) -> Path:

async def persist_run(self, run_id: str) -> None:
record = self._runs[run_id]
run_dir = self.run_dir(run_id)
lock = self._locks[run_id]
with lock:
(run_dir / "run.json").write_text(record.model_dump_json(indent=2), encoding="utf-8")
path = self.run_dir(run_id) / "run.json"

# Check if disk is newer before overwriting
if path.exists():
try:
disk_mtime = path.stat().st_mtime
if self._mtimes.get(run_id, 0) < disk_mtime:
# Disk is newer, reload first to avoid overwriting clinical updates (like failed status)
disk_content = path.read_text(encoding="utf-8")
disk_record = RunRecord.model_validate_json(disk_content)
if disk_record.status in ("failed", "cancelled", "completed"):
# Don't overwrite terminal status
self._runs[run_id] = disk_record
self._mtimes[run_id] = disk_mtime
return
except Exception as e:
sync_logger.error(f"Error checking disk state for {run_id}: {e}")

path.write_text(record.model_dump_json(indent=2), encoding="utf-8")
self._mtimes[run_id] = path.stat().st_mtime

async def append_event(self, run_id: str, event: RunEvent) -> None:
lock = self._locks[run_id]
Expand DownExpand Up@@ -112,12 +156,15 @@ def read_artifact_text(self, run_id: str, node_id: str, name: str) -> str:
return self.artifact_path(run_id, node_id, name).read_text(encoding="utf-8")

def get_run(self, run_id: str) -> RunRecord:
self._sync_runs()
return self._runs[run_id]

def list_runs(self) -> list[RunRecord]:
self._sync_runs()
return sorted(self._runs.values(), key=lambda run: run.created_at, reverse=True)

def get_events(self, run_id: str) -> list[RunEvent]:
self._sync_runs()
return list(self._events_cache[run_id])

async def subscribe(self, run_id: str) -> queue.Queue[RunEvent]:
Expand Down
24 changes: 24 additions & 0 deletions agentflow/web/frontend/.gitignore
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
73 changes: 73 additions & 0 deletions agentflow/web/frontend/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
# React + TypeScript + Vite

This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.

Currently, two official plugins are available:

- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)

## React Compiler

The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).

## Expanding the ESLint configuration

If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:

```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...

// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,

// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:

```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'

export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
23 changes: 23 additions & 0 deletions agentflow/web/frontend/eslint.config.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'

export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
13 changes: 13 additions & 0 deletions agentflow/web/frontend/index.html
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
<!doctype html>
<htmllang="en">
<head>
<metacharset="UTF-8" />
<linkrel="icon" type="image/svg+xml" href="/favicon.svg" />
<metaname="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<divid="root"></div>
<scripttype="module" src="/src/main.tsx"></script>
</body>
</html>
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Feat frontend by o3o3o · Pull Request #4 · agentenv/agentflow · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,4 +10,5 @@ test-results/
.e2e-flaky
dist/
*.egg-info/
.worktrees/
.worktrees/
uv.lock
15 changes: 12 additions & 3 deletions Makefile
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
.DEFAULT_GOAL := help

.PHONY: help test smoke
.PHONY: help test smoke install-ui build-ui

PYTHON := $(if $(wildcard .venv/bin/python),.venv/bin/python,python3)
FRONTEND_DIR := agentflow/web/frontend

help:
@printf '%s\n' \
'Available targets:' \
' test Run the test suite' \
' smoke Run the default smoke pipeline'
' test Run the test suite' \
' smoke Run the default smoke pipeline' \
' install-ui Install frontend dependencies' \
' build-ui Build the frontend dashboard'

test:
$(PYTHON) -m pytest -q

smoke:
$(PYTHON) -m agentflow run examples/airflow_like.py --output summary

install-ui:
cd $(FRONTEND_DIR) && npm install

build-ui:
cd $(FRONTEND_DIR) && npm run build
6 changes: 6 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,12 @@ python3 -m venv .venv && . .venv/bin/activate
pip install -e .[dev]
```

To build the dashboard (optional, requires Node.js):
```bash
make install-ui
make build-ui
```

## Quick Start

```python
Expand Down
28 changes: 28 additions & 0 deletions agentflow/app.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,11 +84,28 @@ def create_app(*, store: RunStore | None = None, orchestrator: Orchestrator | No
app.state.orchestrator = orchestrator

base_dir = os.path.join(os.path.dirname(__file__), "web")
frontend_dist = os.path.join(base_dir, "frontend", "dist")

# Mount Vite static assets
if os.path.isdir(frontend_dist):
assets_dir = os.path.join(frontend_dist, "assets")
if os.path.isdir(assets_dir):
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")

# Public folder/static files inside dist
app.mount("/public", StaticFiles(directory=frontend_dist), name="public-dist")

# Legacy mounts and templates
templates = Jinja2Templates(directory=os.path.join(base_dir, "templates"))
app.mount("/static", StaticFiles(directory=os.path.join(base_dir, "static")), name="static")

@app.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse:
react_index = os.path.join(frontend_dist, "index.html")
if os.path.isfile(react_index):
with open(react_index, "r") as f:
return HTMLResponse(content=f.read(), status_code=200)

return templates.TemplateResponse(
"index.html",
{"request": request, "example": _load_default_web_example(), "base_dir": os.getcwd()},
Expand DownExpand Up@@ -153,6 +170,17 @@ async def get_artifact(run_id: str, node_id: str, name: str) -> PlainTextRespons
raise HTTPException(status_code=404, detail="artifact not found") from exc
return PlainTextResponse(content)

@app.get("/api/runs/{run_id}/scratchboard")
async def get_scratchboard(run_id: str) -> PlainTextResponse:
from agentflow.scratchboard import SCRATCHBOARD_FILENAME
try:
path = app.state.store.run_dir(run_id) / SCRATCHBOARD_FILENAME

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This read-only endpoint has a write side effect for arbitrary run_ids. run_dir(run_id) creates the directory if it does not exist, so GET /api/runs/<missing>/scratchboard returns 200 with an empty body and leaves a new run directory on disk instead of returning 404 like the other per-run endpoints.

I reproduced this with a nonexistent run id and ended up with a new runs/<id>/ directory after the request. Please validate that the run exists before calling run_dir().

if not path.exists():
return PlainTextResponse("")
return PlainTextResponse(path.read_text(encoding="utf-8"))
except Exception as exc:
raise HTTPException(status_code=404, detail="scratchboard not found") from exc

@app.get("/api/runs/{run_id}/stream")
async def stream_run(run_id: str):
if run_id not in {run.id for run in app.state.store.list_runs()}:
Expand Down
91 changes: 69 additions & 22 deletions agentflow/store.py
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import json
import logging
import queue
import threading
from collections import defaultdict
Expand All@@ -12,6 +13,9 @@
from agentflow.specs import RunEvent, RunRecord
from agentflow.utils import ensure_dir

# Set up a dedicated logger for sync issues
sync_logger = logging.getLogger("agentflow.sync")


class RunStore:
def __init__(self, base_dir: str | Path = ".agentflow/runs") -> None:
Expand All@@ -20,24 +24,48 @@ def __init__(self, base_dir: str | Path = ".agentflow/runs") -> None:
self._locks: defaultdict[str, threading.Lock] = defaultdict(threading.Lock)
self._subscribers: defaultdict[str, set[queue.Queue[RunEvent]]] = defaultdict(set)
self._events_cache: defaultdict[str, list[RunEvent]] = defaultdict(list)
self._load_existing_runs()

def _load_existing_runs(self) -> None:
for run_file in sorted(self.base_dir.glob("*/run.json")):
run_id = run_file.parent.name
try:
run = RunRecord.model_validate_json(run_file.read_text(encoding="utf-8"))
self._runs[run_id] = run
events_path = run_file.parent / "events.jsonl"
if events_path.exists():
events = [
RunEvent.model_validate_json(line)
for line in events_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
self._events_cache[run_id] = events
except (OSError, ValidationError, json.JSONDecodeError, KeyError):
continue
self._mtimes: dict[str, float] = {}
self._sync_runs()

def _sync_runs(self) -> None:
"""Synchronize in-memory cache with the filesystem."""
try:
run_files = list(self.base_dir.glob("*/run.json"))
for run_file in sorted(run_files):
run_id = run_file.parent.name
try:
mtime = run_file.stat().st_mtime
if run_id not in self._runs or self._mtimes.get(run_id, 0) < mtime:
content = run_file.read_text(encoding="utf-8")
if not content.strip():
continue

run = RunRecord.model_validate_json(content)
# Only update if status changed or it's new
if run_id not in self._runs or self._runs[run_id].status != run.status:
sync_logger.debug(f"Syncing run {run_id}: status {run.status}")

self._runs[run_id] = run

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This swaps the live RunRecord object out from under the active orchestrator. orchestrator.run() captures the original record once and keeps mutating it, but _sync_runs() replaces self._runs[run_id] with a brand-new model whenever the file on disk changes. After a cross-process update like cancel() from a fresh RunStore, the in-flight run keeps updating the stale object while the store keeps returning the replacement object, so terminal state never settles correctly.

tests/test_orchestrator.py::test_orchestrator_honors_cancel_request_from_fresh_instance now times out on this branch, and it passes on master. Please avoid replacing live run objects here, or limit disk sync to inactive runs.

self._mtimes[run_id] = mtime

# Sync events
events_path = run_file.parent / "events.jsonl"
if events_path.exists():
event_mtime = events_path.stat().st_mtime
if self._mtimes.get(f"{run_id}_events", 0) < event_mtime:
events = [
RunEvent.model_validate_json(line)
for line in events_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
self._events_cache[run_id] = events
self._mtimes[f"{run_id}_events"] = event_mtime
except (OSError, ValidationError, json.JSONDecodeError, KeyError) as e:
sync_logger.error(f"Failed to sync {run_id}: {e}")
continue
except OSError as e:
sync_logger.error(f"Sync error: {e}")
pass

async def create_run(self, record: RunRecord | None = None) -> RunRecord:
if record is None:
Expand All@@ -63,10 +91,26 @@ def cancel_request_path(self, run_id: str) -> Path:

async def persist_run(self, run_id: str) -> None:
record = self._runs[run_id]
run_dir = self.run_dir(run_id)
lock = self._locks[run_id]
with lock:
(run_dir / "run.json").write_text(record.model_dump_json(indent=2), encoding="utf-8")
path = self.run_dir(run_id) / "run.json"

# Check if disk is newer before overwriting
if path.exists():
try:
disk_mtime = path.stat().st_mtime
if self._mtimes.get(run_id, 0) < disk_mtime:
# Disk is newer, reload first to avoid overwriting clinical updates (like failed status)
disk_content = path.read_text(encoding="utf-8")
disk_record = RunRecord.model_validate_json(disk_content)
if disk_record.status in ("failed", "cancelled", "completed"):
# Don't overwrite terminal status
self._runs[run_id] = disk_record
self._mtimes[run_id] = disk_mtime
return
except Exception as e:
sync_logger.error(f"Error checking disk state for {run_id}: {e}")

path.write_text(record.model_dump_json(indent=2), encoding="utf-8")
self._mtimes[run_id] = path.stat().st_mtime

async def append_event(self, run_id: str, event: RunEvent) -> None:
lock = self._locks[run_id]
Expand DownExpand Up@@ -112,12 +156,15 @@ def read_artifact_text(self, run_id: str, node_id: str, name: str) -> str:
return self.artifact_path(run_id, node_id, name).read_text(encoding="utf-8")

def get_run(self, run_id: str) -> RunRecord:
self._sync_runs()
return self._runs[run_id]

def list_runs(self) -> list[RunRecord]:
self._sync_runs()
return sorted(self._runs.values(), key=lambda run: run.created_at, reverse=True)

def get_events(self, run_id: str) -> list[RunEvent]:
self._sync_runs()
return list(self._events_cache[run_id])

async def subscribe(self, run_id: str) -> queue.Queue[RunEvent]:
Expand Down
24 changes: 24 additions & 0 deletions agentflow/web/frontend/.gitignore
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
73 changes: 73 additions & 0 deletions agentflow/web/frontend/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
# React + TypeScript + Vite

This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.

Currently, two official plugins are available:

- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)

## React Compiler

The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).

## Expanding the ESLint configuration

If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:

```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...

// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,

// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:

```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'

export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
23 changes: 23 additions & 0 deletions agentflow/web/frontend/eslint.config.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'

export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
13 changes: 13 additions & 0 deletions agentflow/web/frontend/index.html
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
<!doctype html>
<htmllang="en">
<head>
<metacharset="UTF-8" />
<linkrel="icon" type="image/svg+xml" href="/favicon.svg" />
<metaname="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<divid="root"></div>
<scripttype="module" src="/src/main.tsx"></script>
</body>
</html>
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Feat frontend by o3o3o · Pull Request #4 · agentenv/agentflow · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,4 +10,5 @@ test-results/
.e2e-flaky
dist/
*.egg-info/
.worktrees/
.worktrees/
uv.lock
15 changes: 12 additions & 3 deletions Makefile
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
.DEFAULT_GOAL := help

.PHONY: help test smoke
.PHONY: help test smoke install-ui build-ui

PYTHON := $(if $(wildcard .venv/bin/python),.venv/bin/python,python3)
FRONTEND_DIR := agentflow/web/frontend

help:
@printf '%s\n' \
'Available targets:' \
' test Run the test suite' \
' smoke Run the default smoke pipeline'
' test Run the test suite' \
' smoke Run the default smoke pipeline' \
' install-ui Install frontend dependencies' \
' build-ui Build the frontend dashboard'

test:
$(PYTHON) -m pytest -q

smoke:
$(PYTHON) -m agentflow run examples/airflow_like.py --output summary

install-ui:
cd $(FRONTEND_DIR) && npm install

build-ui:
cd $(FRONTEND_DIR) && npm run build
6 changes: 6 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,12 @@ python3 -m venv .venv && . .venv/bin/activate
pip install -e .[dev]
```

To build the dashboard (optional, requires Node.js):
```bash
make install-ui
make build-ui
```

## Quick Start

```python
Expand Down
28 changes: 28 additions & 0 deletions agentflow/app.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,11 +84,28 @@ def create_app(*, store: RunStore | None = None, orchestrator: Orchestrator | No
app.state.orchestrator = orchestrator

base_dir = os.path.join(os.path.dirname(__file__), "web")
frontend_dist = os.path.join(base_dir, "frontend", "dist")

# Mount Vite static assets
if os.path.isdir(frontend_dist):
assets_dir = os.path.join(frontend_dist, "assets")
if os.path.isdir(assets_dir):
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")

# Public folder/static files inside dist
app.mount("/public", StaticFiles(directory=frontend_dist), name="public-dist")

# Legacy mounts and templates
templates = Jinja2Templates(directory=os.path.join(base_dir, "templates"))
app.mount("/static", StaticFiles(directory=os.path.join(base_dir, "static")), name="static")

@app.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse:
react_index = os.path.join(frontend_dist, "index.html")
if os.path.isfile(react_index):
with open(react_index, "r") as f:
return HTMLResponse(content=f.read(), status_code=200)

return templates.TemplateResponse(
"index.html",
{"request": request, "example": _load_default_web_example(), "base_dir": os.getcwd()},
Expand DownExpand Up@@ -153,6 +170,17 @@ async def get_artifact(run_id: str, node_id: str, name: str) -> PlainTextRespons
raise HTTPException(status_code=404, detail="artifact not found") from exc
return PlainTextResponse(content)

@app.get("/api/runs/{run_id}/scratchboard")
async def get_scratchboard(run_id: str) -> PlainTextResponse:
from agentflow.scratchboard import SCRATCHBOARD_FILENAME
try:
path = app.state.store.run_dir(run_id) / SCRATCHBOARD_FILENAME

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This read-only endpoint has a write side effect for arbitrary run_ids. run_dir(run_id) creates the directory if it does not exist, so GET /api/runs/<missing>/scratchboard returns 200 with an empty body and leaves a new run directory on disk instead of returning 404 like the other per-run endpoints.

I reproduced this with a nonexistent run id and ended up with a new runs/<id>/ directory after the request. Please validate that the run exists before calling run_dir().

if not path.exists():
return PlainTextResponse("")
return PlainTextResponse(path.read_text(encoding="utf-8"))
except Exception as exc:
raise HTTPException(status_code=404, detail="scratchboard not found") from exc

@app.get("/api/runs/{run_id}/stream")
async def stream_run(run_id: str):
if run_id not in {run.id for run in app.state.store.list_runs()}:
Expand Down
91 changes: 69 additions & 22 deletions agentflow/store.py
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import json
import logging
import queue
import threading
from collections import defaultdict
Expand All@@ -12,6 +13,9 @@
from agentflow.specs import RunEvent, RunRecord
from agentflow.utils import ensure_dir

# Set up a dedicated logger for sync issues
sync_logger = logging.getLogger("agentflow.sync")


class RunStore:
def __init__(self, base_dir: str | Path = ".agentflow/runs") -> None:
Expand All@@ -20,24 +24,48 @@ def __init__(self, base_dir: str | Path = ".agentflow/runs") -> None:
self._locks: defaultdict[str, threading.Lock] = defaultdict(threading.Lock)
self._subscribers: defaultdict[str, set[queue.Queue[RunEvent]]] = defaultdict(set)
self._events_cache: defaultdict[str, list[RunEvent]] = defaultdict(list)
self._load_existing_runs()

def _load_existing_runs(self) -> None:
for run_file in sorted(self.base_dir.glob("*/run.json")):
run_id = run_file.parent.name
try:
run = RunRecord.model_validate_json(run_file.read_text(encoding="utf-8"))
self._runs[run_id] = run
events_path = run_file.parent / "events.jsonl"
if events_path.exists():
events = [
RunEvent.model_validate_json(line)
for line in events_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
self._events_cache[run_id] = events
except (OSError, ValidationError, json.JSONDecodeError, KeyError):
continue
self._mtimes: dict[str, float] = {}
self._sync_runs()

def _sync_runs(self) -> None:
"""Synchronize in-memory cache with the filesystem."""
try:
run_files = list(self.base_dir.glob("*/run.json"))
for run_file in sorted(run_files):
run_id = run_file.parent.name
try:
mtime = run_file.stat().st_mtime
if run_id not in self._runs or self._mtimes.get(run_id, 0) < mtime:
content = run_file.read_text(encoding="utf-8")
if not content.strip():
continue

run = RunRecord.model_validate_json(content)
# Only update if status changed or it's new
if run_id not in self._runs or self._runs[run_id].status != run.status:
sync_logger.debug(f"Syncing run {run_id}: status {run.status}")

self._runs[run_id] = run

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This swaps the live RunRecord object out from under the active orchestrator. orchestrator.run() captures the original record once and keeps mutating it, but _sync_runs() replaces self._runs[run_id] with a brand-new model whenever the file on disk changes. After a cross-process update like cancel() from a fresh RunStore, the in-flight run keeps updating the stale object while the store keeps returning the replacement object, so terminal state never settles correctly.

tests/test_orchestrator.py::test_orchestrator_honors_cancel_request_from_fresh_instance now times out on this branch, and it passes on master. Please avoid replacing live run objects here, or limit disk sync to inactive runs.

self._mtimes[run_id] = mtime

# Sync events
events_path = run_file.parent / "events.jsonl"
if events_path.exists():
event_mtime = events_path.stat().st_mtime
if self._mtimes.get(f"{run_id}_events", 0) < event_mtime:
events = [
RunEvent.model_validate_json(line)
for line in events_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
self._events_cache[run_id] = events
self._mtimes[f"{run_id}_events"] = event_mtime
except (OSError, ValidationError, json.JSONDecodeError, KeyError) as e:
sync_logger.error(f"Failed to sync {run_id}: {e}")
continue
except OSError as e:
sync_logger.error(f"Sync error: {e}")
pass

async def create_run(self, record: RunRecord | None = None) -> RunRecord:
if record is None:
Expand All@@ -63,10 +91,26 @@ def cancel_request_path(self, run_id: str) -> Path:

async def persist_run(self, run_id: str) -> None:
record = self._runs[run_id]
run_dir = self.run_dir(run_id)
lock = self._locks[run_id]
with lock:
(run_dir / "run.json").write_text(record.model_dump_json(indent=2), encoding="utf-8")
path = self.run_dir(run_id) / "run.json"

# Check if disk is newer before overwriting
if path.exists():
try:
disk_mtime = path.stat().st_mtime
if self._mtimes.get(run_id, 0) < disk_mtime:
# Disk is newer, reload first to avoid overwriting clinical updates (like failed status)
disk_content = path.read_text(encoding="utf-8")
disk_record = RunRecord.model_validate_json(disk_content)
if disk_record.status in ("failed", "cancelled", "completed"):
# Don't overwrite terminal status
self._runs[run_id] = disk_record
self._mtimes[run_id] = disk_mtime
return
except Exception as e:
sync_logger.error(f"Error checking disk state for {run_id}: {e}")

path.write_text(record.model_dump_json(indent=2), encoding="utf-8")
self._mtimes[run_id] = path.stat().st_mtime

async def append_event(self, run_id: str, event: RunEvent) -> None:
lock = self._locks[run_id]
Expand DownExpand Up@@ -112,12 +156,15 @@ def read_artifact_text(self, run_id: str, node_id: str, name: str) -> str:
return self.artifact_path(run_id, node_id, name).read_text(encoding="utf-8")

def get_run(self, run_id: str) -> RunRecord:
self._sync_runs()
return self._runs[run_id]

def list_runs(self) -> list[RunRecord]:
self._sync_runs()
return sorted(self._runs.values(), key=lambda run: run.created_at, reverse=True)

def get_events(self, run_id: str) -> list[RunEvent]:
self._sync_runs()
return list(self._events_cache[run_id])

async def subscribe(self, run_id: str) -> queue.Queue[RunEvent]:
Expand Down
24 changes: 24 additions & 0 deletions agentflow/web/frontend/.gitignore
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
73 changes: 73 additions & 0 deletions agentflow/web/frontend/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
# React + TypeScript + Vite

This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.

Currently, two official plugins are available:

- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)

## React Compiler

The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).

## Expanding the ESLint configuration

If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:

```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...

// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,

// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:

```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'

export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
23 changes: 23 additions & 0 deletions agentflow/web/frontend/eslint.config.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'

export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
13 changes: 13 additions & 0 deletions agentflow/web/frontend/index.html
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
<!doctype html>
<htmllang="en">
<head>
<metacharset="UTF-8" />
<linkrel="icon" type="image/svg+xml" href="/favicon.svg" />
<metaname="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<divid="root"></div>
<scripttype="module" src="/src/main.tsx"></script>
</body>
</html>
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Feat frontend by o3o3o · Pull Request #4 · agentenv/agentflow · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,4 +10,5 @@ test-results/
.e2e-flaky
dist/
*.egg-info/
.worktrees/
.worktrees/
uv.lock
15 changes: 12 additions & 3 deletions Makefile
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
.DEFAULT_GOAL := help

.PHONY: help test smoke
.PHONY: help test smoke install-ui build-ui

PYTHON := $(if $(wildcard .venv/bin/python),.venv/bin/python,python3)
FRONTEND_DIR := agentflow/web/frontend

help:
@printf '%s\n' \
'Available targets:' \
' test Run the test suite' \
' smoke Run the default smoke pipeline'
' test Run the test suite' \
' smoke Run the default smoke pipeline' \
' install-ui Install frontend dependencies' \
' build-ui Build the frontend dashboard'

test:
$(PYTHON) -m pytest -q

smoke:
$(PYTHON) -m agentflow run examples/airflow_like.py --output summary

install-ui:
cd $(FRONTEND_DIR) && npm install

build-ui:
cd $(FRONTEND_DIR) && npm run build
6 changes: 6 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,12 @@ python3 -m venv .venv && . .venv/bin/activate
pip install -e .[dev]
```

To build the dashboard (optional, requires Node.js):
```bash
make install-ui
make build-ui
```

## Quick Start

```python
Expand Down
28 changes: 28 additions & 0 deletions agentflow/app.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,11 +84,28 @@ def create_app(*, store: RunStore | None = None, orchestrator: Orchestrator | No
app.state.orchestrator = orchestrator

base_dir = os.path.join(os.path.dirname(__file__), "web")
frontend_dist = os.path.join(base_dir, "frontend", "dist")

# Mount Vite static assets
if os.path.isdir(frontend_dist):
assets_dir = os.path.join(frontend_dist, "assets")
if os.path.isdir(assets_dir):
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")

# Public folder/static files inside dist
app.mount("/public", StaticFiles(directory=frontend_dist), name="public-dist")

# Legacy mounts and templates
templates = Jinja2Templates(directory=os.path.join(base_dir, "templates"))
app.mount("/static", StaticFiles(directory=os.path.join(base_dir, "static")), name="static")

@app.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse:
react_index = os.path.join(frontend_dist, "index.html")
if os.path.isfile(react_index):
with open(react_index, "r") as f:
return HTMLResponse(content=f.read(), status_code=200)

return templates.TemplateResponse(
"index.html",
{"request": request, "example": _load_default_web_example(), "base_dir": os.getcwd()},
Expand DownExpand Up@@ -153,6 +170,17 @@ async def get_artifact(run_id: str, node_id: str, name: str) -> PlainTextRespons
raise HTTPException(status_code=404, detail="artifact not found") from exc
return PlainTextResponse(content)

@app.get("/api/runs/{run_id}/scratchboard")
async def get_scratchboard(run_id: str) -> PlainTextResponse:
from agentflow.scratchboard import SCRATCHBOARD_FILENAME
try:
path = app.state.store.run_dir(run_id) / SCRATCHBOARD_FILENAME

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This read-only endpoint has a write side effect for arbitrary run_ids. run_dir(run_id) creates the directory if it does not exist, so GET /api/runs/<missing>/scratchboard returns 200 with an empty body and leaves a new run directory on disk instead of returning 404 like the other per-run endpoints.

I reproduced this with a nonexistent run id and ended up with a new runs/<id>/ directory after the request. Please validate that the run exists before calling run_dir().

if not path.exists():
return PlainTextResponse("")
return PlainTextResponse(path.read_text(encoding="utf-8"))
except Exception as exc:
raise HTTPException(status_code=404, detail="scratchboard not found") from exc

@app.get("/api/runs/{run_id}/stream")
async def stream_run(run_id: str):
if run_id not in {run.id for run in app.state.store.list_runs()}:
Expand Down
91 changes: 69 additions & 22 deletions agentflow/store.py
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import json
import logging
import queue
import threading
from collections import defaultdict
Expand All@@ -12,6 +13,9 @@
from agentflow.specs import RunEvent, RunRecord
from agentflow.utils import ensure_dir

# Set up a dedicated logger for sync issues
sync_logger = logging.getLogger("agentflow.sync")


class RunStore:
def __init__(self, base_dir: str | Path = ".agentflow/runs") -> None:
Expand All@@ -20,24 +24,48 @@ def __init__(self, base_dir: str | Path = ".agentflow/runs") -> None:
self._locks: defaultdict[str, threading.Lock] = defaultdict(threading.Lock)
self._subscribers: defaultdict[str, set[queue.Queue[RunEvent]]] = defaultdict(set)
self._events_cache: defaultdict[str, list[RunEvent]] = defaultdict(list)
self._load_existing_runs()

def _load_existing_runs(self) -> None:
for run_file in sorted(self.base_dir.glob("*/run.json")):
run_id = run_file.parent.name
try:
run = RunRecord.model_validate_json(run_file.read_text(encoding="utf-8"))
self._runs[run_id] = run
events_path = run_file.parent / "events.jsonl"
if events_path.exists():
events = [
RunEvent.model_validate_json(line)
for line in events_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
self._events_cache[run_id] = events
except (OSError, ValidationError, json.JSONDecodeError, KeyError):
continue
self._mtimes: dict[str, float] = {}
self._sync_runs()

def _sync_runs(self) -> None:
"""Synchronize in-memory cache with the filesystem."""
try:
run_files = list(self.base_dir.glob("*/run.json"))
for run_file in sorted(run_files):
run_id = run_file.parent.name
try:
mtime = run_file.stat().st_mtime
if run_id not in self._runs or self._mtimes.get(run_id, 0) < mtime:
content = run_file.read_text(encoding="utf-8")
if not content.strip():
continue

run = RunRecord.model_validate_json(content)
# Only update if status changed or it's new
if run_id not in self._runs or self._runs[run_id].status != run.status:
sync_logger.debug(f"Syncing run {run_id}: status {run.status}")

self._runs[run_id] = run

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This swaps the live RunRecord object out from under the active orchestrator. orchestrator.run() captures the original record once and keeps mutating it, but _sync_runs() replaces self._runs[run_id] with a brand-new model whenever the file on disk changes. After a cross-process update like cancel() from a fresh RunStore, the in-flight run keeps updating the stale object while the store keeps returning the replacement object, so terminal state never settles correctly.

tests/test_orchestrator.py::test_orchestrator_honors_cancel_request_from_fresh_instance now times out on this branch, and it passes on master. Please avoid replacing live run objects here, or limit disk sync to inactive runs.

self._mtimes[run_id] = mtime

# Sync events
events_path = run_file.parent / "events.jsonl"
if events_path.exists():
event_mtime = events_path.stat().st_mtime
if self._mtimes.get(f"{run_id}_events", 0) < event_mtime:
events = [
RunEvent.model_validate_json(line)
for line in events_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
self._events_cache[run_id] = events
self._mtimes[f"{run_id}_events"] = event_mtime
except (OSError, ValidationError, json.JSONDecodeError, KeyError) as e:
sync_logger.error(f"Failed to sync {run_id}: {e}")
continue
except OSError as e:
sync_logger.error(f"Sync error: {e}")
pass

async def create_run(self, record: RunRecord | None = None) -> RunRecord:
if record is None:
Expand All@@ -63,10 +91,26 @@ def cancel_request_path(self, run_id: str) -> Path:

async def persist_run(self, run_id: str) -> None:
record = self._runs[run_id]
run_dir = self.run_dir(run_id)
lock = self._locks[run_id]
with lock:
(run_dir / "run.json").write_text(record.model_dump_json(indent=2), encoding="utf-8")
path = self.run_dir(run_id) / "run.json"

# Check if disk is newer before overwriting
if path.exists():
try:
disk_mtime = path.stat().st_mtime
if self._mtimes.get(run_id, 0) < disk_mtime:
# Disk is newer, reload first to avoid overwriting clinical updates (like failed status)
disk_content = path.read_text(encoding="utf-8")
disk_record = RunRecord.model_validate_json(disk_content)
if disk_record.status in ("failed", "cancelled", "completed"):
# Don't overwrite terminal status
self._runs[run_id] = disk_record
self._mtimes[run_id] = disk_mtime
return
except Exception as e:
sync_logger.error(f"Error checking disk state for {run_id}: {e}")

path.write_text(record.model_dump_json(indent=2), encoding="utf-8")
self._mtimes[run_id] = path.stat().st_mtime

async def append_event(self, run_id: str, event: RunEvent) -> None:
lock = self._locks[run_id]
Expand DownExpand Up@@ -112,12 +156,15 @@ def read_artifact_text(self, run_id: str, node_id: str, name: str) -> str:
return self.artifact_path(run_id, node_id, name).read_text(encoding="utf-8")

def get_run(self, run_id: str) -> RunRecord:
self._sync_runs()
return self._runs[run_id]

def list_runs(self) -> list[RunRecord]:
self._sync_runs()
return sorted(self._runs.values(), key=lambda run: run.created_at, reverse=True)

def get_events(self, run_id: str) -> list[RunEvent]:
self._sync_runs()
return list(self._events_cache[run_id])

async def subscribe(self, run_id: str) -> queue.Queue[RunEvent]:
Expand Down
24 changes: 24 additions & 0 deletions agentflow/web/frontend/.gitignore
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
73 changes: 73 additions & 0 deletions agentflow/web/frontend/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
# React + TypeScript + Vite

This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.

Currently, two official plugins are available:

- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)

## React Compiler

The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).

## Expanding the ESLint configuration

If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:

```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...

// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,

// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:

```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'

export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
23 changes: 23 additions & 0 deletions agentflow/web/frontend/eslint.config.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'

export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
13 changes: 13 additions & 0 deletions agentflow/web/frontend/index.html
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
<!doctype html>
<htmllang="en">
<head>
<metacharset="UTF-8" />
<linkrel="icon" type="image/svg+xml" href="/favicon.svg" />
<metaname="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<divid="root"></div>
<scripttype="module" src="/src/main.tsx"></script>
</body>
</html>
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Feat frontend by o3o3o · Pull Request #4 · agentenv/agentflow · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,4 +10,5 @@ test-results/
.e2e-flaky
dist/
*.egg-info/
.worktrees/
.worktrees/
uv.lock
15 changes: 12 additions & 3 deletions Makefile
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
.DEFAULT_GOAL := help

.PHONY: help test smoke
.PHONY: help test smoke install-ui build-ui

PYTHON := $(if $(wildcard .venv/bin/python),.venv/bin/python,python3)
FRONTEND_DIR := agentflow/web/frontend

help:
@printf '%s\n' \
'Available targets:' \
' test Run the test suite' \
' smoke Run the default smoke pipeline'
' test Run the test suite' \
' smoke Run the default smoke pipeline' \
' install-ui Install frontend dependencies' \
' build-ui Build the frontend dashboard'

test:
$(PYTHON) -m pytest -q

smoke:
$(PYTHON) -m agentflow run examples/airflow_like.py --output summary

install-ui:
cd $(FRONTEND_DIR) && npm install

build-ui:
cd $(FRONTEND_DIR) && npm run build
6 changes: 6 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,12 @@ python3 -m venv .venv && . .venv/bin/activate
pip install -e .[dev]
```

To build the dashboard (optional, requires Node.js):
```bash
make install-ui
make build-ui
```

## Quick Start

```python
Expand Down
28 changes: 28 additions & 0 deletions agentflow/app.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,11 +84,28 @@ def create_app(*, store: RunStore | None = None, orchestrator: Orchestrator | No
app.state.orchestrator = orchestrator

base_dir = os.path.join(os.path.dirname(__file__), "web")
frontend_dist = os.path.join(base_dir, "frontend", "dist")

# Mount Vite static assets
if os.path.isdir(frontend_dist):
assets_dir = os.path.join(frontend_dist, "assets")
if os.path.isdir(assets_dir):
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")

# Public folder/static files inside dist
app.mount("/public", StaticFiles(directory=frontend_dist), name="public-dist")

# Legacy mounts and templates
templates = Jinja2Templates(directory=os.path.join(base_dir, "templates"))
app.mount("/static", StaticFiles(directory=os.path.join(base_dir, "static")), name="static")

@app.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse:
react_index = os.path.join(frontend_dist, "index.html")
if os.path.isfile(react_index):
with open(react_index, "r") as f:
return HTMLResponse(content=f.read(), status_code=200)

return templates.TemplateResponse(
"index.html",
{"request": request, "example": _load_default_web_example(), "base_dir": os.getcwd()},
Expand DownExpand Up@@ -153,6 +170,17 @@ async def get_artifact(run_id: str, node_id: str, name: str) -> PlainTextRespons
raise HTTPException(status_code=404, detail="artifact not found") from exc
return PlainTextResponse(content)

@app.get("/api/runs/{run_id}/scratchboard")
async def get_scratchboard(run_id: str) -> PlainTextResponse:
from agentflow.scratchboard import SCRATCHBOARD_FILENAME
try:
path = app.state.store.run_dir(run_id) / SCRATCHBOARD_FILENAME

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This read-only endpoint has a write side effect for arbitrary run_ids. run_dir(run_id) creates the directory if it does not exist, so GET /api/runs/<missing>/scratchboard returns 200 with an empty body and leaves a new run directory on disk instead of returning 404 like the other per-run endpoints.

I reproduced this with a nonexistent run id and ended up with a new runs/<id>/ directory after the request. Please validate that the run exists before calling run_dir().

if not path.exists():
return PlainTextResponse("")
return PlainTextResponse(path.read_text(encoding="utf-8"))
except Exception as exc:
raise HTTPException(status_code=404, detail="scratchboard not found") from exc

@app.get("/api/runs/{run_id}/stream")
async def stream_run(run_id: str):
if run_id not in {run.id for run in app.state.store.list_runs()}:
Expand Down
91 changes: 69 additions & 22 deletions agentflow/store.py
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import json
import logging
import queue
import threading
from collections import defaultdict
Expand All@@ -12,6 +13,9 @@
from agentflow.specs import RunEvent, RunRecord
from agentflow.utils import ensure_dir

# Set up a dedicated logger for sync issues
sync_logger = logging.getLogger("agentflow.sync")


class RunStore:
def __init__(self, base_dir: str | Path = ".agentflow/runs") -> None:
Expand All@@ -20,24 +24,48 @@ def __init__(self, base_dir: str | Path = ".agentflow/runs") -> None:
self._locks: defaultdict[str, threading.Lock] = defaultdict(threading.Lock)
self._subscribers: defaultdict[str, set[queue.Queue[RunEvent]]] = defaultdict(set)
self._events_cache: defaultdict[str, list[RunEvent]] = defaultdict(list)
self._load_existing_runs()

def _load_existing_runs(self) -> None:
for run_file in sorted(self.base_dir.glob("*/run.json")):
run_id = run_file.parent.name
try:
run = RunRecord.model_validate_json(run_file.read_text(encoding="utf-8"))
self._runs[run_id] = run
events_path = run_file.parent / "events.jsonl"
if events_path.exists():
events = [
RunEvent.model_validate_json(line)
for line in events_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
self._events_cache[run_id] = events
except (OSError, ValidationError, json.JSONDecodeError, KeyError):
continue
self._mtimes: dict[str, float] = {}
self._sync_runs()

def _sync_runs(self) -> None:
"""Synchronize in-memory cache with the filesystem."""
try:
run_files = list(self.base_dir.glob("*/run.json"))
for run_file in sorted(run_files):
run_id = run_file.parent.name
try:
mtime = run_file.stat().st_mtime
if run_id not in self._runs or self._mtimes.get(run_id, 0) < mtime:
content = run_file.read_text(encoding="utf-8")
if not content.strip():
continue

run = RunRecord.model_validate_json(content)
# Only update if status changed or it's new
if run_id not in self._runs or self._runs[run_id].status != run.status:
sync_logger.debug(f"Syncing run {run_id}: status {run.status}")

self._runs[run_id] = run

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This swaps the live RunRecord object out from under the active orchestrator. orchestrator.run() captures the original record once and keeps mutating it, but _sync_runs() replaces self._runs[run_id] with a brand-new model whenever the file on disk changes. After a cross-process update like cancel() from a fresh RunStore, the in-flight run keeps updating the stale object while the store keeps returning the replacement object, so terminal state never settles correctly.

tests/test_orchestrator.py::test_orchestrator_honors_cancel_request_from_fresh_instance now times out on this branch, and it passes on master. Please avoid replacing live run objects here, or limit disk sync to inactive runs.

self._mtimes[run_id] = mtime

# Sync events
events_path = run_file.parent / "events.jsonl"
if events_path.exists():
event_mtime = events_path.stat().st_mtime
if self._mtimes.get(f"{run_id}_events", 0) < event_mtime:
events = [
RunEvent.model_validate_json(line)
for line in events_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
self._events_cache[run_id] = events
self._mtimes[f"{run_id}_events"] = event_mtime
except (OSError, ValidationError, json.JSONDecodeError, KeyError) as e:
sync_logger.error(f"Failed to sync {run_id}: {e}")
continue
except OSError as e:
sync_logger.error(f"Sync error: {e}")
pass

async def create_run(self, record: RunRecord | None = None) -> RunRecord:
if record is None:
Expand All@@ -63,10 +91,26 @@ def cancel_request_path(self, run_id: str) -> Path:

async def persist_run(self, run_id: str) -> None:
record = self._runs[run_id]
run_dir = self.run_dir(run_id)
lock = self._locks[run_id]
with lock:
(run_dir / "run.json").write_text(record.model_dump_json(indent=2), encoding="utf-8")
path = self.run_dir(run_id) / "run.json"

# Check if disk is newer before overwriting
if path.exists():
try:
disk_mtime = path.stat().st_mtime
if self._mtimes.get(run_id, 0) < disk_mtime:
# Disk is newer, reload first to avoid overwriting clinical updates (like failed status)
disk_content = path.read_text(encoding="utf-8")
disk_record = RunRecord.model_validate_json(disk_content)
if disk_record.status in ("failed", "cancelled", "completed"):
# Don't overwrite terminal status
self._runs[run_id] = disk_record
self._mtimes[run_id] = disk_mtime
return
except Exception as e:
sync_logger.error(f"Error checking disk state for {run_id}: {e}")

path.write_text(record.model_dump_json(indent=2), encoding="utf-8")
self._mtimes[run_id] = path.stat().st_mtime

async def append_event(self, run_id: str, event: RunEvent) -> None:
lock = self._locks[run_id]
Expand DownExpand Up@@ -112,12 +156,15 @@ def read_artifact_text(self, run_id: str, node_id: str, name: str) -> str:
return self.artifact_path(run_id, node_id, name).read_text(encoding="utf-8")

def get_run(self, run_id: str) -> RunRecord:
self._sync_runs()
return self._runs[run_id]

def list_runs(self) -> list[RunRecord]:
self._sync_runs()
return sorted(self._runs.values(), key=lambda run: run.created_at, reverse=True)

def get_events(self, run_id: str) -> list[RunEvent]:
self._sync_runs()
return list(self._events_cache[run_id])

async def subscribe(self, run_id: str) -> queue.Queue[RunEvent]:
Expand Down
24 changes: 24 additions & 0 deletions agentflow/web/frontend/.gitignore
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
73 changes: 73 additions & 0 deletions agentflow/web/frontend/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
# React + TypeScript + Vite

This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.

Currently, two official plugins are available:

- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)

## React Compiler

The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).

## Expanding the ESLint configuration

If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:

```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...

// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,

// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:

```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'

export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
23 changes: 23 additions & 0 deletions agentflow/web/frontend/eslint.config.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'

export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
13 changes: 13 additions & 0 deletions agentflow/web/frontend/index.html
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
<!doctype html>
<htmllang="en">
<head>
<metacharset="UTF-8" />
<linkrel="icon" type="image/svg+xml" href="/favicon.svg" />
<metaname="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<divid="root"></div>
<scripttype="module" src="/src/main.tsx"></script>
</body>
</html>
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Feat frontend by o3o3o · Pull Request #4 · agentenv/agentflow · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,4 +10,5 @@ test-results/
.e2e-flaky
dist/
*.egg-info/
.worktrees/
.worktrees/
uv.lock
15 changes: 12 additions & 3 deletions Makefile
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
.DEFAULT_GOAL := help

.PHONY: help test smoke
.PHONY: help test smoke install-ui build-ui

PYTHON := $(if $(wildcard .venv/bin/python),.venv/bin/python,python3)
FRONTEND_DIR := agentflow/web/frontend

help:
@printf '%s\n' \
'Available targets:' \
' test Run the test suite' \
' smoke Run the default smoke pipeline'
' test Run the test suite' \
' smoke Run the default smoke pipeline' \
' install-ui Install frontend dependencies' \
' build-ui Build the frontend dashboard'

test:
$(PYTHON) -m pytest -q

smoke:
$(PYTHON) -m agentflow run examples/airflow_like.py --output summary

install-ui:
cd $(FRONTEND_DIR) && npm install

build-ui:
cd $(FRONTEND_DIR) && npm run build
6 changes: 6 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,12 @@ python3 -m venv .venv && . .venv/bin/activate
pip install -e .[dev]
```

To build the dashboard (optional, requires Node.js):
```bash
make install-ui
make build-ui
```

## Quick Start

```python
Expand Down
28 changes: 28 additions & 0 deletions agentflow/app.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,11 +84,28 @@ def create_app(*, store: RunStore | None = None, orchestrator: Orchestrator | No
app.state.orchestrator = orchestrator

base_dir = os.path.join(os.path.dirname(__file__), "web")
frontend_dist = os.path.join(base_dir, "frontend", "dist")

# Mount Vite static assets
if os.path.isdir(frontend_dist):
assets_dir = os.path.join(frontend_dist, "assets")
if os.path.isdir(assets_dir):
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")

# Public folder/static files inside dist
app.mount("/public", StaticFiles(directory=frontend_dist), name="public-dist")

# Legacy mounts and templates
templates = Jinja2Templates(directory=os.path.join(base_dir, "templates"))
app.mount("/static", StaticFiles(directory=os.path.join(base_dir, "static")), name="static")

@app.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse:
react_index = os.path.join(frontend_dist, "index.html")
if os.path.isfile(react_index):
with open(react_index, "r") as f:
return HTMLResponse(content=f.read(), status_code=200)

return templates.TemplateResponse(
"index.html",
{"request": request, "example": _load_default_web_example(), "base_dir": os.getcwd()},
Expand DownExpand Up@@ -153,6 +170,17 @@ async def get_artifact(run_id: str, node_id: str, name: str) -> PlainTextRespons
raise HTTPException(status_code=404, detail="artifact not found") from exc
return PlainTextResponse(content)

@app.get("/api/runs/{run_id}/scratchboard")
async def get_scratchboard(run_id: str) -> PlainTextResponse:
from agentflow.scratchboard import SCRATCHBOARD_FILENAME
try:
path = app.state.store.run_dir(run_id) / SCRATCHBOARD_FILENAME

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This read-only endpoint has a write side effect for arbitrary run_ids. run_dir(run_id) creates the directory if it does not exist, so GET /api/runs/<missing>/scratchboard returns 200 with an empty body and leaves a new run directory on disk instead of returning 404 like the other per-run endpoints.

I reproduced this with a nonexistent run id and ended up with a new runs/<id>/ directory after the request. Please validate that the run exists before calling run_dir().

if not path.exists():
return PlainTextResponse("")
return PlainTextResponse(path.read_text(encoding="utf-8"))
except Exception as exc:
raise HTTPException(status_code=404, detail="scratchboard not found") from exc

@app.get("/api/runs/{run_id}/stream")
async def stream_run(run_id: str):
if run_id not in {run.id for run in app.state.store.list_runs()}:
Expand Down
91 changes: 69 additions & 22 deletions agentflow/store.py
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import json
import logging
import queue
import threading
from collections import defaultdict
Expand All@@ -12,6 +13,9 @@
from agentflow.specs import RunEvent, RunRecord
from agentflow.utils import ensure_dir

# Set up a dedicated logger for sync issues
sync_logger = logging.getLogger("agentflow.sync")


class RunStore:
def __init__(self, base_dir: str | Path = ".agentflow/runs") -> None:
Expand All@@ -20,24 +24,48 @@ def __init__(self, base_dir: str | Path = ".agentflow/runs") -> None:
self._locks: defaultdict[str, threading.Lock] = defaultdict(threading.Lock)
self._subscribers: defaultdict[str, set[queue.Queue[RunEvent]]] = defaultdict(set)
self._events_cache: defaultdict[str, list[RunEvent]] = defaultdict(list)
self._load_existing_runs()

def _load_existing_runs(self) -> None:
for run_file in sorted(self.base_dir.glob("*/run.json")):
run_id = run_file.parent.name
try:
run = RunRecord.model_validate_json(run_file.read_text(encoding="utf-8"))
self._runs[run_id] = run
events_path = run_file.parent / "events.jsonl"
if events_path.exists():
events = [
RunEvent.model_validate_json(line)
for line in events_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
self._events_cache[run_id] = events
except (OSError, ValidationError, json.JSONDecodeError, KeyError):
continue
self._mtimes: dict[str, float] = {}
self._sync_runs()

def _sync_runs(self) -> None:
"""Synchronize in-memory cache with the filesystem."""
try:
run_files = list(self.base_dir.glob("*/run.json"))
for run_file in sorted(run_files):
run_id = run_file.parent.name
try:
mtime = run_file.stat().st_mtime
if run_id not in self._runs or self._mtimes.get(run_id, 0) < mtime:
content = run_file.read_text(encoding="utf-8")
if not content.strip():
continue

run = RunRecord.model_validate_json(content)
# Only update if status changed or it's new
if run_id not in self._runs or self._runs[run_id].status != run.status:
sync_logger.debug(f"Syncing run {run_id}: status {run.status}")

self._runs[run_id] = run

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This swaps the live RunRecord object out from under the active orchestrator. orchestrator.run() captures the original record once and keeps mutating it, but _sync_runs() replaces self._runs[run_id] with a brand-new model whenever the file on disk changes. After a cross-process update like cancel() from a fresh RunStore, the in-flight run keeps updating the stale object while the store keeps returning the replacement object, so terminal state never settles correctly.

tests/test_orchestrator.py::test_orchestrator_honors_cancel_request_from_fresh_instance now times out on this branch, and it passes on master. Please avoid replacing live run objects here, or limit disk sync to inactive runs.

self._mtimes[run_id] = mtime

# Sync events
events_path = run_file.parent / "events.jsonl"
if events_path.exists():
event_mtime = events_path.stat().st_mtime
if self._mtimes.get(f"{run_id}_events", 0) < event_mtime:
events = [
RunEvent.model_validate_json(line)
for line in events_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
self._events_cache[run_id] = events
self._mtimes[f"{run_id}_events"] = event_mtime
except (OSError, ValidationError, json.JSONDecodeError, KeyError) as e:
sync_logger.error(f"Failed to sync {run_id}: {e}")
continue
except OSError as e:
sync_logger.error(f"Sync error: {e}")
pass

async def create_run(self, record: RunRecord | None = None) -> RunRecord:
if record is None:
Expand All@@ -63,10 +91,26 @@ def cancel_request_path(self, run_id: str) -> Path:

async def persist_run(self, run_id: str) -> None:
record = self._runs[run_id]
run_dir = self.run_dir(run_id)
lock = self._locks[run_id]
with lock:
(run_dir / "run.json").write_text(record.model_dump_json(indent=2), encoding="utf-8")
path = self.run_dir(run_id) / "run.json"

# Check if disk is newer before overwriting
if path.exists():
try:
disk_mtime = path.stat().st_mtime
if self._mtimes.get(run_id, 0) < disk_mtime:
# Disk is newer, reload first to avoid overwriting clinical updates (like failed status)
disk_content = path.read_text(encoding="utf-8")
disk_record = RunRecord.model_validate_json(disk_content)
if disk_record.status in ("failed", "cancelled", "completed"):
# Don't overwrite terminal status
self._runs[run_id] = disk_record
self._mtimes[run_id] = disk_mtime
return
except Exception as e:
sync_logger.error(f"Error checking disk state for {run_id}: {e}")

path.write_text(record.model_dump_json(indent=2), encoding="utf-8")
self._mtimes[run_id] = path.stat().st_mtime

async def append_event(self, run_id: str, event: RunEvent) -> None:
lock = self._locks[run_id]
Expand DownExpand Up@@ -112,12 +156,15 @@ def read_artifact_text(self, run_id: str, node_id: str, name: str) -> str:
return self.artifact_path(run_id, node_id, name).read_text(encoding="utf-8")

def get_run(self, run_id: str) -> RunRecord:
self._sync_runs()
return self._runs[run_id]

def list_runs(self) -> list[RunRecord]:
self._sync_runs()
return sorted(self._runs.values(), key=lambda run: run.created_at, reverse=True)

def get_events(self, run_id: str) -> list[RunEvent]:
self._sync_runs()
return list(self._events_cache[run_id])

async def subscribe(self, run_id: str) -> queue.Queue[RunEvent]:
Expand Down
24 changes: 24 additions & 0 deletions agentflow/web/frontend/.gitignore
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
73 changes: 73 additions & 0 deletions agentflow/web/frontend/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
# React + TypeScript + Vite

This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.

Currently, two official plugins are available:

- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)

## React Compiler

The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).

## Expanding the ESLint configuration

If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:

```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...

// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,

// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:

```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'

export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
23 changes: 23 additions & 0 deletions agentflow/web/frontend/eslint.config.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'

export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
13 changes: 13 additions & 0 deletions agentflow/web/frontend/index.html
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
<!doctype html>
<htmllang="en">
<head>
<metacharset="UTF-8" />
<linkrel="icon" type="image/svg+xml" href="/favicon.svg" />
<metaname="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<divid="root"></div>
<scripttype="module" src="/src/main.tsx"></script>
</body>
</html>
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Feat frontend by o3o3o · Pull Request #4 · agentenv/agentflow · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,4 +10,5 @@ test-results/
.e2e-flaky
dist/
*.egg-info/
.worktrees/
.worktrees/
uv.lock
15 changes: 12 additions & 3 deletions Makefile
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
.DEFAULT_GOAL := help

.PHONY: help test smoke
.PHONY: help test smoke install-ui build-ui

PYTHON := $(if $(wildcard .venv/bin/python),.venv/bin/python,python3)
FRONTEND_DIR := agentflow/web/frontend

help:
@printf '%s\n' \
'Available targets:' \
' test Run the test suite' \
' smoke Run the default smoke pipeline'
' test Run the test suite' \
' smoke Run the default smoke pipeline' \
' install-ui Install frontend dependencies' \
' build-ui Build the frontend dashboard'

test:
$(PYTHON) -m pytest -q

smoke:
$(PYTHON) -m agentflow run examples/airflow_like.py --output summary

install-ui:
cd $(FRONTEND_DIR) && npm install

build-ui:
cd $(FRONTEND_DIR) && npm run build
6 changes: 6 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,12 @@ python3 -m venv .venv && . .venv/bin/activate
pip install -e .[dev]
```

To build the dashboard (optional, requires Node.js):
```bash
make install-ui
make build-ui
```

## Quick Start

```python
Expand Down
28 changes: 28 additions & 0 deletions agentflow/app.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,11 +84,28 @@ def create_app(*, store: RunStore | None = None, orchestrator: Orchestrator | No
app.state.orchestrator = orchestrator

base_dir = os.path.join(os.path.dirname(__file__), "web")
frontend_dist = os.path.join(base_dir, "frontend", "dist")

# Mount Vite static assets
if os.path.isdir(frontend_dist):
assets_dir = os.path.join(frontend_dist, "assets")
if os.path.isdir(assets_dir):
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")

# Public folder/static files inside dist
app.mount("/public", StaticFiles(directory=frontend_dist), name="public-dist")

# Legacy mounts and templates
templates = Jinja2Templates(directory=os.path.join(base_dir, "templates"))
app.mount("/static", StaticFiles(directory=os.path.join(base_dir, "static")), name="static")

@app.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse:
react_index = os.path.join(frontend_dist, "index.html")
if os.path.isfile(react_index):
with open(react_index, "r") as f:
return HTMLResponse(content=f.read(), status_code=200)

return templates.TemplateResponse(
"index.html",
{"request": request, "example": _load_default_web_example(), "base_dir": os.getcwd()},
Expand DownExpand Up@@ -153,6 +170,17 @@ async def get_artifact(run_id: str, node_id: str, name: str) -> PlainTextRespons
raise HTTPException(status_code=404, detail="artifact not found") from exc
return PlainTextResponse(content)

@app.get("/api/runs/{run_id}/scratchboard")
async def get_scratchboard(run_id: str) -> PlainTextResponse:
from agentflow.scratchboard import SCRATCHBOARD_FILENAME
try:
path = app.state.store.run_dir(run_id) / SCRATCHBOARD_FILENAME

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This read-only endpoint has a write side effect for arbitrary run_ids. run_dir(run_id) creates the directory if it does not exist, so GET /api/runs/<missing>/scratchboard returns 200 with an empty body and leaves a new run directory on disk instead of returning 404 like the other per-run endpoints.

I reproduced this with a nonexistent run id and ended up with a new runs/<id>/ directory after the request. Please validate that the run exists before calling run_dir().

if not path.exists():
return PlainTextResponse("")
return PlainTextResponse(path.read_text(encoding="utf-8"))
except Exception as exc:
raise HTTPException(status_code=404, detail="scratchboard not found") from exc

@app.get("/api/runs/{run_id}/stream")
async def stream_run(run_id: str):
if run_id not in {run.id for run in app.state.store.list_runs()}:
Expand Down
91 changes: 69 additions & 22 deletions agentflow/store.py
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import json
import logging
import queue
import threading
from collections import defaultdict
Expand All@@ -12,6 +13,9 @@
from agentflow.specs import RunEvent, RunRecord
from agentflow.utils import ensure_dir

# Set up a dedicated logger for sync issues
sync_logger = logging.getLogger("agentflow.sync")


class RunStore:
def __init__(self, base_dir: str | Path = ".agentflow/runs") -> None:
Expand All@@ -20,24 +24,48 @@ def __init__(self, base_dir: str | Path = ".agentflow/runs") -> None:
self._locks: defaultdict[str, threading.Lock] = defaultdict(threading.Lock)
self._subscribers: defaultdict[str, set[queue.Queue[RunEvent]]] = defaultdict(set)
self._events_cache: defaultdict[str, list[RunEvent]] = defaultdict(list)
self._load_existing_runs()

def _load_existing_runs(self) -> None:
for run_file in sorted(self.base_dir.glob("*/run.json")):
run_id = run_file.parent.name
try:
run = RunRecord.model_validate_json(run_file.read_text(encoding="utf-8"))
self._runs[run_id] = run
events_path = run_file.parent / "events.jsonl"
if events_path.exists():
events = [
RunEvent.model_validate_json(line)
for line in events_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
self._events_cache[run_id] = events
except (OSError, ValidationError, json.JSONDecodeError, KeyError):
continue
self._mtimes: dict[str, float] = {}
self._sync_runs()

def _sync_runs(self) -> None:
"""Synchronize in-memory cache with the filesystem."""
try:
run_files = list(self.base_dir.glob("*/run.json"))
for run_file in sorted(run_files):
run_id = run_file.parent.name
try:
mtime = run_file.stat().st_mtime
if run_id not in self._runs or self._mtimes.get(run_id, 0) < mtime:
content = run_file.read_text(encoding="utf-8")
if not content.strip():
continue

run = RunRecord.model_validate_json(content)
# Only update if status changed or it's new
if run_id not in self._runs or self._runs[run_id].status != run.status:
sync_logger.debug(f"Syncing run {run_id}: status {run.status}")

self._runs[run_id] = run

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This swaps the live RunRecord object out from under the active orchestrator. orchestrator.run() captures the original record once and keeps mutating it, but _sync_runs() replaces self._runs[run_id] with a brand-new model whenever the file on disk changes. After a cross-process update like cancel() from a fresh RunStore, the in-flight run keeps updating the stale object while the store keeps returning the replacement object, so terminal state never settles correctly.

tests/test_orchestrator.py::test_orchestrator_honors_cancel_request_from_fresh_instance now times out on this branch, and it passes on master. Please avoid replacing live run objects here, or limit disk sync to inactive runs.

self._mtimes[run_id] = mtime

# Sync events
events_path = run_file.parent / "events.jsonl"
if events_path.exists():
event_mtime = events_path.stat().st_mtime
if self._mtimes.get(f"{run_id}_events", 0) < event_mtime:
events = [
RunEvent.model_validate_json(line)
for line in events_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
self._events_cache[run_id] = events
self._mtimes[f"{run_id}_events"] = event_mtime
except (OSError, ValidationError, json.JSONDecodeError, KeyError) as e:
sync_logger.error(f"Failed to sync {run_id}: {e}")
continue
except OSError as e:
sync_logger.error(f"Sync error: {e}")
pass

async def create_run(self, record: RunRecord | None = None) -> RunRecord:
if record is None:
Expand All@@ -63,10 +91,26 @@ def cancel_request_path(self, run_id: str) -> Path:

async def persist_run(self, run_id: str) -> None:
record = self._runs[run_id]
run_dir = self.run_dir(run_id)
lock = self._locks[run_id]
with lock:
(run_dir / "run.json").write_text(record.model_dump_json(indent=2), encoding="utf-8")
path = self.run_dir(run_id) / "run.json"

# Check if disk is newer before overwriting
if path.exists():
try:
disk_mtime = path.stat().st_mtime
if self._mtimes.get(run_id, 0) < disk_mtime:
# Disk is newer, reload first to avoid overwriting clinical updates (like failed status)
disk_content = path.read_text(encoding="utf-8")
disk_record = RunRecord.model_validate_json(disk_content)
if disk_record.status in ("failed", "cancelled", "completed"):
# Don't overwrite terminal status
self._runs[run_id] = disk_record
self._mtimes[run_id] = disk_mtime
return
except Exception as e:
sync_logger.error(f"Error checking disk state for {run_id}: {e}")

path.write_text(record.model_dump_json(indent=2), encoding="utf-8")
self._mtimes[run_id] = path.stat().st_mtime

async def append_event(self, run_id: str, event: RunEvent) -> None:
lock = self._locks[run_id]
Expand DownExpand Up@@ -112,12 +156,15 @@ def read_artifact_text(self, run_id: str, node_id: str, name: str) -> str:
return self.artifact_path(run_id, node_id, name).read_text(encoding="utf-8")

def get_run(self, run_id: str) -> RunRecord:
self._sync_runs()
return self._runs[run_id]

def list_runs(self) -> list[RunRecord]:
self._sync_runs()
return sorted(self._runs.values(), key=lambda run: run.created_at, reverse=True)

def get_events(self, run_id: str) -> list[RunEvent]:
self._sync_runs()
return list(self._events_cache[run_id])

async def subscribe(self, run_id: str) -> queue.Queue[RunEvent]:
Expand Down
24 changes: 24 additions & 0 deletions agentflow/web/frontend/.gitignore
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
73 changes: 73 additions & 0 deletions agentflow/web/frontend/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
# React + TypeScript + Vite

This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.

Currently, two official plugins are available:

- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)

## React Compiler

The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).

## Expanding the ESLint configuration

If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:

```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...

// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,

// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:

```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'

export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
23 changes: 23 additions & 0 deletions agentflow/web/frontend/eslint.config.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'

export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
13 changes: 13 additions & 0 deletions agentflow/web/frontend/index.html
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
<!doctype html>
<htmllang="en">
<head>
<metacharset="UTF-8" />
<linkrel="icon" type="image/svg+xml" href="/favicon.svg" />
<metaname="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<divid="root"></div>
<scripttype="module" src="/src/main.tsx"></script>
</body>
</html>
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Feat frontend by o3o3o · Pull Request #4 · agentenv/agentflow · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,4 +10,5 @@ test-results/
.e2e-flaky
dist/
*.egg-info/
.worktrees/
.worktrees/
uv.lock
15 changes: 12 additions & 3 deletions Makefile
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
.DEFAULT_GOAL := help

.PHONY: help test smoke
.PHONY: help test smoke install-ui build-ui

PYTHON := $(if $(wildcard .venv/bin/python),.venv/bin/python,python3)
FRONTEND_DIR := agentflow/web/frontend

help:
@printf '%s\n' \
'Available targets:' \
' test Run the test suite' \
' smoke Run the default smoke pipeline'
' test Run the test suite' \
' smoke Run the default smoke pipeline' \
' install-ui Install frontend dependencies' \
' build-ui Build the frontend dashboard'

test:
$(PYTHON) -m pytest -q

smoke:
$(PYTHON) -m agentflow run examples/airflow_like.py --output summary

install-ui:
cd $(FRONTEND_DIR) && npm install

build-ui:
cd $(FRONTEND_DIR) && npm run build
6 changes: 6 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,12 @@ python3 -m venv .venv && . .venv/bin/activate
pip install -e .[dev]
```

To build the dashboard (optional, requires Node.js):
```bash
make install-ui
make build-ui
```

## Quick Start

```python
Expand Down
28 changes: 28 additions & 0 deletions agentflow/app.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,11 +84,28 @@ def create_app(*, store: RunStore | None = None, orchestrator: Orchestrator | No
app.state.orchestrator = orchestrator

base_dir = os.path.join(os.path.dirname(__file__), "web")
frontend_dist = os.path.join(base_dir, "frontend", "dist")

# Mount Vite static assets
if os.path.isdir(frontend_dist):
assets_dir = os.path.join(frontend_dist, "assets")
if os.path.isdir(assets_dir):
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")

# Public folder/static files inside dist
app.mount("/public", StaticFiles(directory=frontend_dist), name="public-dist")

# Legacy mounts and templates
templates = Jinja2Templates(directory=os.path.join(base_dir, "templates"))
app.mount("/static", StaticFiles(directory=os.path.join(base_dir, "static")), name="static")

@app.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse:
react_index = os.path.join(frontend_dist, "index.html")
if os.path.isfile(react_index):
with open(react_index, "r") as f:
return HTMLResponse(content=f.read(), status_code=200)

return templates.TemplateResponse(
"index.html",
{"request": request, "example": _load_default_web_example(), "base_dir": os.getcwd()},
Expand DownExpand Up@@ -153,6 +170,17 @@ async def get_artifact(run_id: str, node_id: str, name: str) -> PlainTextRespons
raise HTTPException(status_code=404, detail="artifact not found") from exc
return PlainTextResponse(content)

@app.get("/api/runs/{run_id}/scratchboard")
async def get_scratchboard(run_id: str) -> PlainTextResponse:
from agentflow.scratchboard import SCRATCHBOARD_FILENAME
try:
path = app.state.store.run_dir(run_id) / SCRATCHBOARD_FILENAME

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This read-only endpoint has a write side effect for arbitrary run_ids. run_dir(run_id) creates the directory if it does not exist, so GET /api/runs/<missing>/scratchboard returns 200 with an empty body and leaves a new run directory on disk instead of returning 404 like the other per-run endpoints.

I reproduced this with a nonexistent run id and ended up with a new runs/<id>/ directory after the request. Please validate that the run exists before calling run_dir().

if not path.exists():
return PlainTextResponse("")
return PlainTextResponse(path.read_text(encoding="utf-8"))
except Exception as exc:
raise HTTPException(status_code=404, detail="scratchboard not found") from exc

@app.get("/api/runs/{run_id}/stream")
async def stream_run(run_id: str):
if run_id not in {run.id for run in app.state.store.list_runs()}:
Expand Down
91 changes: 69 additions & 22 deletions agentflow/store.py
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import json
import logging
import queue
import threading
from collections import defaultdict
Expand All@@ -12,6 +13,9 @@
from agentflow.specs import RunEvent, RunRecord
from agentflow.utils import ensure_dir

# Set up a dedicated logger for sync issues
sync_logger = logging.getLogger("agentflow.sync")


class RunStore:
def __init__(self, base_dir: str | Path = ".agentflow/runs") -> None:
Expand All@@ -20,24 +24,48 @@ def __init__(self, base_dir: str | Path = ".agentflow/runs") -> None:
self._locks: defaultdict[str, threading.Lock] = defaultdict(threading.Lock)
self._subscribers: defaultdict[str, set[queue.Queue[RunEvent]]] = defaultdict(set)
self._events_cache: defaultdict[str, list[RunEvent]] = defaultdict(list)
self._load_existing_runs()

def _load_existing_runs(self) -> None:
for run_file in sorted(self.base_dir.glob("*/run.json")):
run_id = run_file.parent.name
try:
run = RunRecord.model_validate_json(run_file.read_text(encoding="utf-8"))
self._runs[run_id] = run
events_path = run_file.parent / "events.jsonl"
if events_path.exists():
events = [
RunEvent.model_validate_json(line)
for line in events_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
self._events_cache[run_id] = events
except (OSError, ValidationError, json.JSONDecodeError, KeyError):
continue
self._mtimes: dict[str, float] = {}
self._sync_runs()

def _sync_runs(self) -> None:
"""Synchronize in-memory cache with the filesystem."""
try:
run_files = list(self.base_dir.glob("*/run.json"))
for run_file in sorted(run_files):
run_id = run_file.parent.name
try:
mtime = run_file.stat().st_mtime
if run_id not in self._runs or self._mtimes.get(run_id, 0) < mtime:
content = run_file.read_text(encoding="utf-8")
if not content.strip():
continue

run = RunRecord.model_validate_json(content)
# Only update if status changed or it's new
if run_id not in self._runs or self._runs[run_id].status != run.status:
sync_logger.debug(f"Syncing run {run_id}: status {run.status}")

self._runs[run_id] = run

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This swaps the live RunRecord object out from under the active orchestrator. orchestrator.run() captures the original record once and keeps mutating it, but _sync_runs() replaces self._runs[run_id] with a brand-new model whenever the file on disk changes. After a cross-process update like cancel() from a fresh RunStore, the in-flight run keeps updating the stale object while the store keeps returning the replacement object, so terminal state never settles correctly.

tests/test_orchestrator.py::test_orchestrator_honors_cancel_request_from_fresh_instance now times out on this branch, and it passes on master. Please avoid replacing live run objects here, or limit disk sync to inactive runs.

self._mtimes[run_id] = mtime

# Sync events
events_path = run_file.parent / "events.jsonl"
if events_path.exists():
event_mtime = events_path.stat().st_mtime
if self._mtimes.get(f"{run_id}_events", 0) < event_mtime:
events = [
RunEvent.model_validate_json(line)
for line in events_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
self._events_cache[run_id] = events
self._mtimes[f"{run_id}_events"] = event_mtime
except (OSError, ValidationError, json.JSONDecodeError, KeyError) as e:
sync_logger.error(f"Failed to sync {run_id}: {e}")
continue
except OSError as e:
sync_logger.error(f"Sync error: {e}")
pass

async def create_run(self, record: RunRecord | None = None) -> RunRecord:
if record is None:
Expand All@@ -63,10 +91,26 @@ def cancel_request_path(self, run_id: str) -> Path:

async def persist_run(self, run_id: str) -> None:
record = self._runs[run_id]
run_dir = self.run_dir(run_id)
lock = self._locks[run_id]
with lock:
(run_dir / "run.json").write_text(record.model_dump_json(indent=2), encoding="utf-8")
path = self.run_dir(run_id) / "run.json"

# Check if disk is newer before overwriting
if path.exists():
try:
disk_mtime = path.stat().st_mtime
if self._mtimes.get(run_id, 0) < disk_mtime:
# Disk is newer, reload first to avoid overwriting clinical updates (like failed status)
disk_content = path.read_text(encoding="utf-8")
disk_record = RunRecord.model_validate_json(disk_content)
if disk_record.status in ("failed", "cancelled", "completed"):
# Don't overwrite terminal status
self._runs[run_id] = disk_record
self._mtimes[run_id] = disk_mtime
return
except Exception as e:
sync_logger.error(f"Error checking disk state for {run_id}: {e}")

path.write_text(record.model_dump_json(indent=2), encoding="utf-8")
self._mtimes[run_id] = path.stat().st_mtime

async def append_event(self, run_id: str, event: RunEvent) -> None:
lock = self._locks[run_id]
Expand DownExpand Up@@ -112,12 +156,15 @@ def read_artifact_text(self, run_id: str, node_id: str, name: str) -> str:
return self.artifact_path(run_id, node_id, name).read_text(encoding="utf-8")

def get_run(self, run_id: str) -> RunRecord:
self._sync_runs()
return self._runs[run_id]

def list_runs(self) -> list[RunRecord]:
self._sync_runs()
return sorted(self._runs.values(), key=lambda run: run.created_at, reverse=True)

def get_events(self, run_id: str) -> list[RunEvent]:
self._sync_runs()
return list(self._events_cache[run_id])

async def subscribe(self, run_id: str) -> queue.Queue[RunEvent]:
Expand Down
24 changes: 24 additions & 0 deletions agentflow/web/frontend/.gitignore
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
73 changes: 73 additions & 0 deletions agentflow/web/frontend/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
# React + TypeScript + Vite

This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.

Currently, two official plugins are available:

- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)

## React Compiler

The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).

## Expanding the ESLint configuration

If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:

```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...

// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,

// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:

```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'

export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
23 changes: 23 additions & 0 deletions agentflow/web/frontend/eslint.config.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'

export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
13 changes: 13 additions & 0 deletions agentflow/web/frontend/index.html
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
<!doctype html>
<htmllang="en">
<head>
<metacharset="UTF-8" />
<linkrel="icon" type="image/svg+xml" href="/favicon.svg" />
<metaname="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<divid="root"></div>
<scripttype="module" src="/src/main.tsx"></script>
</body>
</html>
Loading