mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[eric] workflows: fix mid-run PATCH/DELETE clobber, optimistic-concurrency PATCH, X-out ghost confirm, custom-draft no-orphan, skipped-run toast,
dup-schedule guard, honest on_missed + Q/R warnings + clearer pause-all + Killed-by-restart copy
This commit is contained in:
+191
@@ -0,0 +1,191 @@
|
||||
# Contributing to OpenSwarm
|
||||
|
||||
A guide for all OpenSwarm contributors.
|
||||
|
||||
## Branches
|
||||
|
||||
There are two protected branches:
|
||||
|
||||
- **`main`**: the stable, production-ready branch. Every merge to `main` represents a versioned release. Never commit directly to it from any branch that is not **`dev`**.
|
||||
- **`dev`**: the active development branch. All feature branches merge here first. This is where work-in-progress code lives and gets tested before release.
|
||||
|
||||
Never commit directly to either branch. Every change, no matter how small, gets its own branch and pull request.
|
||||
|
||||
### Naming format
|
||||
|
||||
```
|
||||
yourname/type/short-description
|
||||
```
|
||||
|
||||
All lowercase, hyphens between words. Keep it short but descriptive.
|
||||
|
||||
| Prefix | When to use | Example |
|
||||
| --- | --- | --- |
|
||||
| `feat/` | New feature | `haik/feat/add-dark-mode` |
|
||||
| `fix/` | Bug fix | `arnav/fix/login-crash` |
|
||||
| `refactor/` | Restructuring code without changing behavior | `cire/refactor/cleanup-auth` |
|
||||
| `docs/` | Documentation only | `haik/docs/update-readme` |
|
||||
| `chore/` | Build scripts, CI, dependencies, tooling | `arnav/chore/update-deps` |
|
||||
|
||||
### Creating a branch
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
git pull
|
||||
git checkout -b yourname/feat/my-feature
|
||||
```
|
||||
|
||||
Always branch off of the latest `dev`.
|
||||
|
||||
## Commits
|
||||
|
||||
### Format
|
||||
|
||||
```
|
||||
[yourname] type: short description in imperative mood
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
```
|
||||
[bob] feat: add user profile page
|
||||
[bob] fix: prevent crash when token expires
|
||||
[bob] refactor: split auth into separate module
|
||||
[bob] docs: add setup instructions to README
|
||||
[bob] chore: upgrade node to v22
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
- Start with `[name] type:` prefix (same list as branches above).
|
||||
- Use imperative mood. "add" not "added", "fix" not "fixed".
|
||||
- One commit = one logical unit of work.
|
||||
|
||||
## The Workflow
|
||||
|
||||
### For day-to-day development
|
||||
|
||||
1. **Pull latest dev**
|
||||
```bash
|
||||
git checkout dev && git pull
|
||||
```
|
||||
2. **Create a branch**
|
||||
```bash
|
||||
git checkout -b yourname/feat/my-feature
|
||||
```
|
||||
3. **Do your work, commit as you go**
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "[yourname] feat: whatever you did"
|
||||
```
|
||||
4. **Push your branch**
|
||||
```bash
|
||||
git push
|
||||
```
|
||||
5. **Open a Pull Request on GitHub**
|
||||
base: `dev`, compare: `yourname/feat/my-feature`.
|
||||
6. **Wait for review and approval.**
|
||||
7. **The maintainer merges it into `dev`** (branches are deleted automatically after merge).
|
||||
|
||||
### For outside contributors (people not on the core team)
|
||||
|
||||
1. Fork the repo (creates your own copy).
|
||||
2. Clone your fork.
|
||||
3. Create a branch off of `dev` and do your work (same naming conventions).
|
||||
4. Push to your fork.
|
||||
5. Open a Pull Request from your fork to the main repo's `dev` branch.
|
||||
6. Wait for review and approval.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
### Title
|
||||
|
||||
Use the same format as commits:
|
||||
|
||||
```
|
||||
[yourname] feat: add dark mode toggle
|
||||
[yourname] fix: resolve crash on empty input
|
||||
```
|
||||
|
||||
### Description
|
||||
|
||||
Write a short explanation of what the change does and why. Two to three sentences is enough. If the change is visual, include a screenshot.
|
||||
|
||||
### Scope
|
||||
|
||||
One logical change per PR. Do not bundle unrelated work. A bug fix and a new feature should be separate PRs, even if you noticed the bug while building the feature.
|
||||
|
||||
## Merging
|
||||
|
||||
All PRs into `dev` are merged using **squash and merge**. This takes all the commits in your PR and combines them into one clean commit on `dev`. This keeps the history readable even if your branch had many small or messy commits.
|
||||
|
||||
Only the maintainer (i.e. Eric) merges PRs. Do not merge your own work (unless ur Eric).
|
||||
|
||||
### Squash and Merge
|
||||
|
||||
When you have a branch with, say, 5 commits:
|
||||
|
||||
```
|
||||
feat: start building login page
|
||||
fix: typo in login form
|
||||
feat: add password validation
|
||||
fix: forgot to import useState
|
||||
feat: finish login page styling
|
||||
```
|
||||
|
||||
**Squash and merge** takes all 5 of those and combines them into a single commit when merging the PR:
|
||||
|
||||
```
|
||||
feat: add login page (#12)
|
||||
```
|
||||
|
||||
So `dev` gets one clean commit instead of messy work-in-progress history. The full commit history still exists on the PR page if anyone ever needs to look at it.
|
||||
|
||||
**How it works:** You don't do anything special. When you click the green "Merge pull request" button on a PR, there's a dropdown arrow next to it. Pick "Squash and merge" from that dropdown. GitHub then asks you to write the final squashed commit message before confirming.
|
||||
|
||||
**Does it happen by default?** No. GitHub defaults to a regular merge commit. But you can change this in repo settings:
|
||||
|
||||
1. Go to repo **Settings > General**.
|
||||
2. Scroll to **Pull Requests**.
|
||||
3. Uncheck "Allow merge commits".
|
||||
4. Uncheck "Allow rebase merging".
|
||||
5. Keep only **"Allow squash merging"** checked.
|
||||
|
||||
After that, squash and merge is the only option anyone sees. No dropdown to pick from, no way to accidentally do a regular merge.
|
||||
|
||||
*Note: this has already been set up in our repo settings, so we're good to go. If this ever needs to be modified, call Haik.*
|
||||
|
||||
## Releases
|
||||
|
||||
When `dev` has accumulated enough changes and is stable, the maintainer merges `dev` into `main` via a PR. Every merge to `main` represents a versioned release.
|
||||
|
||||
### Flow
|
||||
|
||||
```
|
||||
feature branches -> PR into dev -> test and stabilize -> PR from dev into main -> tag a release
|
||||
```
|
||||
|
||||
### Versioning
|
||||
|
||||
Releases use semantic versioning:
|
||||
|
||||
| Change type | Version bump | Example |
|
||||
| --- | --- | --- |
|
||||
| Bug fixes, plus modifications or additions to existing features | Patch | `v1.0.0` -> `v1.0.1` |
|
||||
| Completely new features (backwards compatible) | Minor | `v1.0.0` -> `v1.1.0` |
|
||||
| Breaking changes | Major | `v1.1.0` -> `v2.0.0` |
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Action | Command |
|
||||
| --- | --- |
|
||||
| Update your local dev | `git checkout dev && git pull` |
|
||||
| Create a new branch | `git checkout -b yourname/type/description` |
|
||||
| Stage all files | `git add .` |
|
||||
| Commit | `git commit -m "[yourname] type: description"` |
|
||||
| Push a new branch | `git push -u origin yourname/type/description` |
|
||||
| Push subsequent commits | `git push` |
|
||||
| See who wrote a line | `git blame filename` |
|
||||
| See commit history | `git log --oneline` |
|
||||
| See your current branch | `git branch` |
|
||||
| Switch to an existing branch | `git checkout branch-name` |
|
||||
@@ -110,27 +110,6 @@ def get_provider_credentials(settings: AppSettings, provider: str) -> dict[str,
|
||||
raise ValueError(f"No credentials for provider: {provider}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy helpers (kept for backward compat during migration)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_agent_sdk_env(settings: AppSettings) -> dict[str, str]:
|
||||
"""Return the env dict for ClaudeAgentOptions based on connection mode.
|
||||
|
||||
DEPRECATED: Use create_provider() from providers.registry instead.
|
||||
"""
|
||||
validate_credentials(settings, "anthropic")
|
||||
|
||||
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
|
||||
proxy_url = getattr(settings, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL
|
||||
return {
|
||||
"ANTHROPIC_AUTH_TOKEN": getattr(settings, "openswarm_bearer_token", ""),
|
||||
"ANTHROPIC_BASE_URL": proxy_url,
|
||||
}
|
||||
|
||||
return {"ANTHROPIC_API_KEY": settings.anthropic_api_key}
|
||||
|
||||
|
||||
def get_anthropic_client(settings: AppSettings) -> anthropic.AsyncAnthropic:
|
||||
"""Return a configured AsyncAnthropic client based on connection mode.
|
||||
|
||||
|
||||
@@ -37,6 +37,31 @@ def _resolve_allowed_tools(wf: Workflow) -> list[str]:
|
||||
return list(wf.actions.configured_sets)
|
||||
|
||||
|
||||
def _persist_run_fields(wf: Workflow, run_fields: dict, schedule_runs_count_delta: int = 0) -> None:
|
||||
"""Merge run-side fields into the current on-disk workflow.
|
||||
|
||||
The executor holds the `wf` it was launched with; meanwhile the user
|
||||
may have PATCHed unrelated fields (title, schedule, permissions...).
|
||||
Saving our captured `wf` would clobber those edits. Re-read the
|
||||
authoritative record from storage and only mutate the run-side fields
|
||||
we own. If the workflow has been deleted while we ran, silently skip
|
||||
the save so we don't resurrect a deleted record.
|
||||
|
||||
schedule_runs_count_delta is a small int (0 or 1) that we add to the
|
||||
on-disk schedule.runs_count to avoid the same race overwriting an
|
||||
in-flight bump on the user's PATCH path.
|
||||
"""
|
||||
fresh = storage.get_workflow(wf.id)
|
||||
if fresh is None:
|
||||
# Deleted while we ran. Don't resurrect.
|
||||
return
|
||||
for k, v in run_fields.items():
|
||||
setattr(fresh, k, v)
|
||||
if schedule_runs_count_delta:
|
||||
fresh.schedule.runs_count = fresh.schedule.runs_count + schedule_runs_count_delta
|
||||
storage.save_workflow(fresh)
|
||||
|
||||
|
||||
def _monthly_spend_so_far(wf: Workflow) -> float:
|
||||
"""Sum cost_usd across runs of `wf` started in the last 30 days.
|
||||
|
||||
@@ -80,10 +105,11 @@ async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: O
|
||||
run.error = f"Monthly cost cap reached (${spent:.2f} / ${wf.cost_cap_usd_monthly:.2f})"
|
||||
run.finished_at = datetime.now()
|
||||
storage.record_run(run)
|
||||
wf.last_run_at = run.finished_at
|
||||
wf.last_run_status = "skipped"
|
||||
wf.last_run_id = run.id
|
||||
storage.save_workflow(wf)
|
||||
_persist_run_fields(wf, {
|
||||
"last_run_at": run.finished_at,
|
||||
"last_run_status": "skipped",
|
||||
"last_run_id": run.id,
|
||||
})
|
||||
return run
|
||||
|
||||
storage.record_run(run)
|
||||
@@ -100,7 +126,11 @@ async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: O
|
||||
wf.last_run_at = run.started_at
|
||||
wf.last_run_status = "running"
|
||||
wf.last_run_id = run.id
|
||||
storage.save_workflow(wf)
|
||||
_persist_run_fields(wf, {
|
||||
"last_run_at": run.started_at,
|
||||
"last_run_status": "running",
|
||||
"last_run_id": run.id,
|
||||
})
|
||||
|
||||
try:
|
||||
steps = [s.text for s in wf.steps if s.text and s.text.strip()]
|
||||
@@ -158,11 +188,13 @@ async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: O
|
||||
wf.last_run_status = "success"
|
||||
# Bump runs_count for scheduled fires that reached a terminal state
|
||||
# other than "skipped". Manual runs don't count against max_runs.
|
||||
if triggered_by == "schedule" and run.status in ("success", "ran_late", "failure"):
|
||||
wf.schedule.runs_count += 1
|
||||
runs_delta = 1 if (triggered_by == "schedule" and run.status in ("success", "ran_late", "failure")) else 0
|
||||
storage.record_run(run)
|
||||
wf.last_run_at = run.finished_at
|
||||
storage.save_workflow(wf)
|
||||
_persist_run_fields(wf, {
|
||||
"last_run_at": run.finished_at,
|
||||
"last_run_status": wf.last_run_status,
|
||||
}, schedule_runs_count_delta=runs_delta)
|
||||
except Exception as e:
|
||||
logger.exception("Workflow run failed: %s", e)
|
||||
run.status = "failure"
|
||||
@@ -170,7 +202,10 @@ async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: O
|
||||
run.finished_at = datetime.now()
|
||||
storage.record_run(run)
|
||||
wf.last_run_status = "failure"
|
||||
storage.save_workflow(wf)
|
||||
_persist_run_fields(wf, {
|
||||
"last_run_status": "failure",
|
||||
"last_run_at": run.finished_at,
|
||||
})
|
||||
finally:
|
||||
async with _running_lock:
|
||||
_running.pop(wf.id, None)
|
||||
|
||||
@@ -262,7 +262,12 @@ def _mark_stuck_runs_failed() -> None:
|
||||
for wf in storage.list_workflows():
|
||||
for r in storage.list_runs(wf.id, limit=200):
|
||||
if r.status == "running":
|
||||
storage.update_run(r.id, status="failure", error="Killed by restart", finished_at=now)
|
||||
storage.update_run(
|
||||
r.id,
|
||||
status="failure",
|
||||
error="OpenSwarm closed before this run finished.",
|
||||
finished_at=now,
|
||||
)
|
||||
|
||||
|
||||
def reconcile_on_startup() -> None:
|
||||
|
||||
@@ -4,7 +4,7 @@ from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi import HTTPException, Header, Request
|
||||
|
||||
from backend.config.Apps import SubApp
|
||||
from backend.apps.workflows.models import (
|
||||
@@ -183,10 +183,32 @@ async def get_workflow_audit(workflow_id: str, limit: int = 50):
|
||||
|
||||
|
||||
@workflows.router.patch("/{workflow_id}")
|
||||
async def update_workflow(workflow_id: str, body: WorkflowUpdate):
|
||||
async def update_workflow(
|
||||
workflow_id: str,
|
||||
body: WorkflowUpdate,
|
||||
if_match: Optional[str] = Header(default=None, alias="If-Match"),
|
||||
):
|
||||
wf = storage.get_workflow(workflow_id)
|
||||
if not wf:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
# Optimistic concurrency: if the client passed If-Match, verify it
|
||||
# matches the current updated_at. Stale writes (another window or a
|
||||
# mid-edit background fire) get a 409 so the FE can prompt to reload
|
||||
# instead of silently clobbering the other actor's changes. Missing
|
||||
# header = legacy client, allow through (back-compat with the
|
||||
# frontend's pre-409 code path; FE rolls out If-Match immediately).
|
||||
if if_match:
|
||||
current_stamp = wf.updated_at.isoformat() if hasattr(wf.updated_at, "isoformat") else str(wf.updated_at)
|
||||
# Strip quotes a well-behaved HTTP client might add per RFC 7232.
|
||||
if if_match.strip().strip('"') != current_stamp:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"error": "stale_update",
|
||||
"message": "This workflow changed in another window or by a recent run. Reload and try again.",
|
||||
"current_updated_at": current_stamp,
|
||||
},
|
||||
)
|
||||
before = wf.model_dump(mode="json")
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
for k, v in data.items():
|
||||
@@ -221,15 +243,20 @@ async def run_workflow_now(workflow_id: str):
|
||||
pre_ids = {r.id for r in storage.list_runs(wf.id, limit=10)}
|
||||
asyncio.create_task(executor.execute(wf, triggered_by="manual"))
|
||||
|
||||
# Poll briefly for the newly created run id (anything not already in
|
||||
# the pre-fire snapshot). Falls back to empty if the executor hasn't
|
||||
# written within 250ms — frontend reconciles via WS afterwards.
|
||||
# Poll briefly for the newly created run id. We also surface the
|
||||
# run's status + error string when it lands quickly (e.g. cost-cap
|
||||
# short-circuit, _running collision) so the FE can render a toast
|
||||
# instead of silently switching to History.
|
||||
for _ in range(25):
|
||||
for r in storage.list_runs(wf.id, limit=10):
|
||||
if r.id not in pre_ids and r.triggered_by == "manual":
|
||||
return {"run_id": r.id}
|
||||
return {
|
||||
"run_id": r.id,
|
||||
"status": r.status,
|
||||
"error": r.error,
|
||||
}
|
||||
await asyncio.sleep(0.01)
|
||||
return {"run_id": ""}
|
||||
return {"run_id": "", "status": None, "error": None}
|
||||
|
||||
|
||||
@workflows.router.get("/{workflow_id}/runs")
|
||||
|
||||
@@ -305,6 +305,122 @@ def test_escalation_schedules_and_ack_cancels():
|
||||
asyncio.new_event_loop().run_until_complete(runner())
|
||||
|
||||
|
||||
def test_executor_merge_does_not_clobber_concurrent_patch():
|
||||
"""Executor's final save must NOT overwrite unrelated fields that
|
||||
were PATCHed while the run was in flight. We simulate this by
|
||||
capturing a wf, mutating storage's record directly (acting as the
|
||||
PATCH that landed mid-run), then asking the executor's persist
|
||||
helper to flush its run-side bookkeeping. The patched fields must
|
||||
survive.
|
||||
"""
|
||||
from backend.apps.workflows import storage, executor
|
||||
from datetime import datetime
|
||||
wf = _make_wf(title="t-orig")
|
||||
storage.save_workflow(wf)
|
||||
# Simulate a user PATCH mid-run.
|
||||
storage._workflow_cache[wf.id].title = "t-patched"
|
||||
storage._workflow_cache[wf.id].description = "patched while running"
|
||||
storage.save_workflow(storage._workflow_cache[wf.id])
|
||||
# Executor uses the stale `wf` it captured before the patch. With
|
||||
# the merge helper, the patched fields must remain.
|
||||
executor._persist_run_fields(wf, {
|
||||
"last_run_at": datetime.now(),
|
||||
"last_run_status": "success",
|
||||
})
|
||||
after = storage.get_workflow(wf.id)
|
||||
assert after.title == "t-patched", "title clobbered by executor"
|
||||
assert after.description == "patched while running", "description clobbered"
|
||||
assert after.last_run_status == "success"
|
||||
|
||||
|
||||
def test_executor_delete_during_run_does_not_resurrect():
|
||||
"""If the workflow was deleted mid-run, executor's persist must
|
||||
silently no-op so the deleted record isn't re-written."""
|
||||
from backend.apps.workflows import storage, executor
|
||||
from datetime import datetime
|
||||
wf = _make_wf(title="doomed")
|
||||
storage.save_workflow(wf)
|
||||
storage.delete_workflow(wf.id)
|
||||
executor._persist_run_fields(wf, {
|
||||
"last_run_at": datetime.now(),
|
||||
"last_run_status": "success",
|
||||
}, schedule_runs_count_delta=1)
|
||||
assert storage.get_workflow(wf.id) is None
|
||||
|
||||
|
||||
def test_patch_if_match_rejects_stale_write():
|
||||
"""A PATCH with a stale If-Match must return 409. Without If-Match,
|
||||
the request still succeeds (legacy clients keep working until they
|
||||
roll out the header)."""
|
||||
from backend.apps.workflows.workflows import update_workflow
|
||||
from backend.apps.workflows.models import WorkflowUpdate
|
||||
from backend.apps.workflows import storage
|
||||
from fastapi import HTTPException
|
||||
|
||||
wf = _make_wf(title="optimistic-test")
|
||||
storage.save_workflow(wf)
|
||||
stale = "1999-01-01T00:00:00"
|
||||
|
||||
async def runner():
|
||||
# Stale If-Match → 409.
|
||||
try:
|
||||
await update_workflow(wf.id, WorkflowUpdate(title="x"), if_match=stale)
|
||||
return "no exception"
|
||||
except HTTPException as he:
|
||||
return he.status_code
|
||||
code = asyncio.new_event_loop().run_until_complete(runner())
|
||||
assert code == 409, f"stale If-Match should 409, got {code}"
|
||||
|
||||
# Fresh If-Match → 200.
|
||||
fresh = storage.get_workflow(wf.id)
|
||||
fresh_stamp = fresh.updated_at.isoformat()
|
||||
async def runner_ok():
|
||||
return await update_workflow(wf.id, WorkflowUpdate(title="y"), if_match=fresh_stamp)
|
||||
result = asyncio.new_event_loop().run_until_complete(runner_ok())
|
||||
assert result["title"] == "y"
|
||||
|
||||
# Missing If-Match → legacy path still works.
|
||||
async def runner_legacy():
|
||||
return await update_workflow(wf.id, WorkflowUpdate(title="z"), if_match=None)
|
||||
result = asyncio.new_event_loop().run_until_complete(runner_legacy())
|
||||
assert result["title"] == "z"
|
||||
|
||||
|
||||
def test_killed_by_restart_message_is_friendly():
|
||||
"""stuck-run reaper writes a user-facing string, not internal jargon."""
|
||||
from backend.apps.workflows import storage, scheduler
|
||||
from backend.apps.workflows.models import WorkflowRun
|
||||
wf = _make_wf()
|
||||
storage.save_workflow(wf)
|
||||
storage.record_run(WorkflowRun(workflow_id=wf.id, status="running"))
|
||||
scheduler._mark_stuck_runs_failed()
|
||||
runs = storage.list_runs(wf.id, limit=10)
|
||||
assert any(r.status == "failure" and "OpenSwarm closed" in (r.error or "") for r in runs)
|
||||
assert not any("Killed by restart" in (r.error or "") for r in runs)
|
||||
|
||||
|
||||
def test_run_endpoint_surfaces_skipped_status():
|
||||
"""POST /workflows/{id}/run returns the skipped status + error when
|
||||
a cost-cap or in-flight collision short-circuits the run."""
|
||||
from backend.apps.workflows.workflows import run_workflow_now
|
||||
from backend.apps.workflows import storage
|
||||
from backend.apps.workflows.models import WorkflowRun
|
||||
from datetime import datetime, timezone
|
||||
wf = _make_wf(title="cap-immediate")
|
||||
wf.cost_cap_usd_monthly = 0.01
|
||||
storage.save_workflow(wf)
|
||||
# Burn the cap with a single $5 historical run.
|
||||
storage.record_run(WorkflowRun(workflow_id=wf.id, status="success", cost_usd=5.0,
|
||||
started_at=datetime.now(timezone.utc),
|
||||
finished_at=datetime.now(timezone.utc)))
|
||||
|
||||
async def runner():
|
||||
return await run_workflow_now(wf.id)
|
||||
res = asyncio.new_event_loop().run_until_complete(runner())
|
||||
assert res.get("status") == "skipped"
|
||||
assert "cost cap" in (res.get("error") or "").lower()
|
||||
|
||||
|
||||
def test_escalation_noop_for_single_tier():
|
||||
from backend.apps.workflows import escalation
|
||||
from backend.apps.workflows.models import Workflow, PermissionTier, WorkflowRun
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { DURATION_MS, EASE } from '@/shared/styles/motionTokens';
|
||||
import { useReducedMotion } from '@/shared/hooks/useReducedMotion';
|
||||
|
||||
/**
|
||||
* Smooth visual transitions for status pills + counters that currently snap.
|
||||
*
|
||||
* <CrossFadeOnChange value={x}>{(v) => <span>{v}</span>}</CrossFadeOnChange>
|
||||
* Old value fades to 30% while new value fades in. Cancels on rapid changes.
|
||||
*
|
||||
* <TweeningNumber value={1234} format={(n) => `$${n.toFixed(4)}`} />
|
||||
* RAF-tweens from previous to new value. Caps duration on big jumps.
|
||||
*/
|
||||
|
||||
interface CrossFadeProps<T> {
|
||||
value: T;
|
||||
children: (currentValue: T) => React.ReactNode;
|
||||
/** Defaults to DURATION_MS.quick (140ms). */
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export function CrossFadeOnChange<T>({ value, children, durationMs }: CrossFadeProps<T>) {
|
||||
const reduced = useReducedMotion();
|
||||
const dur = reduced ? 0 : (durationMs ?? DURATION_MS.quick);
|
||||
const [displayed, setDisplayed] = useState(value);
|
||||
const [opacity, setOpacity] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (Object.is(displayed, value)) return;
|
||||
if (dur === 0) {
|
||||
setDisplayed(value);
|
||||
return;
|
||||
}
|
||||
// Fade old to ~0, then swap and fade new in.
|
||||
setOpacity(0);
|
||||
const t = setTimeout(() => {
|
||||
setDisplayed(value);
|
||||
setOpacity(1);
|
||||
}, dur / 2);
|
||||
return () => clearTimeout(t);
|
||||
}, [value, dur, displayed]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
opacity,
|
||||
transition: `opacity ${dur / 2}ms ${EASE.out}`,
|
||||
}}
|
||||
>
|
||||
{children(displayed)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface TweeningNumberProps {
|
||||
value: number;
|
||||
/** How to render the tweened number. Default: `n.toString()`. */
|
||||
format?: (n: number) => string;
|
||||
/** Cap on tween duration regardless of delta. Default 500ms. */
|
||||
maxDurationMs?: number;
|
||||
}
|
||||
|
||||
export const TweeningNumber: React.FC<TweeningNumberProps> = ({
|
||||
value,
|
||||
format = (n) => String(Math.round(n)),
|
||||
maxDurationMs = 500,
|
||||
}) => {
|
||||
const reduced = useReducedMotion();
|
||||
const [displayed, setDisplayed] = useState(value);
|
||||
const startedAtRef = useRef<number | null>(null);
|
||||
const fromRef = useRef<number>(value);
|
||||
const toRef = useRef<number>(value);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (reduced) {
|
||||
setDisplayed(value);
|
||||
return;
|
||||
}
|
||||
if (Object.is(toRef.current, value)) return;
|
||||
|
||||
fromRef.current = displayed;
|
||||
toRef.current = value;
|
||||
startedAtRef.current = performance.now();
|
||||
|
||||
// Duration scales with delta but caps. ~1ms per unit, capped.
|
||||
const delta = Math.abs(value - fromRef.current);
|
||||
const dur = Math.min(maxDurationMs, Math.max(120, delta * 1.2));
|
||||
|
||||
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
||||
|
||||
const step = (now: number) => {
|
||||
const t = Math.min(1, (now - (startedAtRef.current as number)) / dur);
|
||||
// ease-out cubic
|
||||
const eased = 1 - Math.pow(1 - t, 3);
|
||||
const current = fromRef.current + (toRef.current - fromRef.current) * eased;
|
||||
setDisplayed(current);
|
||||
if (t < 1) {
|
||||
rafRef.current = requestAnimationFrame(step);
|
||||
} else {
|
||||
rafRef.current = null;
|
||||
}
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(step);
|
||||
|
||||
return () => {
|
||||
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [value, reduced, maxDurationMs]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return <>{format(displayed)}</>;
|
||||
};
|
||||
@@ -946,13 +946,24 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
)}
|
||||
</Box>
|
||||
{!isDraft && id && (
|
||||
<Tooltip title="Schedule this chat as a recurring workflow">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => setScheduleAnchor(e.currentTarget)}
|
||||
sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }}>
|
||||
<ScheduleIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Tooltip title="Turn this chat into a recurring workflow that runs on its own.">
|
||||
<Box
|
||||
onClick={(e) => setScheduleAnchor(e.currentTarget as HTMLElement)}
|
||||
role="button"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.4,
|
||||
fontSize: '0.78rem', fontWeight: 600,
|
||||
color: c.accent.primary,
|
||||
bgcolor: c.accent.primary + '14',
|
||||
border: `1px solid ${c.accent.primary}40`,
|
||||
px: 0.85, py: 0.35,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: c.accent.primary + '22' },
|
||||
}}>
|
||||
<ScheduleIcon sx={{ fontSize: 14 }} />
|
||||
Schedule
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!isDraft && id && (
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
interface Props {
|
||||
currentIndex: number;
|
||||
totalBranches: number;
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
const BranchNavigator: React.FC<Props> = ({ currentIndex, totalBranches, onPrevious, onNext }) => {
|
||||
const c = useClaudeTokens();
|
||||
if (totalBranches <= 1) return null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
mt: -0.25,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.25,
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onPrevious}
|
||||
disabled={currentIndex === 0}
|
||||
sx={{ color: c.text.tertiary, p: 0.25, '&.Mui-disabled': { color: c.border.medium } }}
|
||||
>
|
||||
<ChevronLeftIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
<Typography sx={{ color: c.text.tertiary, fontSize: '0.7rem', minWidth: 28, textAlign: 'center', userSelect: 'none' }}>
|
||||
{currentIndex + 1} / {totalBranches}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onNext}
|
||||
disabled={currentIndex === totalBranches - 1}
|
||||
sx={{ color: c.text.tertiary, p: 0.25, '&.Mui-disabled': { color: c.border.medium } }}
|
||||
>
|
||||
<ChevronRightIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BranchNavigator;
|
||||
@@ -1,402 +0,0 @@
|
||||
import React, { useRef, useEffect, useCallback } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
const PALETTES = {
|
||||
salmon: ['#C46B57', '#D4795F', '#E8927A', '#F0A088', '#F5B49E'],
|
||||
blue: ['#445588', '#5577AA', '#6688BB', '#7799CC', '#88AADD'],
|
||||
coral: ['#993344', '#AA3D4E', '#BB4455', '#CC5566', '#DD6677'],
|
||||
green: ['#447755', '#558866', '#669977', '#77AA88', '#88BB99'],
|
||||
purple: ['#665588', '#7766AA', '#8877BB', '#9988CC', '#AA99DD'],
|
||||
} as const;
|
||||
|
||||
type PaletteKey = keyof typeof PALETTES;
|
||||
|
||||
interface PixelChartProps {
|
||||
data: { label: string; value: number }[];
|
||||
palette?: PaletteKey;
|
||||
height?: number;
|
||||
pixelSize?: number;
|
||||
formatValue?: (v: number) => string;
|
||||
glow?: boolean;
|
||||
showXLabels?: boolean;
|
||||
showYScale?: boolean;
|
||||
mode?: 'bar' | 'area'; // 'area' draws a filled line chart instead of bars
|
||||
}
|
||||
|
||||
const PixelChart: React.FC<PixelChartProps> = ({
|
||||
data,
|
||||
palette = 'salmon',
|
||||
height = 140,
|
||||
pixelSize = 6,
|
||||
formatValue,
|
||||
glow = true,
|
||||
showXLabels = true,
|
||||
showYScale = true,
|
||||
mode = 'bar',
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const animRef = useRef(0);
|
||||
const progressRef = useRef(0);
|
||||
const hoverIdxRef = useRef(-1);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const c = useClaudeTokens();
|
||||
const colors = PALETTES[palette];
|
||||
|
||||
const maxVal = Math.max(...data.map((d) => d.value), 0.001);
|
||||
|
||||
// Compute nice Y-axis ticks
|
||||
const yTicks = (() => {
|
||||
if (maxVal <= 0) return [0];
|
||||
const rawStep = maxVal / 3;
|
||||
const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
|
||||
const normalised = rawStep / magnitude;
|
||||
let niceStep: number;
|
||||
if (normalised <= 1) niceStep = magnitude;
|
||||
else if (normalised <= 2) niceStep = 2 * magnitude;
|
||||
else if (normalised <= 5) niceStep = 5 * magnitude;
|
||||
else niceStep = 10 * magnitude;
|
||||
const ticks: number[] = [];
|
||||
for (let v = 0; v <= maxVal * 1.1; v += niceStep) {
|
||||
ticks.push(v);
|
||||
}
|
||||
if (ticks.length < 2) ticks.push(niceStep);
|
||||
return ticks;
|
||||
})();
|
||||
|
||||
// X-axis labels: show first, last, and up to 3 evenly spaced
|
||||
const xLabels = (() => {
|
||||
if (data.length <= 1) return data.map((d, i) => ({ idx: i, label: d.label }));
|
||||
if (data.length <= 5) return data.map((d, i) => ({ idx: i, label: d.label }));
|
||||
const result: { idx: number; label: string }[] = [];
|
||||
result.push({ idx: 0, label: data[0].label });
|
||||
const step = Math.floor(data.length / 4);
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const idx = Math.min(i * step, data.length - 2);
|
||||
if (idx > 0 && idx < data.length - 1) {
|
||||
result.push({ idx, label: data[idx].label });
|
||||
}
|
||||
}
|
||||
result.push({ idx: data.length - 1, label: data[data.length - 1].label });
|
||||
return result;
|
||||
})();
|
||||
|
||||
const Y_LABEL_WIDTH = showYScale ? 80 : 0;
|
||||
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container || data.length === 0) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const totalW = container.clientWidth;
|
||||
const chartW = totalW - Y_LABEL_WIDTH;
|
||||
const h = height;
|
||||
canvas.width = totalW * dpr;
|
||||
canvas.height = h * dpr;
|
||||
canvas.style.width = `${totalW}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const px = pixelSize;
|
||||
const gridCols = Math.floor(chartW / px);
|
||||
const gridRows = Math.floor(h / px);
|
||||
const effectiveMax = yTicks[yTicks.length - 1] || maxVal;
|
||||
|
||||
ctx.clearRect(0, 0, totalW, h);
|
||||
|
||||
// Y-axis labels and horizontal grid lines
|
||||
if (showYScale) {
|
||||
ctx.font = '10px monospace';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
for (const tick of yTicks) {
|
||||
const yNorm = effectiveMax > 0 ? tick / effectiveMax : 0;
|
||||
const yPx = h - yNorm * (h - px);
|
||||
|
||||
// Grid line
|
||||
ctx.strokeStyle = c.border.subtle;
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.setLineDash([2, 4]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(Y_LABEL_WIDTH, yPx);
|
||||
ctx.lineTo(totalW, yPx);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Label
|
||||
const label = formatValue ? formatValue(tick) : (tick % 1 === 0 ? String(tick) : tick.toFixed(1));
|
||||
ctx.fillStyle = c.text.ghost;
|
||||
ctx.fillText(label, Y_LABEL_WIDTH - 8, yPx);
|
||||
}
|
||||
}
|
||||
|
||||
// Subtle grid dots in chart area
|
||||
ctx.fillStyle = c.border.subtle;
|
||||
for (let gy = 0; gy < gridRows; gy += 5) {
|
||||
for (let gx = 0; gx < gridCols; gx += 5) {
|
||||
ctx.fillRect(Y_LABEL_WIDTH + gx * px, gy * px, 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
const progress = Math.min(progressRef.current, 1);
|
||||
const hoverIdx = hoverIdxRef.current;
|
||||
|
||||
if (mode === 'area') {
|
||||
// -- Area / line chart mode --
|
||||
const usableH = h - px * 2;
|
||||
const points: { x: number; y: number }[] = [];
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const val = data[i].value;
|
||||
const norm = effectiveMax > 0 ? val / effectiveMax : 0;
|
||||
const x = Y_LABEL_WIDTH + (i / Math.max(data.length - 1, 1)) * chartW;
|
||||
const y = h - px - norm * usableH * progress;
|
||||
points.push({ x, y });
|
||||
}
|
||||
|
||||
if (points.length > 0) {
|
||||
// Filled area with gradient
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, h);
|
||||
gradient.addColorStop(0, colors[colors.length - 1] + '60');
|
||||
gradient.addColorStop(0.5, colors[Math.floor(colors.length / 2)] + '30');
|
||||
gradient.addColorStop(1, colors[0] + '08');
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(points[0].x, h);
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
if (i === 0) {
|
||||
ctx.lineTo(points[i].x, points[i].y);
|
||||
} else {
|
||||
const prev = points[i - 1];
|
||||
const curr = points[i];
|
||||
const cpx = (prev.x + curr.x) / 2;
|
||||
ctx.bezierCurveTo(cpx, prev.y, cpx, curr.y, curr.x, curr.y);
|
||||
}
|
||||
}
|
||||
ctx.lineTo(points[points.length - 1].x, h);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fill();
|
||||
|
||||
// Line on top
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
if (i === 0) {
|
||||
ctx.moveTo(points[i].x, points[i].y);
|
||||
} else {
|
||||
const prev = points[i - 1];
|
||||
const curr = points[i];
|
||||
const cpx = (prev.x + curr.x) / 2;
|
||||
ctx.bezierCurveTo(cpx, prev.y, cpx, curr.y, curr.x, curr.y);
|
||||
}
|
||||
}
|
||||
ctx.strokeStyle = colors[colors.length - 1];
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
// Glow on line
|
||||
if (glow) {
|
||||
ctx.shadowColor = colors[colors.length - 1];
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
// Data point dots
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
if (data[i].value > 0) {
|
||||
const isHov = i === hoverIdx;
|
||||
ctx.beginPath();
|
||||
ctx.arc(points[i].x, points[i].y, isHov ? 4 : 2.5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = isHov ? colors[colors.length - 1] : colors[Math.floor(colors.length / 2)];
|
||||
ctx.fill();
|
||||
if (isHov) {
|
||||
ctx.strokeStyle = colors[colors.length - 1];
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pixel scatter in the filled area for the pixel art feel
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const p1 = points[i];
|
||||
const p2 = points[i + 1];
|
||||
const steps = Math.ceil((p2.x - p1.x) / px);
|
||||
for (let s = 0; s < steps; s++) {
|
||||
const t = s / steps;
|
||||
const x = p1.x + t * (p2.x - p1.x);
|
||||
const lineY = p1.y + t * (p2.y - p1.y);
|
||||
for (let py = lineY + px * 2; py < h - px; py += px * 2) {
|
||||
if (Math.random() > 0.65) {
|
||||
const depth = (py - lineY) / (h - lineY);
|
||||
const ci = Math.max(0, Math.floor((1 - depth) * (colors.length - 1)));
|
||||
ctx.globalAlpha = 0.15 + (1 - depth) * 0.2;
|
||||
ctx.fillStyle = colors[ci];
|
||||
ctx.fillRect(Math.floor(x / px) * px, Math.floor(py / px) * px, px - 1, px - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
} else {
|
||||
// -- Bar chart mode (original) --
|
||||
const barSlots = data.length;
|
||||
const totalBarPx = Math.max(1, Math.floor(gridCols / barSlots));
|
||||
const barW = Math.max(1, totalBarPx - 1);
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const val = data[i].value;
|
||||
const normalised = effectiveMax > 0 ? val / effectiveMax : 0;
|
||||
const usableRows = gridRows - 2;
|
||||
const targetH = Math.max(normalised > 0 ? 1 : 0, Math.round(normalised * usableRows));
|
||||
const barH = Math.round(targetH * progress);
|
||||
const barX = i * totalBarPx;
|
||||
const isHovered = i === hoverIdx;
|
||||
|
||||
for (let row = 0; row < barH; row++) {
|
||||
const y = gridRows - 1 - row;
|
||||
const colorIdx = Math.min(colors.length - 1, Math.floor((row / Math.max(barH - 1, 1)) * (colors.length - 1)));
|
||||
const baseColor = isHovered ? colors[Math.min(colorIdx + 1, colors.length - 1)] : colors[colorIdx];
|
||||
|
||||
for (let col = 0; col < barW; col++) {
|
||||
ctx.fillStyle = baseColor;
|
||||
ctx.fillRect(Y_LABEL_WIDTH + (barX + col) * px, y * px, px - 1, px - 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (glow && barH > 0) {
|
||||
const topY = (gridRows - 1 - barH + 1) * px;
|
||||
ctx.shadowColor = colors[colors.length - 1];
|
||||
ctx.shadowBlur = 6;
|
||||
ctx.fillStyle = colors[colors.length - 1];
|
||||
for (let col = 0; col < barW; col++) {
|
||||
ctx.fillRect(Y_LABEL_WIDTH + (barX + col) * px, topY, px - 1, px - 1);
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [data, height, pixelSize, c, colors, glow, maxVal, yTicks, showYScale, Y_LABEL_WIDTH, formatValue, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
progressRef.current = 0;
|
||||
let start: number | null = null;
|
||||
const animate = (ts: number) => {
|
||||
if (!start) start = ts;
|
||||
progressRef.current = Math.min(1, (ts - start) / 600);
|
||||
draw();
|
||||
if (progressRef.current < 1) animRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
animRef.current = requestAnimationFrame(animate);
|
||||
return () => cancelAnimationFrame(animRef.current);
|
||||
}, [data, draw]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => draw();
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [draw]);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const canvas = canvasRef.current;
|
||||
const tooltip = tooltipRef.current;
|
||||
if (!canvas || !tooltip || data.length === 0) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left - Y_LABEL_WIDTH;
|
||||
if (mx < 0) { hoverIdxRef.current = -1; tooltip.style.opacity = '0'; draw(); return; }
|
||||
|
||||
const chartW = rect.width - Y_LABEL_WIDTH;
|
||||
const gridCols = Math.floor(chartW / pixelSize);
|
||||
const totalBarPx = Math.max(1, Math.floor(gridCols / data.length));
|
||||
const idx = Math.floor(mx / (totalBarPx * pixelSize));
|
||||
|
||||
if (idx >= 0 && idx < data.length) {
|
||||
hoverIdxRef.current = idx;
|
||||
const d = data[idx];
|
||||
const valStr = formatValue ? formatValue(d.value) : d.value.toFixed(2);
|
||||
tooltip.textContent = `${d.label}: ${valStr}`;
|
||||
tooltip.style.opacity = '1';
|
||||
tooltip.style.left = `${e.clientX - rect.left}px`;
|
||||
tooltip.style.top = `${e.clientY - rect.top - 28}px`;
|
||||
} else {
|
||||
hoverIdxRef.current = -1;
|
||||
tooltip.style.opacity = '0';
|
||||
}
|
||||
draw();
|
||||
},
|
||||
[data, pixelSize, draw, formatValue, Y_LABEL_WIDTH],
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
hoverIdxRef.current = -1;
|
||||
if (tooltipRef.current) tooltipRef.current.style.opacity = '0';
|
||||
draw();
|
||||
}, [draw]);
|
||||
|
||||
return (
|
||||
<Box ref={containerRef} sx={{ position: 'relative', width: '100%' }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
style={{ display: 'block', width: '100%', imageRendering: 'pixelated', cursor: 'crosshair' }}
|
||||
/>
|
||||
{/* X-axis labels */}
|
||||
{showXLabels && data.length > 0 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 0.5, pl: `${Y_LABEL_WIDTH}px` }}>
|
||||
{xLabels.map((xl) => (
|
||||
<Typography
|
||||
key={xl.idx}
|
||||
sx={{
|
||||
color: c.text.ghost,
|
||||
fontSize: '0.58rem',
|
||||
fontFamily: c.font.mono,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: 60,
|
||||
}}
|
||||
>
|
||||
{xl.label}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{/* Tooltip */}
|
||||
<Box
|
||||
ref={tooltipRef}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
pointerEvents: 'none',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.12s',
|
||||
bgcolor: c.bg.inverse,
|
||||
color: c.text.inverse,
|
||||
fontSize: '0.7rem',
|
||||
fontFamily: c.font.mono,
|
||||
fontWeight: 500,
|
||||
px: 1,
|
||||
py: 0.35,
|
||||
borderRadius: 0.75,
|
||||
whiteSpace: 'nowrap',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 10,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PixelChart;
|
||||
@@ -1,60 +0,0 @@
|
||||
import React from 'react';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogContentText from '@mui/material/DialogContentText';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import Button from '@mui/material/Button';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
const CloseAgentDialog: React.FC<Props> = ({ open, onCancel, onConfirm }) => {
|
||||
const c = useClaudeTokens();
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onCancel}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
bgcolor: c.bg.surface,
|
||||
borderRadius: 4,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
minWidth: 380,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ color: c.status.warning, fontWeight: 700, fontSize: '1rem', pb: 0.5 }}>
|
||||
Agent still running
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText sx={{ color: c.text.muted, fontSize: '0.875rem' }}>
|
||||
This agent is still running. Closing it will pause the agent.
|
||||
You can resume it later from the chat history.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={onCancel} sx={{ color: c.text.tertiary }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
variant="contained"
|
||||
sx={{
|
||||
bgcolor: c.status.warning,
|
||||
'&:hover': { bgcolor: '#6b4a18' },
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Close & Pause
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default CloseAgentDialog;
|
||||
@@ -10,11 +10,33 @@ import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { fetchCloudSmsStatus, type Workflow, type ScheduleConfig, type PermissionTier } from '@/shared/state/workflowsSlice';
|
||||
import { WEEKDAY_LABEL, formatTime, fireTimesWithin } from './scheduleUtils';
|
||||
import { routingFor } from './workflowVisuals';
|
||||
import { nextTierAfter } from './permissionsUtils';
|
||||
import { BODY_FS, LABEL_FS, HINT_FS, INPUT_FS } from './workflowEditCommon';
|
||||
|
||||
function jsWeekday(d: Date): number { return d.getDay(); }
|
||||
|
||||
// Turn an IANA zone string into something a non-dev can parse. "local"
|
||||
// (legacy) or the host's own zone collapse to "your time"; otherwise
|
||||
// show "Pacific Time" / "Eastern Time" / etc. when we can resolve a
|
||||
// short name via Intl, falling back to the raw IANA name if not.
|
||||
function friendlyTzLabel(tz: string): string {
|
||||
if (!tz || tz === 'local') return 'your time';
|
||||
try {
|
||||
const host = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
if (tz === host) {
|
||||
const parts = new Intl.DateTimeFormat('en', { timeZone: tz, timeZoneName: 'long' }).formatToParts(new Date());
|
||||
const name = parts.find((p) => p.type === 'timeZoneName')?.value || '';
|
||||
return name ? `your time (${name.replace(' Standard Time', '').replace(' Daylight Time', '')})` : 'your time';
|
||||
}
|
||||
const parts = new Intl.DateTimeFormat('en', { timeZone: tz, timeZoneName: 'long' }).formatToParts(new Date());
|
||||
const name = parts.find((p) => p.type === 'timeZoneName')?.value || '';
|
||||
return name || tz;
|
||||
} catch {
|
||||
return tz;
|
||||
}
|
||||
}
|
||||
|
||||
function lastDayOfMonthFE(year: number, monthZeroBased: number): number {
|
||||
return new Date(year, monthZeroBased + 1, 0).getDate();
|
||||
}
|
||||
@@ -195,7 +217,7 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
|
||||
</Box>
|
||||
{s.repeat_unit === 'week' && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, pl: 2, flexWrap: 'wrap' }}>
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>↳ on</Typography>
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>on</Typography>
|
||||
{WEEKDAY_LABEL.map((label, idx) => {
|
||||
const active = s.on_days.includes(idx);
|
||||
return (
|
||||
@@ -209,7 +231,7 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, pl: 2 }}>
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>↳ at</Typography>
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>at</Typography>
|
||||
{/* 12-hour picker; backend stores 0..23 but the UI uses 1..12+AM/PM
|
||||
so users can't accidentally schedule "3" thinking it's 3pm and
|
||||
get a 3am run. */}
|
||||
@@ -250,7 +272,7 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
|
||||
<MenuItem value="AM">AM</MenuItem>
|
||||
<MenuItem value="PM">PM</MenuItem>
|
||||
</Select>
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.text.ghost, ml: 1 }}>{s.timezone === 'local' ? 'system tz' : s.timezone}</Typography>
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.text.ghost, ml: 1 }}>{friendlyTzLabel(s.timezone)}</Typography>
|
||||
</Box>
|
||||
|
||||
{nextPreview && s.enabled && (
|
||||
@@ -267,9 +289,9 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
|
||||
value={endKind}
|
||||
onChange={(e) => setEndKind(e.target.value as EndKind)}
|
||||
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
|
||||
<MenuItem value="forever">Forever</MenuItem>
|
||||
<MenuItem value="forever">Until I turn it off</MenuItem>
|
||||
<MenuItem value="on_date">Until a date</MenuItem>
|
||||
<MenuItem value="after_n">After N runs</MenuItem>
|
||||
<MenuItem value="after_n">After a number of runs</MenuItem>
|
||||
</Select>
|
||||
{endKind === 'on_date' && (
|
||||
<InputBase
|
||||
@@ -294,13 +316,36 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{/* Inline warnings when the end condition is already satisfied; the
|
||||
scheduler will auto-disable on the next tick which surprises
|
||||
users who expected to arm a fresh schedule. */}
|
||||
{(() => {
|
||||
if (endKind === 'on_date' && s.ends_at) {
|
||||
const ends = new Date(s.ends_at).getTime();
|
||||
if (!Number.isNaN(ends) && ends <= Date.now()) {
|
||||
return (
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.status.warning || c.text.muted, pl: 2 }}>
|
||||
This date is in the past. The schedule will turn itself off.
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
}
|
||||
if (endKind === 'after_n' && s.max_runs != null && s.runs_count >= s.max_runs) {
|
||||
return (
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.status.warning || c.text.muted, pl: 2 }}>
|
||||
This workflow has already run {s.runs_count}× (limit {s.max_runs}). Raise the number or reset the counter to re-arm.
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
||||
{/* Row 5: cost. Pass the live draft schedule so the row stays in
|
||||
sync with the "Next run" preview even before the user saves. */}
|
||||
<CostRow workflow={draft} draftSched={s} onCapChange={(v) => setDraft({ ...draft, cost_cap_usd_monthly: v })} />
|
||||
|
||||
{/* Row 6: action surface (freeze). */}
|
||||
<Typography sx={{ fontSize: BODY_FS, fontWeight: 700, color: c.text.primary, mt: 0.5 }}>Which actions can the agent use?</Typography>
|
||||
<Typography sx={{ fontSize: BODY_FS, fontWeight: 700, color: c.text.primary, mt: 0.5 }}>What can the agent do while it runs?</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, pl: 2 }}>
|
||||
<Select
|
||||
size="small"
|
||||
@@ -308,28 +353,30 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
|
||||
onChange={(e) => {
|
||||
const scoped = e.target.value === 'scoped';
|
||||
if (!scoped) {
|
||||
const ok = window.confirm('"Full agent access" lets this scheduled run execute Bash, edit files, and use any installed action. Are you sure?');
|
||||
const ok = window.confirm('Full access lets this scheduled run do anything an agent normally can: run commands, edit files, browse the web, send messages. Continue?');
|
||||
if (!ok) return;
|
||||
}
|
||||
setDraft({ ...draft, actions: { ...draft.actions, freeze: scoped } });
|
||||
}}
|
||||
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
|
||||
<MenuItem value="scoped">Scoped to actions used in original chat (recommended)</MenuItem>
|
||||
<MenuItem value="full">Full agent access (Bash, file write)</MenuItem>
|
||||
<MenuItem value="scoped">Only what the original chat used (recommended)</MenuItem>
|
||||
<MenuItem value="full">Anything an agent can do (run commands, edit files, browse)</MenuItem>
|
||||
</Select>
|
||||
</Box>
|
||||
|
||||
{/* Row 7: missed-run policy. */}
|
||||
{/* Row 7: missed-run policy. Backend implements one catch-up only
|
||||
today, so we don't expose a "run every missed time" option that
|
||||
we couldn't honor. If the backend gains real replay support
|
||||
later, add the third option back. */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, pl: 2, mt: 0.25 }}>
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>If a run was missed (computer asleep):</Typography>
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>If your computer was asleep when a run was due:</Typography>
|
||||
<Select
|
||||
size="small"
|
||||
value={s.on_missed}
|
||||
value={s.on_missed === 'run_all' ? 'run_once' : s.on_missed}
|
||||
onChange={(e) => setSched({ on_missed: e.target.value as ScheduleConfig['on_missed'] })}
|
||||
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
|
||||
<MenuItem value="skip">Skip it</MenuItem>
|
||||
<MenuItem value="run_once">Run once when app reopens</MenuItem>
|
||||
<MenuItem value="run_all">Run every missed slot</MenuItem>
|
||||
<MenuItem value="skip">Skip the missed run</MenuItem>
|
||||
<MenuItem value="run_once">Run once after I wake the app</MenuItem>
|
||||
</Select>
|
||||
</Box>
|
||||
|
||||
@@ -346,7 +393,7 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
|
||||
/>
|
||||
))}
|
||||
{canAddBackup && (
|
||||
<Box onClick={addBackup} role="button" sx={{ fontSize: LABEL_FS, color: c.text.muted, cursor: 'pointer', mt: 0.5, fontWeight: 500, '&:hover': { color: c.accent.primary } }}>+ add a backup</Box>
|
||||
<Box onClick={addBackup} role="button" sx={{ fontSize: LABEL_FS, color: c.text.muted, cursor: 'pointer', mt: 0.5, fontWeight: 500, '&:hover': { color: c.accent.primary } }}>+ Escalate if I don't respond</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
@@ -365,11 +412,11 @@ function AppOpenStatusBadge({ info, hour, minute, onFix }: { info: AppOpenInfo;
|
||||
}}>
|
||||
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: good ? c.status.success : (c.status.warning || c.text.muted) }} />
|
||||
<Typography sx={{ flex: 1, fontSize: HINT_FS, color: c.text.primary }}>
|
||||
{good ? 'Will fire even if OpenSwarm is closed.' : `Requires OpenSwarm to be open at ${fmt}.`}
|
||||
{good ? 'Will run even if you close OpenSwarm.' : `OpenSwarm must be open at ${fmt} for this to run.`}
|
||||
</Typography>
|
||||
{!good && (
|
||||
<Tooltip title="Enables launch-at-login and the menubar tray so the scheduler keeps running.">
|
||||
<Box onClick={onFix} role="button" sx={{ fontSize: HINT_FS, color: c.accent.primary, cursor: 'pointer', fontWeight: 700 }}>Fix</Box>
|
||||
<Tooltip title="One click: start OpenSwarm automatically when you log in, and keep a small icon in your menubar so it stays running when you close the window. You can undo both later in Settings.">
|
||||
<Box onClick={onFix} role="button" sx={{ fontSize: HINT_FS, color: c.accent.primary, cursor: 'pointer', fontWeight: 700, whiteSpace: 'nowrap' }}>Always-on</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
@@ -379,6 +426,7 @@ function AppOpenStatusBadge({ info, hour, minute, onFix }: { info: AppOpenInfo;
|
||||
function CostRow({ workflow, draftSched, onCapChange }: { workflow: Workflow; draftSched: ScheduleConfig; onCapChange: (v: number | null) => void }) {
|
||||
const c = useClaudeTokens();
|
||||
const est = workflow.cost_estimate;
|
||||
const connectionMode = useAppSelector((s) => (s as { settings?: { data?: { connection_mode?: string } } }).settings?.data?.connection_mode);
|
||||
// Compute fires/30-days live from the draft so the row matches the
|
||||
// "Next run" preview even before the user saves. Backend's cached
|
||||
// estimate is the saved-state value and would lie after a draft edit.
|
||||
@@ -388,20 +436,46 @@ function CostRow({ workflow, draftSched, onCapChange }: { workflow: Workflow; dr
|
||||
const end = new Date(now.getTime() + 30 * 86400000);
|
||||
return fireTimesWithin({ schedule: draftSched } as Workflow, now, end, 200).length;
|
||||
}, [draftSched]);
|
||||
const route = routingFor(workflow.model, connectionMode);
|
||||
const lastRun = est?.last_run_usd ?? 0;
|
||||
const monthly = lastRun * liveFires;
|
||||
const cap = workflow.cost_cap_usd_monthly;
|
||||
|
||||
// Subscription-routed workflows have no per-call cost we can project,
|
||||
// so swap the row from "$X.XX/mo" copy to a usage-estimate sentence
|
||||
// that tells the truth: covered by the plan, here's how often it fires.
|
||||
if (route.kind === 'subscription') {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4, pl: 2 }}>
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>
|
||||
{liveFires > 0
|
||||
? `Will use about ${liveFires} run${liveFires === 1 ? '' : 's'} per month from your ${route.subLabel} plan. No per-run cost.`
|
||||
: `Covered by your ${route.subLabel} plan. No upcoming runs yet.`}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.text.ghost }}>
|
||||
A monthly cost cap doesn't apply here. Your plan handles the usage limits.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4, pl: 2 }}>
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>
|
||||
{liveFires > 0 && lastRun > 0
|
||||
? `~$${monthly.toFixed(2)}/mo at last run's cost ($${lastRun.toFixed(4)} × ${liveFires} fires).`
|
||||
? `About $${monthly.toFixed(2)} per month at the last run's cost.`
|
||||
: liveFires > 0
|
||||
? `Will fire ${liveFires}× in the next 30 days. Run once to project a monthly cost.`
|
||||
? `Will run ${liveFires} time${liveFires === 1 ? '' : 's'} in the next 30 days. Run once to project a monthly cost.`
|
||||
: 'No upcoming runs.'}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>Monthly cost cap:</Typography>
|
||||
{liveFires > 0 && lastRun > 0 && (
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.text.ghost }}>
|
||||
{`$${lastRun.toFixed(4)} × ${liveFires} runs`}
|
||||
</Typography>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>Monthly cap:</Typography>
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.text.ghost }}>$</Typography>
|
||||
<InputBase
|
||||
type="number"
|
||||
placeholder="none"
|
||||
@@ -409,8 +483,10 @@ function CostRow({ workflow, draftSched, onCapChange }: { workflow: Workflow; dr
|
||||
onChange={(e) => onCapChange(e.target.value === '' ? null : Math.max(0, Number(e.target.value)))}
|
||||
sx={{ width: 72, fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.3 }}
|
||||
/>
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.text.ghost }}>USD. Skips runs once exceeded; visible in History.</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.text.ghost }}>
|
||||
We'll skip runs once you hit this for the month. You'll see the skip in History.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
// Minimum-steps-to-value entry point: from any open chat, hit "Schedule"
|
||||
// in the header, pick one of four presets, and we materialize a workflow
|
||||
// seeded with source_session_id (so it inherits the chat's tool surface
|
||||
// + steps via the existing /workflows/create path). "Custom..." opens
|
||||
// the full editor for power users.
|
||||
// + steps via the existing /workflows/create path). "Custom..." opens a
|
||||
// LOCAL draft card instead of immediately POSTing /workflows/create, so
|
||||
// users who change their mind don't leave behind an orphan workflow.
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import InputBase from '@mui/material/InputBase';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { createWorkflow, openWorkflowCard, type ScheduleConfig } from '@/shared/state/workflowsSlice';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { createWorkflow, openWorkflowCard, type ScheduleConfig, type Workflow } from '@/shared/state/workflowsSlice';
|
||||
import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { defaultSchedule } from './scheduleUtils';
|
||||
|
||||
type Preset = {
|
||||
@@ -42,6 +44,18 @@ export default function ScheduleThisPopover({ anchorEl, onClose, sessionId, sess
|
||||
const [title, setTitle] = useState<string>(sessionName || 'Untitled');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const workflows = useAppSelector((s) => s.workflows.items);
|
||||
|
||||
// Dup-detect: a chat session can only sanely have one schedule attached.
|
||||
// If we find one already, offer "Open existing" instead of silently
|
||||
// creating a duplicate that fires twice.
|
||||
const existing = useMemo<Workflow | null>(() => {
|
||||
if (!sessionId) return null;
|
||||
for (const w of Object.values(workflows)) {
|
||||
if (w.source_session_id === sessionId) return w;
|
||||
}
|
||||
return null;
|
||||
}, [workflows, sessionId]);
|
||||
|
||||
const submit = useCallback(async (preset: Preset) => {
|
||||
if (busy) return;
|
||||
@@ -53,9 +67,10 @@ export default function ScheduleThisPopover({ anchorEl, onClose, sessionId, sess
|
||||
title,
|
||||
source_session_id: sessionId,
|
||||
schedule,
|
||||
} as any));
|
||||
} as Partial<Workflow>));
|
||||
if (createWorkflow.fulfilled.match(result)) {
|
||||
const wf: any = result.payload;
|
||||
const wf = result.payload as Workflow;
|
||||
dispatch(addWorkflowCard({ workflowId: wf.id, sourceSessionId: sessionId }));
|
||||
dispatch(openWorkflowCard({ workflowId: wf.id, view: 'saved' }));
|
||||
onCreated?.(wf.id);
|
||||
onClose();
|
||||
@@ -69,31 +84,33 @@ export default function ScheduleThisPopover({ anchorEl, onClose, sessionId, sess
|
||||
}
|
||||
}, [busy, dispatch, sessionId, title, onClose, onCreated]);
|
||||
|
||||
const openCustom = useCallback(async () => {
|
||||
// "Custom..." materializes a workflow with schedule.enabled=false
|
||||
// and routes to the full editor. The editor's master toggle is the
|
||||
// explicit gate — nothing fires until the user flips it on.
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const schedule: ScheduleConfig = { ...defaultSchedule() };
|
||||
const result = await dispatch(createWorkflow({
|
||||
const openCustom = useCallback(() => {
|
||||
// Open a local draft. NO backend create yet — the workflow only
|
||||
// exists on disk once the user clicks Save in the editor. Closing
|
||||
// the draft card from here leaves nothing behind (the "orphan"
|
||||
// bug from the previous create-then-edit flow).
|
||||
const tempId = `draft-${sessionId}-${Date.now()}`;
|
||||
dispatch(addWorkflowCard({ workflowId: tempId, sourceSessionId: sessionId }));
|
||||
dispatch(openWorkflowCard({
|
||||
workflowId: tempId,
|
||||
sourceSessionId: sessionId,
|
||||
view: 'preview',
|
||||
draft: {
|
||||
title,
|
||||
source_session_id: sessionId,
|
||||
schedule,
|
||||
} as any));
|
||||
if (createWorkflow.fulfilled.match(result)) {
|
||||
const wf: any = result.payload;
|
||||
dispatch(openWorkflowCard({ workflowId: wf.id, view: 'edit', editFacet: 'Schedule' }));
|
||||
onCreated?.(wf.id);
|
||||
onClose();
|
||||
} else {
|
||||
setError('Create failed. Try again.');
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, dispatch, sessionId, title, onClose, onCreated]);
|
||||
description: 'Scheduled from chat. Edit anytime.',
|
||||
steps: [{ id: 'step-1', text: '' }],
|
||||
schedule: { ...defaultSchedule() },
|
||||
} as Partial<Workflow>,
|
||||
}));
|
||||
onClose();
|
||||
}, [dispatch, sessionId, title, onClose]);
|
||||
|
||||
const openExisting = useCallback(() => {
|
||||
if (!existing) return;
|
||||
dispatch(addWorkflowCard({ workflowId: existing.id, sourceSessionId: sessionId }));
|
||||
dispatch(openWorkflowCard({ workflowId: existing.id, view: 'saved' }));
|
||||
onClose();
|
||||
}, [dispatch, existing, sessionId, onClose]);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
@@ -107,6 +124,30 @@ export default function ScheduleThisPopover({ anchorEl, onClose, sessionId, sess
|
||||
<Typography sx={{ fontSize: '0.78rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em', mb: 0.75 }}>
|
||||
SCHEDULE THIS CHAT
|
||||
</Typography>
|
||||
{existing && (
|
||||
<Box sx={{
|
||||
display: 'flex', flexDirection: 'column', gap: 0.4,
|
||||
px: 1, py: 0.75, mb: 0.75,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
bgcolor: c.status.warningBg || c.bg.elevated,
|
||||
border: `1px solid ${(c.status.warning || c.text.muted) + '60'}`,
|
||||
}}>
|
||||
<Typography sx={{ fontSize: '0.78rem', fontWeight: 700, color: c.text.primary }}>
|
||||
This chat is already scheduled.
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>
|
||||
"{existing.title}" was made from this conversation. Adding another would fire twice.
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, mt: 0.5 }}>
|
||||
<Box onClick={openExisting} role="button" sx={{
|
||||
fontSize: '0.74rem', fontWeight: 600, color: c.accent.primary,
|
||||
cursor: 'pointer', px: 0.75, py: 0.3, borderRadius: `${c.radius.md}px`,
|
||||
bgcolor: c.accent.primary + '14', border: `1px solid ${c.accent.primary}40`,
|
||||
'&:hover': { bgcolor: c.accent.primary + '22' },
|
||||
}}>Open existing →</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.secondary }}>Name:</Typography>
|
||||
<InputBase
|
||||
@@ -142,7 +183,7 @@ export default function ScheduleThisPopover({ anchorEl, onClose, sessionId, sess
|
||||
'&:hover': { bgcolor: c.bg.elevated },
|
||||
}}>
|
||||
<Typography sx={{ fontSize: '0.84rem', fontWeight: 600, color: c.accent.primary }}>Custom…</Typography>
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>Open the full editor</Typography>
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>Open the editor without saving yet</Typography>
|
||||
</Box>
|
||||
{error && (
|
||||
<Typography sx={{ mt: 0.5, fontSize: '0.74rem', color: c.status.error }}>{error}</Typography>
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// Vertical step list with connector + optional live-fill during a run +
|
||||
// optional auto-icon per step + optional duration estimate per step.
|
||||
// Used by both the Preview (draft) view and the Saved view so the two
|
||||
// stay visually consistent.
|
||||
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import type { Workflow, WorkflowRun } from '@/shared/state/workflowsSlice';
|
||||
import { stepIconFor, estimateStepDuration } from './workflowVisuals';
|
||||
|
||||
interface Props {
|
||||
workflow?: Workflow | null;
|
||||
steps: Workflow['steps'];
|
||||
runs?: WorkflowRun[];
|
||||
// Pass the active run id to fill the connector progressively as the
|
||||
// workflow streams. Currently estimated by elapsed/expected; once
|
||||
// per-step telemetry ships, swap to a real step-index signal.
|
||||
activeRunId?: string | null;
|
||||
// Subtle frame around each step (used by Preview's edit-mode look). The
|
||||
// Saved view turns this off for a quieter read.
|
||||
framed?: boolean;
|
||||
// Callback when a step row is edited inline; only useful in Preview.
|
||||
onChangeStep?: (idx: number, text: string) => void;
|
||||
}
|
||||
|
||||
const CIRCLE_SIZE = 28;
|
||||
// Vertical connector lives on the inner edge of the circle column; its
|
||||
// x-offset matches CIRCLE_SIZE/2 so it bisects the numbered circles.
|
||||
const CONNECTOR_X = CIRCLE_SIZE / 2;
|
||||
|
||||
export default function StepList({ workflow, steps, runs, activeRunId, framed, onChangeStep }: Props) {
|
||||
const c = useClaudeTokens();
|
||||
const hasSteps = steps && steps.length > 0;
|
||||
if (!hasSteps) return null;
|
||||
|
||||
// Determine "current step" for live-fill. We don't have per-step
|
||||
// telemetry yet, so estimate via elapsed/expected ratio if a run is
|
||||
// active, otherwise leave it null (no fill).
|
||||
const activeStepIdx = useActiveStepIdx(steps.length, runs, activeRunId);
|
||||
|
||||
return (
|
||||
<Box sx={{ position: 'relative', pl: 0, mt: 0.25 }}>
|
||||
{/* Connector spine. SVG so the live-fill segment can clip cleanly. */}
|
||||
{steps.length > 1 && (
|
||||
<Box
|
||||
aria-hidden
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: CONNECTOR_X - 0.5,
|
||||
top: CIRCLE_SIZE * 0.5,
|
||||
bottom: CIRCLE_SIZE * 0.5,
|
||||
width: 1,
|
||||
bgcolor: c.border.medium,
|
||||
opacity: 0.65,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{steps.length > 1 && activeStepIdx !== null && (
|
||||
<Box
|
||||
aria-hidden
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: CONNECTOR_X - 1,
|
||||
top: CIRCLE_SIZE * 0.5,
|
||||
// Progress = (active+1)/total, capped at total-1 so the fill
|
||||
// never overshoots the bottom circle.
|
||||
height: `calc((100% - ${CIRCLE_SIZE}px) * ${Math.min(steps.length - 1, activeStepIdx) / (steps.length - 1)})`,
|
||||
width: 2,
|
||||
bgcolor: c.accent.primary,
|
||||
transition: 'height 0.4s ease-out',
|
||||
boxShadow: `0 0 6px ${c.accent.primary}`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.85 }}>
|
||||
{steps.map((s, idx) => {
|
||||
const Icon = stepIconFor(s.text || '');
|
||||
const duration = workflow ? estimateStepDuration(workflow, runs, idx) : null;
|
||||
const isActive = activeStepIdx === idx;
|
||||
const isPast = activeStepIdx !== null && idx < activeStepIdx;
|
||||
return (
|
||||
<Box key={s.id} sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.25, position: 'relative' }}>
|
||||
<Box sx={{
|
||||
width: CIRCLE_SIZE, height: CIRCLE_SIZE, borderRadius: '50%',
|
||||
border: `1px solid ${isActive || isPast ? c.accent.primary : c.border.medium}`,
|
||||
bgcolor: isActive ? c.accent.primary : isPast ? c.accent.primary + '22' : c.bg.surface,
|
||||
color: isActive ? '#fff' : isPast ? c.accent.primary : c.text.secondary,
|
||||
fontSize: '0.78rem', fontWeight: 700,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
position: 'relative', zIndex: 1,
|
||||
transition: 'background 0.25s ease, color 0.25s ease',
|
||||
}}>
|
||||
{Icon ? <Icon sx={{ fontSize: 14 }} /> : (idx + 1)}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
{onChangeStep ? (
|
||||
<Box
|
||||
component="textarea"
|
||||
value={s.text}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChangeStep(idx, e.target.value)}
|
||||
sx={{
|
||||
width: '100%', resize: 'vertical',
|
||||
fontFamily: 'inherit', fontSize: '0.92rem', color: c.text.primary,
|
||||
border: framed ? `1px solid ${idx === 0 ? c.border.medium : c.border.subtle}` : `1px solid transparent`,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
bgcolor: framed ? c.bg.surface : 'transparent',
|
||||
px: 1.25, py: 0.75, lineHeight: 1.4,
|
||||
'&:focus': { outline: 'none', borderColor: c.accent.primary },
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={{
|
||||
fontSize: '0.92rem', color: c.text.primary,
|
||||
border: framed ? `1px solid ${idx === 0 ? c.border.medium : c.border.subtle}` : 'none',
|
||||
borderRadius: framed ? `${c.radius.md}px` : 0,
|
||||
bgcolor: framed ? c.bg.surface : 'transparent',
|
||||
px: framed ? 1.25 : 0.5, py: framed ? 0.75 : 0.1,
|
||||
lineHeight: 1.45,
|
||||
}}>
|
||||
{s.text}
|
||||
</Box>
|
||||
)}
|
||||
{duration && (
|
||||
<Tooltip title="Estimated from recent successful runs (whole-run duration divided by step count).">
|
||||
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, mt: 0.25, ml: framed ? 1.25 : 0.5 }}>
|
||||
~{duration}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Synthesize an "active step" index from the active run's elapsed time
|
||||
// vs the historical average run duration. Doesn't pretend to be exact;
|
||||
// good enough for the user to see the progress bar advance during a
|
||||
// long workflow. Returns null when no live run.
|
||||
function useActiveStepIdx(stepCount: number, runs: WorkflowRun[] | undefined, activeRunId: string | null | undefined): number | null {
|
||||
const [tick, setTick] = React.useState(0);
|
||||
React.useEffect(() => {
|
||||
if (!activeRunId) return;
|
||||
const id = window.setInterval(() => setTick((t) => (t + 1) % 1000000), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [activeRunId]);
|
||||
void tick;
|
||||
if (!activeRunId || !runs) return null;
|
||||
const active = runs.find((r) => r.id === activeRunId && r.status === 'running');
|
||||
if (!active) return null;
|
||||
const elapsed = Date.now() - new Date(active.started_at).getTime();
|
||||
const completed = runs.filter((r) => (r.status === 'success' || r.status === 'ran_late') && r.finished_at);
|
||||
if (completed.length === 0) {
|
||||
// No history: jump to the middle step so the bar advances visibly.
|
||||
return Math.min(stepCount - 1, Math.max(0, Math.floor(stepCount / 2)));
|
||||
}
|
||||
const durations = completed.slice(0, 10).map((r) => new Date(r.finished_at!).getTime() - new Date(r.started_at).getTime());
|
||||
const avg = durations.reduce((a, b) => a + b, 0) / durations.length || 1;
|
||||
const ratio = Math.min(0.99, Math.max(0, elapsed / avg));
|
||||
return Math.min(stepCount - 1, Math.floor(ratio * stepCount));
|
||||
}
|
||||
@@ -2,6 +2,13 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Snackbar from '@mui/material/Snackbar';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import Button from '@mui/material/Button';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import EditIcon from '@mui/icons-material/EditOutlined';
|
||||
import HistoryIcon from '@mui/icons-material/HistoryRounded';
|
||||
@@ -12,6 +19,7 @@ import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import {
|
||||
closeWorkflowCard,
|
||||
deleteWorkflow,
|
||||
fetchRuns,
|
||||
openWorkflowCard as openWorkflowCardAction,
|
||||
rekeyOpenCard,
|
||||
@@ -27,7 +35,8 @@ import {
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import WorkflowEditViews from './WorkflowEditViews';
|
||||
import { HistoryDetail, HistoryList, PreviewView, SavedView, statusBg, statusColor } from './WorkflowCardSubviews';
|
||||
import { HistoryDetail, HistoryList, PreviewView, SavedView } from './WorkflowCardSubviews';
|
||||
import { StatusDot, RunSparkline, LastFiredHint, isStaleSinceLastRun } from './workflowVisuals';
|
||||
|
||||
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
|
||||
|
||||
@@ -91,15 +100,45 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
// Transient "Starting…" label state on the Run button. See onClick handler
|
||||
// for the full rationale (avoid no-feedback flicker on fast manual runs).
|
||||
const [runStarting, setRunStarting] = useState(false);
|
||||
const [runToast, setRunToast] = useState<string | null>(null);
|
||||
const [editDirty, setEditDirty] = useState(false);
|
||||
|
||||
// ---- Lazy-load runs for the history view ----
|
||||
// Lazy-load runs whenever a view that needs them is open. Saved view
|
||||
// uses runs for the live-fill connector + step duration estimates;
|
||||
// History views obviously need them too.
|
||||
useEffect(() => {
|
||||
if (!card) return;
|
||||
if ((card.view === 'history' || card.view === 'history_detail') && workflow && !runs) {
|
||||
const needsRuns = card.view === 'saved' || card.view === 'history' || card.view === 'history_detail';
|
||||
if (needsRuns && workflow && !runs) {
|
||||
dispatch(fetchRuns(workflow.id));
|
||||
}
|
||||
}, [card?.view, workflow?.id, runs, dispatch]);
|
||||
|
||||
// Keep wheel-scroll inside the card body instead of letting it bubble
|
||||
// up to the dashboard pan/zoom listener. Without this, scrolling the
|
||||
// schedule/history list shifts the canvas underneath the card. Mirrors
|
||||
// the chat-panel wheel guard in AgentChat.tsx. Ctrl/meta + wheel is
|
||||
// intentionally allowed through so canvas zoom still works when the
|
||||
// cursor is over a workflow card.
|
||||
const bodyScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
const el = bodyScrollRef.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) return;
|
||||
const atTop = el.scrollTop <= 0;
|
||||
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
|
||||
const scrollingDown = e.deltaY > 0;
|
||||
const scrollingUp = e.deltaY < 0;
|
||||
if ((scrollingUp && atTop) || (scrollingDown && atBottom)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
e.stopPropagation();
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, []);
|
||||
|
||||
const title = workflow?.title || card?.draft?.title || 'Workflow';
|
||||
const isDraft = card?.view === 'preview' && !workflow;
|
||||
const steps = (workflow?.steps || card?.draft?.steps || []) as Workflow['steps'];
|
||||
@@ -256,10 +295,33 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
}, [computeResize, dispatch, workflowId]);
|
||||
|
||||
// ---- Close: drop transient view state AND remove from layout ----
|
||||
const onClose = useCallback(() => {
|
||||
// Two-step when the schedule is on: a quiet X would make the workflow
|
||||
// a "ghost" (still firing on a hidden timer) which surprises users who
|
||||
// mentally model X as "throw away." Confirm-then-act lets them choose
|
||||
// between hiding the card and actually killing the schedule.
|
||||
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false);
|
||||
const hardClose = useCallback(() => {
|
||||
dispatch(closeWorkflowCard(workflowId));
|
||||
dispatch(removeWorkflowCard(workflowId));
|
||||
}, [dispatch, workflowId]);
|
||||
const onClose = useCallback(() => {
|
||||
if (workflow?.schedule?.enabled) {
|
||||
setCloseConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
hardClose();
|
||||
}, [workflow?.schedule?.enabled, hardClose]);
|
||||
const onConfirmHide = useCallback(() => {
|
||||
setCloseConfirmOpen(false);
|
||||
hardClose();
|
||||
}, [hardClose]);
|
||||
const onConfirmStopAndDelete = useCallback(async () => {
|
||||
setCloseConfirmOpen(false);
|
||||
if (workflow?.id) {
|
||||
await dispatch(deleteWorkflow(workflow.id));
|
||||
}
|
||||
hardClose();
|
||||
}, [dispatch, workflow?.id, hardClose]);
|
||||
|
||||
// ---- Display calculations ----
|
||||
const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0;
|
||||
@@ -336,14 +398,11 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
}}
|
||||
>
|
||||
<DragIndicatorIcon sx={{ fontSize: 16, color: c.text.ghost }} />
|
||||
<StatusDot status={workflow?.last_run_status} />
|
||||
<Typography sx={{ flex: 1, fontWeight: 700, fontSize: '0.95rem', color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{workflow?.last_run_status && (
|
||||
<Box sx={{ fontSize: '0.68rem', fontWeight: 700, color: statusColor(workflow.last_run_status, c), bgcolor: statusBg(workflow.last_run_status, c), px: 0.8, py: 0.3, borderRadius: 0.75 }}>
|
||||
{workflow.last_run_status}
|
||||
</Box>
|
||||
)}
|
||||
{runs && runs.length > 0 && <RunSparkline runs={runs} />}
|
||||
<IconButton
|
||||
size="small"
|
||||
data-no-drag
|
||||
@@ -363,13 +422,24 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
icon={<PlayArrowIcon sx={{ fontSize: 16 }} />}
|
||||
active={card.view === 'saved'}
|
||||
accent
|
||||
breathe={!runStarting && isStaleSinceLastRun(workflow)}
|
||||
breatheTooltip="Haven't run this in a few days. Click to run it now."
|
||||
onClick={async () => {
|
||||
if (runStarting) return;
|
||||
setRunStarting(true);
|
||||
dispatch(updateWorkflowCard({ workflowId, patch: { view: 'history' } }));
|
||||
try {
|
||||
await dispatch(runWorkflowNow(workflow.id));
|
||||
const result = await dispatch(runWorkflowNow(workflow.id));
|
||||
await dispatch(fetchRuns(workflow.id));
|
||||
// Detect skipped manual runs so the user gets a real
|
||||
// explanation instead of a silent button-flicker. The
|
||||
// most common skip today is the monthly cost cap.
|
||||
if (runWorkflowNow.fulfilled.match(result)) {
|
||||
const payload = result.payload;
|
||||
if (payload.status === 'skipped' && payload.error) {
|
||||
setRunToast(`Run skipped: ${payload.error}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Hold the "Starting…" label briefly so the user sees the
|
||||
// state change even on fast runs. Without this the button
|
||||
@@ -382,6 +452,8 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
label="Edit"
|
||||
icon={<EditIcon sx={{ fontSize: 16 }} />}
|
||||
active={card.view === 'edit'}
|
||||
dot={editDirty}
|
||||
dotTooltip="You have unsaved changes in this tab."
|
||||
onClick={() => dispatch(updateWorkflowCard({ workflowId, patch: { view: 'edit', editFacet: card.editFacet || 'General' } }))}
|
||||
/>
|
||||
<TabBtn
|
||||
@@ -407,7 +479,7 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
Crossfades between Run/Edit/History tabs so the swap doesn't
|
||||
read as a "jump". Outer box is the scrollable viewport; the
|
||||
animated child changes per `card.view`. */}
|
||||
<Box sx={{ flex: 1, p: 2, overflowY: 'auto', minHeight: 0, position: 'relative' }}>
|
||||
<Box ref={bodyScrollRef} data-no-drag sx={{ flex: 1, p: 2, overflowY: 'auto', minHeight: 0, position: 'relative', overscrollBehavior: 'contain' }}>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={card.view}
|
||||
@@ -435,12 +507,20 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{card.view === 'saved' && workflow && <SavedView workflow={workflow} steps={steps} />}
|
||||
{card.view === 'saved' && workflow && (
|
||||
<SavedView
|
||||
workflow={workflow}
|
||||
steps={steps}
|
||||
runs={runs}
|
||||
activeRunId={(runs || []).find((r) => r.status === 'running')?.id || null}
|
||||
/>
|
||||
)}
|
||||
{card.view === 'edit' && workflow && (
|
||||
<WorkflowEditViews
|
||||
workflow={workflow}
|
||||
facet={card.editFacet || 'General'}
|
||||
onChangeFacet={(f) => dispatch(updateWorkflowCard({ workflowId, patch: { editFacet: f } }))}
|
||||
onDirtyChange={setEditDirty}
|
||||
/>
|
||||
)}
|
||||
{card.view === 'history' && workflow && (
|
||||
@@ -476,13 +556,38 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{/* Toast for run outcomes that need explaining beyond the History
|
||||
row (cost cap, "previous run still active," etc.). Auto-hides
|
||||
after 6s; user can click anywhere to dismiss. */}
|
||||
<Snackbar
|
||||
open={Boolean(runToast)}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => setRunToast(null)}
|
||||
message={runToast || ''}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
/>
|
||||
{/* Ghost-protection dialog: only opens when an enabled-schedule
|
||||
card is X'd out. Cancel keeps the card; "Hide card" closes
|
||||
but leaves the schedule alive; "Stop & delete" wipes the
|
||||
workflow entirely. */}
|
||||
<Dialog open={closeConfirmOpen} onClose={() => setCloseConfirmOpen(false)}>
|
||||
<DialogTitle>Close this workflow card?</DialogTitle>
|
||||
<DialogContent>
|
||||
The schedule will keep firing in the background even after you close this card. Choose what you want to happen.
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setCloseConfirmOpen(false)}>Cancel</Button>
|
||||
<Button onClick={onConfirmHide}>Hide card (schedule keeps running)</Button>
|
||||
<Button color="error" onClick={onConfirmStopAndDelete}>Stop & delete</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
function TabBtn({ label, icon, active, accent, onClick }: { label: string; icon: React.ReactNode; active: boolean; accent?: boolean; onClick: () => void }) {
|
||||
function TabBtn({ label, icon, active, accent, breathe, breatheTooltip, dot, dotTooltip, onClick }: { label: string; icon: React.ReactNode; active: boolean; accent?: boolean; breathe?: boolean; breatheTooltip?: string; dot?: boolean; dotTooltip?: string; onClick: () => void }) {
|
||||
const c = useClaudeTokens();
|
||||
return (
|
||||
const btn = (
|
||||
<Box
|
||||
onClick={onClick}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
@@ -498,11 +603,36 @@ function TabBtn({ label, icon, active, accent, onClick }: { label: string; icon:
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
cursor: 'pointer', userSelect: 'none',
|
||||
'&:hover': { bgcolor: c.accent.primary + '10' },
|
||||
// Subtle "ready" breath when a stale workflow's Run button hasn't
|
||||
// been touched in over 24h. ~3% scale + glow swell, slow enough
|
||||
// to read as ambient rather than urgent. Tooltip is on so users
|
||||
// don't think the button is malfunctioning.
|
||||
...(breathe && {
|
||||
animation: 'workflow-run-breath 3.2s ease-in-out infinite',
|
||||
'@keyframes workflow-run-breath': {
|
||||
'0%, 100%': { boxShadow: `0 0 0 ${c.accent.primary}00`, transform: 'scale(1)' },
|
||||
'50%': { boxShadow: `0 0 14px ${c.accent.primary}55`, transform: 'scale(1.03)' },
|
||||
},
|
||||
}),
|
||||
}}>
|
||||
{icon}
|
||||
{label}
|
||||
{dot && (
|
||||
<Box sx={{
|
||||
width: 7, height: 7, borderRadius: '50%',
|
||||
bgcolor: c.accent.primary,
|
||||
ml: 0.25,
|
||||
}} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
if (dot && dotTooltip) {
|
||||
return <Tooltip title={dotTooltip}>{btn}</Tooltip>;
|
||||
}
|
||||
if (breathe && breatheTooltip) {
|
||||
return <Tooltip title={breatheTooltip}>{btn}</Tooltip>;
|
||||
}
|
||||
return btn;
|
||||
}
|
||||
|
||||
export default React.memo(WorkflowCard);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import HistoryIcon from '@mui/icons-material/HistoryToggleOffRounded';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import {
|
||||
closeWorkflowCard,
|
||||
createWorkflow,
|
||||
@@ -10,7 +13,8 @@ import {
|
||||
type WorkflowRun,
|
||||
} from '@/shared/state/workflowsSlice';
|
||||
import { removeWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { describePermissions, describeSchedule } from './scheduleUtils';
|
||||
import { ScheduleChip, PermissionChip, CostChip, humanDuration, routingFor } from './workflowVisuals';
|
||||
import StepList from './StepList';
|
||||
|
||||
export function statusColor(s: string, c: ReturnType<typeof useClaudeTokens>): string {
|
||||
if (s === 'success') return c.status.success;
|
||||
@@ -30,7 +34,7 @@ export function statusBg(s: string, c: ReturnType<typeof useClaudeTokens>): stri
|
||||
export function labelForStatus(s: string): string {
|
||||
if (s === 'success') return 'Success';
|
||||
if (s === 'failure') return 'Failure';
|
||||
if (s === 'ran_late') return 'Ran Late';
|
||||
if (s === 'ran_late') return 'Ran late';
|
||||
if (s === 'running') return 'Running';
|
||||
if (s === 'skipped') return 'Skipped';
|
||||
return s;
|
||||
@@ -104,58 +108,241 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft,
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
|
||||
<Box sx={{ flex: 1, fontSize: '0.88rem', color: c.text.secondary, lineHeight: 1.5 }}>{description}</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 0.5 }}>
|
||||
{steps.map((s, idx) => (
|
||||
<Box key={s.id} sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.25 }}>
|
||||
<Box sx={{ width: 24, height: 24, borderRadius: '50%', border: `1px solid ${c.border.medium}`, fontSize: '0.78rem', fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', color: c.text.secondary, flexShrink: 0, mt: 0.25 }}>{idx + 1}</Box>
|
||||
<Box sx={{ flex: 1, fontSize: '0.92rem', color: c.text.primary, border: `1px solid ${idx === 0 ? c.border.medium : c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 1.25, py: 0.75, bgcolor: c.bg.surface, lineHeight: 1.4 }}>{s.text}</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 0.75, mt: 1 }}>
|
||||
<ActionBtn label="Discard" tone="muted" onClick={onDiscard} />
|
||||
<StepList steps={steps} framed />
|
||||
{/* Save sits on the right; "Throw away" sits on the LEFT separated
|
||||
by a flex spacer so a panicked user can't fat-finger the
|
||||
destructive option while reaching for Save. */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mt: 1 }}>
|
||||
<ActionBtn label="Throw away" tone="muted" onClick={onDiscard} />
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<ActionBtn label="Save" tone="success" onClick={onSave} disabled={busy} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function SavedView({ workflow, steps }: { workflow: Workflow; steps: Workflow['steps'] }) {
|
||||
export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Workflow; steps: Workflow['steps']; runs?: WorkflowRun[]; activeRunId?: string | null }) {
|
||||
const c = useClaudeTokens();
|
||||
const connectionMode = useAppSelector((s) => (s as { settings?: { data?: { connection_mode?: string } } }).settings?.data?.connection_mode);
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Typography sx={{ fontSize: '0.88rem', color: c.text.secondary }}><strong style={{ color: c.text.primary }}>Scheduled:</strong> {describeSchedule(workflow.schedule)}</Typography>
|
||||
<Typography sx={{ fontSize: '0.88rem', color: c.text.secondary }}><strong style={{ color: c.text.primary }}>Permissions:</strong> {describePermissions(workflow)}</Typography>
|
||||
<Typography sx={{ fontSize: '0.88rem', color: c.text.secondary, lineHeight: 1.5, mt: 0.5 }}>{workflow.description}</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 0.5 }}>
|
||||
{steps.map((s, idx) => (
|
||||
<Box key={s.id} sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.25 }}>
|
||||
<Box sx={{ width: 24, height: 24, borderRadius: '50%', border: `1px solid ${c.border.medium}`, fontSize: '0.78rem', fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', color: c.text.secondary, flexShrink: 0, mt: 0.25 }}>{idx + 1}</Box>
|
||||
<Box sx={{ flex: 1, fontSize: '0.92rem', color: c.text.primary, px: 0.5, lineHeight: 1.45 }}>{s.text}</Box>
|
||||
</Box>
|
||||
))}
|
||||
{/* Pill chips replace the two text rows. Same info, glanceable. */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 0.5 }}>
|
||||
<ScheduleChip workflow={workflow} />
|
||||
<PermissionChip workflow={workflow} />
|
||||
<CostChip workflow={workflow} connectionMode={connectionMode} />
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<AuditTraceLink workflowId={workflow.id} />
|
||||
</Box>
|
||||
<Typography sx={{ fontSize: '0.88rem', color: c.text.secondary, lineHeight: 1.5, mt: 0.5 }}>{workflow.description}</Typography>
|
||||
<StepList workflow={workflow} steps={steps} runs={runs} activeRunId={activeRunId} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Audit-trace popover. Lazy-fetches the last N edits from /workflows/{id}/audit
|
||||
// on open, renders a compact list. The trigger sits inline with the chip
|
||||
// row so power users can spot it without cluttering the title.
|
||||
function AuditTraceLink({ workflowId }: { workflowId: string }) {
|
||||
const c = useClaudeTokens();
|
||||
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
||||
const [entries, setEntries] = useState<Array<{ ts: string; who: string; diff: Record<string, { before: unknown; after: unknown }> }> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const open = useCallback(async (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
setAnchor(e.currentTarget);
|
||||
if (entries !== null) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const { API_BASE, getAuthToken } = await import('@/shared/config');
|
||||
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
|
||||
const res = await fetch(`${API_BASE}/workflows/${encodeURIComponent(workflowId)}/audit?limit=5`, {
|
||||
headers: tok ? { Authorization: `Bearer ${tok}` } : {},
|
||||
});
|
||||
const data = await res.json();
|
||||
setEntries(Array.isArray(data?.entries) ? data.entries : []);
|
||||
} catch {
|
||||
setEntries([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [entries, workflowId]);
|
||||
const close = () => setAnchor(null);
|
||||
const count = entries?.length ?? 0;
|
||||
return (
|
||||
<>
|
||||
<Tooltip title="Recent edits to this workflow">
|
||||
<Box onClick={open} role="button" sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.3,
|
||||
fontSize: '0.7rem', color: c.text.muted, cursor: 'pointer',
|
||||
px: 0.5, py: 0.25, borderRadius: 0.75,
|
||||
'&:hover': { color: c.accent.primary, bgcolor: c.bg.elevated },
|
||||
}}>
|
||||
<HistoryIcon sx={{ fontSize: 12 }} />
|
||||
{entries === null ? 'edits' : `${count} edit${count === 1 ? '' : 's'}`}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Popover
|
||||
open={Boolean(anchor)}
|
||||
anchorEl={anchor}
|
||||
onClose={close}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}>
|
||||
<Box sx={{ minWidth: 280, maxWidth: 360, p: 1 }}>
|
||||
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em', mb: 0.5 }}>
|
||||
RECENT EDITS
|
||||
</Typography>
|
||||
{loading && <Typography sx={{ fontSize: '0.78rem', color: c.text.muted }}>Loading…</Typography>}
|
||||
{!loading && (entries === null || entries.length === 0) && (
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted }}>No edits yet.</Typography>
|
||||
)}
|
||||
{!loading && entries && entries.map((e, idx) => {
|
||||
const fields = Object.keys(e.diff || {}).filter((k) => k !== 'updated_at');
|
||||
const summary = fields.length === 0 ? 'no field changes' : fields.slice(0, 3).join(', ') + (fields.length > 3 ? `, +${fields.length - 3} more` : '');
|
||||
return (
|
||||
<Box key={idx} sx={{ display: 'flex', flexDirection: 'column', py: 0.5, borderTop: idx === 0 ? 'none' : `1px solid ${c.border.subtle}` }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.primary, fontWeight: 600 }}>{e.who || 'user'}</Typography>
|
||||
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost }}>{relTimeShort(e.ts)}</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ fontSize: '0.74rem', color: c.text.secondary }}>{summary}</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function relTimeShort(iso: string): string {
|
||||
try {
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
if (ms < 60000) return 'just now';
|
||||
const m = Math.floor(ms / 60000);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
return `${d}d ago`;
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
function runDuration(r: WorkflowRun): string | null {
|
||||
if (!r.finished_at) return null;
|
||||
try {
|
||||
const ms = new Date(r.finished_at).getTime() - new Date(r.started_at).getTime();
|
||||
if (ms <= 0) return null;
|
||||
return humanDuration(ms);
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// Groups runs into "This week / Last week / Month YYYY" buckets so a
|
||||
// long history list reads as eras rather than 50 same-looking dates.
|
||||
function groupKey(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const day = 24 * 3600 * 1000;
|
||||
const startOfWeek = (x: Date) => { const y = new Date(x); y.setHours(0, 0, 0, 0); y.setDate(y.getDate() - y.getDay()); return y; };
|
||||
const thisWeekStart = startOfWeek(now).getTime();
|
||||
const lastWeekStart = thisWeekStart - 7 * day;
|
||||
if (d.getTime() >= thisWeekStart) return 'This week';
|
||||
if (d.getTime() >= lastWeekStart) return 'Last week';
|
||||
return d.toLocaleString('en', { month: 'long', year: 'numeric' });
|
||||
} catch { return 'Earlier'; }
|
||||
}
|
||||
|
||||
export function HistoryList({ runs, onOpen }: { runs: WorkflowRun[]; onOpen: (r: WorkflowRun) => void }) {
|
||||
const c = useClaudeTokens();
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
// Filter chips: all / failures / late. Power-users debugging a flaky
|
||||
// workflow shouldn't have to scroll past successes.
|
||||
const [filter, setFilter] = useState<'all' | 'failure' | 'ran_late'>('all');
|
||||
const filtered = useMemo(() => {
|
||||
if (filter === 'all') return runs;
|
||||
return (runs || []).filter((r) => r.status === filter);
|
||||
}, [runs, filter]);
|
||||
const groups = useMemo(() => {
|
||||
const out: Array<{ key: string; runs: WorkflowRun[] }> = [];
|
||||
for (const r of filtered || []) {
|
||||
const k = groupKey(r.started_at);
|
||||
const last = out[out.length - 1];
|
||||
if (last && last.key === k) last.runs.push(r);
|
||||
else out.push({ key: k, runs: [r] });
|
||||
}
|
||||
return out;
|
||||
}, [filtered]);
|
||||
// Header sparkline summarising recent successes/failures so users can
|
||||
// see "lately broken" before scrolling.
|
||||
const recent = (runs || []).slice(0, 30);
|
||||
if (!runs || runs.length === 0) {
|
||||
return <Typography sx={{ fontSize: '0.88rem', color: c.text.muted, py: 1.5, textAlign: 'center' }}>No runs yet</Typography>;
|
||||
}
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{runs.map((r) => (
|
||||
<Box
|
||||
key={r.id}
|
||||
onClick={() => onOpen(r)}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1.25, py: 0.75, px: 0.5, cursor: 'pointer', borderRadius: 0.75, '&:hover': { bgcolor: c.bg.elevated } }}>
|
||||
<Box sx={{ fontSize: '0.72rem', fontWeight: 700, color: statusColor(r.status, c), bgcolor: statusBg(r.status, c), px: 0.8, py: 0.3, borderRadius: 0.75, minWidth: 64, textAlign: 'center' }}>
|
||||
{labelForStatus(r.status)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.75 }}>
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.25 }}>
|
||||
{recent.map((r) => (
|
||||
<Box key={r.id} sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: statusColor(r.status, c) }} />
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
{(['all', 'failure', 'ran_late'] as const).map((k) => (
|
||||
<Box key={k} onClick={() => setFilter(k)} role="button" sx={{
|
||||
fontSize: '0.72rem', fontWeight: 600,
|
||||
color: filter === k ? c.accent.primary : c.text.muted,
|
||||
bgcolor: filter === k ? c.accent.primary + '14' : 'transparent',
|
||||
border: `1px solid ${filter === k ? c.accent.primary + '40' : c.border.subtle}`,
|
||||
px: 0.7, py: 0.2, borderRadius: 999, cursor: 'pointer',
|
||||
'&:hover': { color: c.accent.primary },
|
||||
}}>
|
||||
{k === 'all' ? 'All' : k === 'failure' ? 'Failures only' : 'Ran late only'}
|
||||
</Box>
|
||||
<Typography sx={{ fontSize: '0.88rem', color: c.text.primary }}>{formatRunDate(r.started_at)}</Typography>
|
||||
<Box sx={{ ml: 'auto', fontSize: '0.78rem', color: c.text.muted }}>Open →</Box>
|
||||
))}
|
||||
</Box>
|
||||
{groups.map(({ key, runs: gRuns }) => (
|
||||
<Box key={key} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em', mt: 0.5, mb: 0.25 }}>
|
||||
{key.toUpperCase()}
|
||||
</Typography>
|
||||
{gRuns.map((r) => {
|
||||
const expanded = expandedId === r.id;
|
||||
const dur = runDuration(r);
|
||||
return (
|
||||
<Box key={r.id}>
|
||||
<Box
|
||||
onClick={() => setExpandedId(expanded ? null : r.id)}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1.25, py: 0.6, px: 0.5, cursor: 'pointer', borderRadius: 0.75, '&:hover': { bgcolor: c.bg.elevated } }}>
|
||||
<Box sx={{ fontSize: '0.72rem', fontWeight: 700, color: statusColor(r.status, c), bgcolor: statusBg(r.status, c), px: 0.8, py: 0.3, borderRadius: 0.75, minWidth: 64, textAlign: 'center' }}>
|
||||
{labelForStatus(r.status)}
|
||||
</Box>
|
||||
<Typography sx={{ fontSize: '0.88rem', color: c.text.primary, flex: 1 }}>{formatRunDate(r.started_at)}</Typography>
|
||||
{dur && <Typography sx={{ fontSize: '0.74rem', color: c.text.ghost }}>{dur}</Typography>}
|
||||
{r.cost_usd > 0 && <Typography sx={{ fontSize: '0.74rem', color: c.text.ghost }}>${r.cost_usd.toFixed(4)}</Typography>}
|
||||
{/* Chevron makes the row read as expandable instead of
|
||||
static text. Rotates 180° while open so the affordance
|
||||
stays visible after click. */}
|
||||
<Box sx={{ fontSize: '0.7rem', color: c.text.ghost, transform: expanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s ease' }}>▾</Box>
|
||||
</Box>
|
||||
{expanded && (
|
||||
<Box sx={{ ml: 8, mt: 0.25, mb: 0.75, p: 1, bgcolor: c.bg.elevated, borderRadius: 0.75, border: `1px solid ${c.border.subtle}` }}>
|
||||
{r.error ? (
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.status.error, lineHeight: 1.4 }}>{r.error}</Typography>
|
||||
) : (
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.secondary, lineHeight: 1.4 }}>
|
||||
{r.session_id ? `Saved as session ${r.session_id.slice(0, 8)}.` : 'No session was recorded for this run.'} Click below to see the full conversation.
|
||||
</Typography>
|
||||
)}
|
||||
<Box sx={{ mt: 0.5, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Box onClick={(e) => { e.stopPropagation(); onOpen(r); }} role="button" sx={{ fontSize: '0.74rem', fontWeight: 600, color: c.accent.primary, cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}>
|
||||
See full conversation →
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Select from '@mui/material/Select';
|
||||
@@ -16,9 +16,12 @@ interface Props {
|
||||
workflow: Workflow;
|
||||
facet: 'General' | 'Actions' | 'Schedule';
|
||||
onChangeFacet: (facet: 'General' | 'Actions' | 'Schedule') => void;
|
||||
// Lifted dirty state so the parent card can decorate the Edit tab with
|
||||
// an unsaved-changes dot. Optional; older callers don't need to wire it.
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
export default function WorkflowEditViews({ workflow, facet, onChangeFacet }: Props) {
|
||||
export default function WorkflowEditViews({ workflow, facet, onChangeFacet, onDirtyChange }: Props) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const [draft, setDraft] = useState<Workflow>(workflow);
|
||||
@@ -30,6 +33,12 @@ export default function WorkflowEditViews({ workflow, facet, onChangeFacet }: Pr
|
||||
|
||||
const dirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(workflow), [draft, workflow]);
|
||||
|
||||
// Push the dirty flag up so the parent card can decorate the Edit tab.
|
||||
useEffect(() => { onDirtyChange?.(dirty); }, [dirty, onDirtyChange]);
|
||||
// Clear the parent's flag on unmount so a closed editor doesn't leave
|
||||
// a stale "you have unsaved changes" dot on the tab.
|
||||
useEffect(() => () => { onDirtyChange?.(false); }, [onDirtyChange]);
|
||||
|
||||
const onSave = useCallback(async () => {
|
||||
if (busy || !dirty) return;
|
||||
const reason = validateDraft(draft);
|
||||
@@ -40,19 +49,28 @@ export default function WorkflowEditViews({ workflow, facet, onChangeFacet }: Pr
|
||||
setSaveError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await dispatch(updateWorkflow({ id: workflow.id, patch: draft }));
|
||||
// If-Match: pass the workflow's current updated_at so the backend
|
||||
// can reject a stale write. Without this, two open windows or a
|
||||
// mid-edit background fire silently clobber each other.
|
||||
const result = await dispatch(updateWorkflow({
|
||||
id: workflow.id,
|
||||
patch: draft,
|
||||
ifMatch: workflow.updated_at || null,
|
||||
}));
|
||||
if (updateWorkflow.fulfilled.match(result)) {
|
||||
setSavedFlash(true);
|
||||
setTimeout(() => setSavedFlash(false), 1400);
|
||||
} else if (result.payload?.kind === 'stale') {
|
||||
setSaveError('This workflow was changed in another window or by a recent run. Discard to reload the latest, then re-apply your edits.');
|
||||
} else {
|
||||
setSaveError('Save failed. Please try again.');
|
||||
setSaveError(result.payload?.message || 'Save failed. Please try again.');
|
||||
}
|
||||
} catch (e) {
|
||||
setSaveError((e as Error)?.message || 'Save failed.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, dirty, dispatch, workflow.id, draft]);
|
||||
}, [busy, dirty, dispatch, workflow.id, workflow.updated_at, draft]);
|
||||
|
||||
const onDiscard = useCallback(() => {
|
||||
setDraft(workflow);
|
||||
|
||||
@@ -288,7 +288,7 @@ const WorkflowsHubCard: React.FC<Props> = ({
|
||||
<AddIcon sx={{ fontSize: 14 }} />
|
||||
New
|
||||
</Box>
|
||||
<Tooltip title={paused ? 'All scheduled workflows are paused. Toggle to resume.' : 'Pause every scheduled workflow without disabling them individually.'}>
|
||||
<Tooltip title={paused ? 'Scheduled runs are paused. In-flight runs will finish; new fires are blocked until you resume.' : 'Stop all future scheduled runs without disabling them one-by-one. Any run already in flight will finish.'}>
|
||||
<Box
|
||||
onClick={togglePaused}
|
||||
role="button"
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
// Shared visual helpers for the workflow card UI tier: schedule/permission
|
||||
// pill chips, status dot, run-status sparkline, step connector, step icon
|
||||
// auto-classifier. Kept as plain functions/components so individual views
|
||||
// can compose without owning the styling.
|
||||
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import ScheduleIcon from '@mui/icons-material/ScheduleRounded';
|
||||
import NotificationsIcon from '@mui/icons-material/NotificationsRounded';
|
||||
import SmsIcon from '@mui/icons-material/SmsRounded';
|
||||
import PhoneInTalkIcon from '@mui/icons-material/PhoneInTalkRounded';
|
||||
import EmailIcon from '@mui/icons-material/MailOutlineRounded';
|
||||
import EventNoteIcon from '@mui/icons-material/EventNoteRounded';
|
||||
import ChromeReaderModeIcon from '@mui/icons-material/ChromeReaderModeRounded';
|
||||
import ChatBubbleOutlineIcon from '@mui/icons-material/ChatBubbleOutlineRounded';
|
||||
import CalendarTodayIcon from '@mui/icons-material/CalendarTodayRounded';
|
||||
import ArticleIcon from '@mui/icons-material/ArticleRounded';
|
||||
import LanguageIcon from '@mui/icons-material/LanguageRounded';
|
||||
import AttachMoneyIcon from '@mui/icons-material/AttachMoneyRounded';
|
||||
import AllInclusiveIcon from '@mui/icons-material/AllInclusiveRounded';
|
||||
import CodeIcon from '@mui/icons-material/CodeRounded';
|
||||
import SearchIcon from '@mui/icons-material/SearchRounded';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import type { Workflow, WorkflowRun, ScheduleConfig, PermissionTier } from '@/shared/state/workflowsSlice';
|
||||
import { formatTime, WEEKDAY_LABEL } from './scheduleUtils';
|
||||
|
||||
// ---------- Status colors ----------
|
||||
|
||||
export type LastRunStatus = NonNullable<Workflow['last_run_status']>;
|
||||
|
||||
export function statusDotColor(status: LastRunStatus | null | undefined, c: ReturnType<typeof useClaudeTokens>) {
|
||||
switch (status) {
|
||||
case 'success': return c.status.success;
|
||||
case 'ran_late': return c.status.warning || '#f59e0b';
|
||||
case 'failure': return c.status.error;
|
||||
case 'running': return c.accent.primary;
|
||||
case 'skipped': return c.text.muted;
|
||||
default: return c.text.ghost;
|
||||
}
|
||||
}
|
||||
|
||||
// Human-readable status word. We surface "ran late" instead of the
|
||||
// underscore-y "ran_late" everywhere it'd be visible to a user.
|
||||
export function statusWord(status: LastRunStatus | null | undefined): string {
|
||||
if (!status) return 'Never run';
|
||||
if (status === 'ran_late') return 'Ran late';
|
||||
return status.charAt(0).toUpperCase() + status.slice(1);
|
||||
}
|
||||
|
||||
// Status pill rendered next to the title. Bigger than the previous 9px
|
||||
// dot and pairs the color with a short word so a non-dev knows what
|
||||
// they're looking at instead of squinting at a single grey pixel.
|
||||
export function StatusDot({ status }: { status: LastRunStatus | null | undefined }) {
|
||||
const c = useClaudeTokens();
|
||||
const word = statusWord(status);
|
||||
const dotColor = statusDotColor(status, c);
|
||||
return (
|
||||
<Tooltip title={status ? `Last run: ${word.toLowerCase()}` : 'This workflow has never run.'}>
|
||||
<Box sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.4,
|
||||
height: 18, px: 0.6, borderRadius: 999,
|
||||
bgcolor: status === 'failure' ? c.status.errorBg : status === 'ran_late' ? c.status.warningBg : status === 'success' ? c.status.successBg : c.bg.elevated,
|
||||
border: `1px solid ${dotColor}55`,
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: dotColor, boxShadow: status === 'failure' ? `0 0 4px ${c.status.error}` : 'none' }} />
|
||||
<Typography sx={{ fontSize: '0.66rem', fontWeight: 700, color: dotColor, letterSpacing: '0.02em' }}>
|
||||
{word}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Pill chips ----------
|
||||
|
||||
function scheduleShort(sched: ScheduleConfig): string {
|
||||
if (!sched.enabled) return 'Not scheduled';
|
||||
const time = formatTime(sched.hour, sched.minute);
|
||||
if (sched.repeat_unit === 'day') {
|
||||
return sched.repeat_every === 1 ? `Daily ${time}` : `Every ${sched.repeat_every}d ${time}`;
|
||||
}
|
||||
if (sched.repeat_unit === 'month') {
|
||||
return sched.repeat_every === 1 ? `Monthly ${time}` : `Every ${sched.repeat_every}mo ${time}`;
|
||||
}
|
||||
if (sched.on_days.length === 5 && [1, 2, 3, 4, 5].every((d) => sched.on_days.includes(d))) return `Weekdays ${time}`;
|
||||
if (sched.on_days.length === 2 && [0, 6].every((d) => sched.on_days.includes(d))) return `Weekends ${time}`;
|
||||
if (sched.on_days.length === 1) {
|
||||
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
return `${labels[sched.on_days[0]]} ${time}`;
|
||||
}
|
||||
if (sched.on_days.length === 0) return `Weekly ${time}`;
|
||||
return `${sched.on_days.length}×/wk ${time}`;
|
||||
}
|
||||
|
||||
// Weekday-dot strip "S M T W T F S" with active days filled. Rendered
|
||||
// inline next to the chip when the schedule is weekly so users can
|
||||
// pattern-match days without parsing prose. Active = filled accent dot.
|
||||
export function WeekdayDots({ on_days }: { on_days: number[] }) {
|
||||
const c = useClaudeTokens();
|
||||
return (
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.35, ml: 0.5 }}>
|
||||
{WEEKDAY_LABEL.map((lbl, idx) => {
|
||||
const active = on_days.includes(idx);
|
||||
return (
|
||||
<Box key={`${lbl}-${idx}`} sx={{
|
||||
width: 12, height: 12, borderRadius: '50%',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: '0.6rem', fontWeight: 700,
|
||||
color: active ? '#fff' : c.text.ghost,
|
||||
bgcolor: active ? c.accent.primary : 'transparent',
|
||||
border: `1px solid ${active ? c.accent.primary : c.border.subtle}`,
|
||||
lineHeight: 1,
|
||||
}}>
|
||||
{lbl}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function permIcon(kind: PermissionTier['kind'], size = 13) {
|
||||
if (kind === 'text') return <SmsIcon sx={{ fontSize: size }} />;
|
||||
if (kind === 'call') return <PhoneInTalkIcon sx={{ fontSize: size }} />;
|
||||
return <NotificationsIcon sx={{ fontSize: size }} />;
|
||||
}
|
||||
|
||||
// Compact "🔔 → 💬 → 📞" representation of the escalation chain. Hover
|
||||
// shows the literal prose (notify, text, call, with delays).
|
||||
export function PermissionChip({ workflow }: { workflow: Workflow }) {
|
||||
const c = useClaudeTokens();
|
||||
const tiers = workflow.permissions || [];
|
||||
if (tiers.length === 0) return null;
|
||||
const label = tiers.map((t) => {
|
||||
if (t.kind === 'notify') return 'notify in app';
|
||||
const unit = t.kind === 'call' ? 'h' : 'm';
|
||||
return `${t.kind} after ${t.after_minutes}${unit}`;
|
||||
}).join(' → ');
|
||||
return (
|
||||
<Tooltip title={label}>
|
||||
<Box sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.35,
|
||||
fontSize: '0.74rem', fontWeight: 500,
|
||||
color: c.text.secondary,
|
||||
bgcolor: c.bg.elevated,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
px: 0.85, py: 0.3, borderRadius: 999,
|
||||
}}>
|
||||
{tiers.map((t, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{permIcon(t.kind)}
|
||||
{i < tiers.length - 1 && <Box sx={{ fontSize: '0.7rem', color: c.text.ghost, mx: 0.1 }}>→</Box>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScheduleChip({ workflow }: { workflow: Workflow }) {
|
||||
const c = useClaudeTokens();
|
||||
const enabled = workflow.schedule.enabled;
|
||||
return (
|
||||
<Tooltip title={enabled ? `Schedule: ${scheduleShort(workflow.schedule)} (${workflow.schedule.timezone === 'local' ? 'system tz' : workflow.schedule.timezone})` : 'Not scheduled'}>
|
||||
<Box sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.4,
|
||||
fontSize: '0.74rem', fontWeight: 600,
|
||||
color: enabled ? c.accent.primary : c.text.muted,
|
||||
bgcolor: enabled ? c.accent.primary + '14' : c.bg.elevated,
|
||||
border: `1px solid ${enabled ? c.accent.primary + '40' : c.border.subtle}`,
|
||||
px: 0.85, py: 0.3, borderRadius: 999,
|
||||
}}>
|
||||
<ScheduleIcon sx={{ fontSize: 13 }} />
|
||||
{scheduleShort(workflow.schedule)}
|
||||
{enabled && workflow.schedule.repeat_unit === 'week' && (
|
||||
<WeekdayDots on_days={workflow.schedule.on_days} />
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// Classify a workflow's billing route based on its model id + the user's
|
||||
// global connection mode. Mirrors the per-session logic in AgentChat so
|
||||
// the workflow card tells the same story the chat header does. Returns
|
||||
// 'metered' when the user pays per call (Anthropic/OpenAI/Gemini API
|
||||
// keys, custom OpenAI-compatible) or 'subscription' when a flat-rate
|
||||
// account is doing the work (Claude Pro/Max, ChatGPT Plus/Pro, Gemini
|
||||
// Advanced, OpenSwarm Pro proxy). `subLabel` names the plan for tooltips.
|
||||
export type RoutingKind = 'metered' | 'subscription';
|
||||
export interface Routing {
|
||||
kind: RoutingKind;
|
||||
subLabel?: string;
|
||||
}
|
||||
|
||||
export function routingFor(model: string, connectionMode: string | undefined): Routing {
|
||||
const m = (model || '').toLowerCase();
|
||||
if (m.endsWith('-api')) return { kind: 'metered' };
|
||||
if (m.endsWith('-cc')) return { kind: 'subscription', subLabel: 'Claude Pro/Max' };
|
||||
const isPlainAnthropic = m === 'sonnet' || m === 'opus' || m === 'haiku';
|
||||
if (isPlainAnthropic && connectionMode === 'openswarm-pro') {
|
||||
return { kind: 'subscription', subLabel: 'OpenSwarm Pro' };
|
||||
}
|
||||
if (isPlainAnthropic) return { kind: 'metered' };
|
||||
if (m.startsWith('gpt-5') || m.startsWith('gpt-4') || m.startsWith('o1') || m.startsWith('o3') || m.startsWith('o4')) {
|
||||
return { kind: 'subscription', subLabel: 'ChatGPT Plus/Pro' };
|
||||
}
|
||||
if (m.startsWith('gemini-')) {
|
||||
return { kind: 'subscription', subLabel: 'Gemini Advanced' };
|
||||
}
|
||||
// Unknown model id, default to metered so we don't oversell "free."
|
||||
return { kind: 'metered' };
|
||||
}
|
||||
|
||||
export function CostChip({ workflow, connectionMode }: { workflow: Workflow; connectionMode?: string }) {
|
||||
const c = useClaudeTokens();
|
||||
const est = workflow.cost_estimate;
|
||||
const route = routingFor(workflow.model, connectionMode);
|
||||
|
||||
// Subscription-routed workflows have no metered per-call cost. Surface
|
||||
// a usage chip instead so the user knows runs are "free" under their
|
||||
// existing plan but still sees the projected fire frequency.
|
||||
if (route.kind === 'subscription') {
|
||||
if (!est || est.fires_per_month === 0) {
|
||||
return (
|
||||
<Tooltip title={`Runs are covered by your ${route.subLabel} plan. No upcoming runs scheduled.`}>
|
||||
<Box sx={chipSx(c)}>
|
||||
<AllInclusiveIcon sx={{ fontSize: 12 }} />
|
||||
{route.subLabel || 'Subscription'}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip title={`Routed through your ${route.subLabel} plan; no per-run cost. About ${est.fires_per_month} runs per month at the current schedule.`}>
|
||||
<Box sx={chipSx(c)}>
|
||||
<AllInclusiveIcon sx={{ fontSize: 12 }} />
|
||||
~{est.fires_per_month} runs/mo
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// Metered route: only render the cost chip once we actually have a
|
||||
// last-run figure to project from. Avoids "$0.00/mo" gaslighting.
|
||||
if (!est || est.fires_per_month === 0 || est.last_run_usd <= 0) return null;
|
||||
const monthly = est.monthly_usd || 0;
|
||||
return (
|
||||
<Tooltip title={`About $${est.last_run_usd.toFixed(4)} per run, times ${est.fires_per_month} runs per month.`}>
|
||||
<Box sx={chipSx(c)}>
|
||||
<AttachMoneyIcon sx={{ fontSize: 12, ml: -0.25 }} />
|
||||
{monthly < 0.01 ? '<0.01' : monthly.toFixed(2)}/mo
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function chipSx(c: ReturnType<typeof useClaudeTokens>) {
|
||||
return {
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.3,
|
||||
fontSize: '0.74rem', fontWeight: 600,
|
||||
color: c.text.secondary,
|
||||
bgcolor: c.bg.elevated,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
px: 0.75, py: 0.3, borderRadius: 999,
|
||||
} as const;
|
||||
}
|
||||
|
||||
// Compact "last fired" mini-label, used inside the Run-tab summary.
|
||||
export function LastFiredHint({ workflow }: { workflow: Workflow }) {
|
||||
const c = useClaudeTokens();
|
||||
if (!workflow.last_run_at) return null;
|
||||
const ms = Date.now() - new Date(workflow.last_run_at).getTime();
|
||||
const ago = relTime(ms);
|
||||
return (
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.ghost }}>Last ran {ago}</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
function relTime(ms: number): string {
|
||||
if (ms < 0) return 'just now';
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 30) return `${d}d ago`;
|
||||
const mo = Math.floor(d / 30);
|
||||
return `${mo}mo ago`;
|
||||
}
|
||||
|
||||
// ---------- Run history sparkline ----------
|
||||
|
||||
// 10-dot horizontal strip of last N runs colored by status. Easy "lately
|
||||
// healthy?" check without opening the History tab. Tooltip names the
|
||||
// pattern out loud so a non-dev knows the dots aren't decorative.
|
||||
export function RunSparkline({ runs, max = 10 }: { runs: WorkflowRun[]; max?: number }) {
|
||||
const c = useClaudeTokens();
|
||||
if (!runs || runs.length === 0) return null;
|
||||
const slice = runs.slice(0, max).reverse();
|
||||
const successes = slice.filter((r) => r.status === 'success').length;
|
||||
const failures = slice.filter((r) => r.status === 'failure').length;
|
||||
const tooltip = `Last ${slice.length} run${slice.length === 1 ? '' : 's'}: ${successes} ok, ${failures} failed (oldest left → newest right). Green = success, red = failure, amber = ran late.`;
|
||||
return (
|
||||
<Tooltip title={tooltip}>
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.3, ml: 0.5 }}>
|
||||
{slice.map((r) => (
|
||||
<Box key={r.id} sx={{
|
||||
width: 6, height: 6, borderRadius: '50%',
|
||||
bgcolor: statusDotColor(r.status as LastRunStatus, c),
|
||||
}} />
|
||||
))}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Step icon auto-classifier ----------
|
||||
|
||||
// Pick a glyph by keyword scan of the step text. Falls back to the
|
||||
// step number when nothing matches. Same Roman-numeral simple heuristic
|
||||
// the user sees: "summarize email" -> mail icon, "make notion page" ->
|
||||
// article icon, etc.
|
||||
const ICON_RULES: Array<{ pattern: RegExp; Icon: React.ElementType }> = [
|
||||
{ pattern: /\b(email|inbox|gmail|outlook|mail)\b/i, Icon: EmailIcon },
|
||||
{ pattern: /\b(calendar|schedule|event|meeting)\b/i, Icon: CalendarTodayIcon },
|
||||
{ pattern: /\b(notion|doc|page|page template|document|article)\b/i, Icon: ArticleIcon },
|
||||
{ pattern: /\b(text|sms|message|whatsapp|imessage)\b/i, Icon: SmsIcon },
|
||||
{ pattern: /\b(call|phone|dial|ring)\b/i, Icon: PhoneInTalkIcon },
|
||||
{ pattern: /\b(browser|web|website|url|fetch|visit|navigate)\b/i, Icon: LanguageIcon },
|
||||
{ pattern: /\b(search|find|look up|google)\b/i, Icon: SearchIcon },
|
||||
{ pattern: /\b(code|github|repo|script|bash|run)\b/i, Icon: CodeIcon },
|
||||
{ pattern: /\b(read|review|summarize|summary)\b/i, Icon: ChromeReaderModeIcon },
|
||||
{ pattern: /\b(chat|reply|respond|dm)\b/i, Icon: ChatBubbleOutlineIcon },
|
||||
{ pattern: /\b(note|memo|journal|log)\b/i, Icon: EventNoteIcon },
|
||||
];
|
||||
|
||||
export function stepIconFor(text: string): React.ElementType | null {
|
||||
for (const rule of ICON_RULES) {
|
||||
if (rule.pattern.test(text)) return rule.Icon;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- Step duration learner ----------
|
||||
|
||||
// Estimates per-step duration by averaging recent runs. Today we only
|
||||
// have whole-run duration on each WorkflowRun (started_at -> finished_at),
|
||||
// so the heuristic spreads it evenly across the step count. When per-step
|
||||
// telemetry lands later, swap this for a per-step lookup.
|
||||
export function estimateStepDuration(workflow: Workflow, runs: WorkflowRun[] | undefined, stepIdx: number): string | null {
|
||||
if (!runs || runs.length === 0) return null;
|
||||
const steps = workflow.steps?.length || 1;
|
||||
const successful = runs.filter((r) => (r.status === 'success' || r.status === 'ran_late') && r.finished_at);
|
||||
if (successful.length === 0) return null;
|
||||
const durations = successful.slice(0, 10).map((r) => {
|
||||
const start = new Date(r.started_at).getTime();
|
||||
const end = new Date(r.finished_at!).getTime();
|
||||
return Math.max(0, end - start);
|
||||
});
|
||||
const avg = durations.reduce((a, b) => a + b, 0) / durations.length;
|
||||
const perStepMs = avg / steps;
|
||||
void stepIdx;
|
||||
return humanDuration(perStepMs);
|
||||
}
|
||||
|
||||
export function humanDuration(ms: number): string {
|
||||
if (ms < 1000) return '<1s';
|
||||
const s = Math.round(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
const rem = s % 60;
|
||||
return rem > 0 && m < 5 ? `${m}m ${rem}s` : `${m}m`;
|
||||
}
|
||||
|
||||
// ---------- Run-button breath logic ----------
|
||||
|
||||
// Returns true when the workflow hasn't been run in over 24h. Used by
|
||||
// the Run tab to add a subtle CSS breathing animation so the button
|
||||
// invites use without yelling.
|
||||
export function isStaleSinceLastRun(workflow: Workflow): boolean {
|
||||
if (!workflow.last_run_at) return false;
|
||||
const age = Date.now() - new Date(workflow.last_run_at).getTime();
|
||||
return age > 24 * 3600 * 1000;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
.under_construction_overlay {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
// z-index: 1000;
|
||||
text-align: center;
|
||||
font-family: Arial, sans-serif;
|
||||
color: white;
|
||||
// box-sizing: border-box;
|
||||
}
|
||||
|
||||
.under_construction_overlay img {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.under_construction_overlay h2 {
|
||||
font-size: 24px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.under_construction_overlay p {
|
||||
font-size: 18px;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
|
||||
import React from 'react';
|
||||
import styles from './UnderConstruction.module.scss'; // CSS for styling the overlay
|
||||
|
||||
const UnderConstruction = () => {
|
||||
return (
|
||||
<div className={styles.under_construction_overlay}>
|
||||
<img src="/hammer-icon.png" alt="Console Icon" />
|
||||
<h2>Under Construction</h2>
|
||||
<p>This feature is coming soon!</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { UnderConstruction };
|
||||
@@ -139,16 +139,42 @@ export const createWorkflow = createAsyncThunk(
|
||||
},
|
||||
);
|
||||
|
||||
export const updateWorkflow = createAsyncThunk(
|
||||
// Optimistic concurrency: PATCH sends If-Match with the workflow's
|
||||
// updated_at. If the backend's record changed since we read it (another
|
||||
// window, a mid-edit background fire), the server returns 409 and the
|
||||
// caller can prompt to reload. Thunk uses rejectWithValue so the FE can
|
||||
// distinguish stale-write from network errors.
|
||||
export const updateWorkflow = createAsyncThunk<
|
||||
Workflow,
|
||||
{ id: string; patch: Partial<Workflow>; ifMatch?: string | null },
|
||||
{ rejectValue: { kind: 'stale' | 'network' | 'server'; message: string; current_updated_at?: string } }
|
||||
>(
|
||||
'workflows/update',
|
||||
async ({ id, patch }: { id: string; patch: Partial<Workflow> }) => {
|
||||
const res = await fetch(`${API}/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
if (!res.ok) throw new Error(`update failed ${res.status}`);
|
||||
return (await res.json()) as Workflow;
|
||||
async ({ id, patch, ifMatch }, { rejectWithValue }) => {
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (ifMatch) headers['If-Match'] = ifMatch;
|
||||
const res = await fetch(`${API}/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
if (res.status === 409) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const detail = (data && (data.detail || data)) || {};
|
||||
return rejectWithValue({
|
||||
kind: 'stale',
|
||||
message: detail.message || 'This workflow changed elsewhere. Reload and try again.',
|
||||
current_updated_at: detail.current_updated_at,
|
||||
});
|
||||
}
|
||||
if (!res.ok) {
|
||||
return rejectWithValue({ kind: 'server', message: `Update failed (${res.status}).` });
|
||||
}
|
||||
return (await res.json()) as Workflow;
|
||||
} catch (e) {
|
||||
return rejectWithValue({ kind: 'network', message: (e as Error)?.message || 'Network error.' });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -161,7 +187,12 @@ export const runWorkflowNow = createAsyncThunk('workflows/run', async (id: strin
|
||||
const res = await fetch(`${API}/${id}/run`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`run failed ${res.status}`);
|
||||
const data = await res.json();
|
||||
return { id, run_id: data.run_id as string };
|
||||
return {
|
||||
id,
|
||||
run_id: (data.run_id || '') as string,
|
||||
status: (data.status || null) as string | null,
|
||||
error: (data.error || null) as string | null,
|
||||
};
|
||||
});
|
||||
|
||||
export const fetchRuns = createAsyncThunk(
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
@use '@/shared/styles/utils.module.scss' as utils;
|
||||
|
||||
$text-map: (
|
||||
'light-1': #F0F0F0,
|
||||
'light-2': #D9D9D9,
|
||||
'light-3': #BDBDBD,
|
||||
'light-4': #999999,
|
||||
);
|
||||
@function text($mode: 'light-1') {
|
||||
@return utils.get-style(
|
||||
$function-map: $text-map,
|
||||
$mode: $mode
|
||||
);
|
||||
}
|
||||
|
||||
$background-color-map: (
|
||||
'dark-1': #232323,
|
||||
'dark-2': #151515,
|
||||
'dark-3': #0A0A0A,
|
||||
);
|
||||
@function background-color($mode: 'dark-1') {
|
||||
@return utils.get-style(
|
||||
$function-map: $background-color-map,
|
||||
$mode: $mode
|
||||
);
|
||||
}
|
||||
|
||||
@mixin gradient-1() {
|
||||
$mask-color: rgba(0, 0, 0, 0.623);
|
||||
$gradient-color: rgba(62, 139, 241, 0.20);
|
||||
$background-color: #151515;
|
||||
background:
|
||||
linear-gradient(0deg, $mask-color 0%, $mask-color 100%),
|
||||
radial-gradient(102.16% 48.94% at 49.58% 47.09%, rgba(6, 14, 25, 0.00) 0%, rgba(62, 139, 241, 0.20) 100%),
|
||||
#151515;
|
||||
}
|
||||
|
||||
$glass-map: (
|
||||
'default': (
|
||||
border-radius: 10px,
|
||||
border: 1px solid rgba(255, 255, 255, 0.075),
|
||||
background: rgba(38, 38, 38, 0.184),
|
||||
background-blend-mode: luminosity,
|
||||
backdrop-filter: blur(50px),
|
||||
),
|
||||
'light-05': (
|
||||
border-radius: 10px,
|
||||
border: 1px solid rgba(255, 255, 255, 0.095),
|
||||
background: rgba(160, 160, 160, 0.048),
|
||||
background-blend-mode: luminosity,
|
||||
backdrop-filter: blur(50px),
|
||||
),
|
||||
'light-075': (
|
||||
border-radius: 10px,
|
||||
border: 1px solid rgba(255, 255, 255, 0.095),
|
||||
background: rgba(160, 160, 160, 0.075),
|
||||
background-blend-mode: luminosity,
|
||||
backdrop-filter: blur(50px),
|
||||
),
|
||||
'light-1': (
|
||||
border-radius: 10px,
|
||||
border: 1px solid rgba(255, 255, 255, 0.095),
|
||||
background: rgba(160, 160, 160, 0.154),
|
||||
background-blend-mode: luminosity,
|
||||
backdrop-filter: blur(50px),
|
||||
),
|
||||
'light-2': (
|
||||
border-radius: 10px,
|
||||
border: 1px solid rgba(255, 255, 255, 0.178),
|
||||
background: rgba(160, 160, 160, 0.46),
|
||||
background-blend-mode: luminosity,
|
||||
backdrop-filter: blur(50px),
|
||||
),
|
||||
'light-3': (
|
||||
border-radius: 10px,
|
||||
border: 1px solid rgba(255, 255, 255, 0.178),
|
||||
background: rgba(0, 0, 0, 0.247),
|
||||
background-blend-mode: luminosity,
|
||||
backdrop-filter: blur(50px),
|
||||
),
|
||||
);
|
||||
@mixin glass($mode: 'default') {
|
||||
@include utils.apply-style-map(
|
||||
$mixin-map: $glass-map,
|
||||
$mode: $mode
|
||||
);
|
||||
}
|
||||
|
||||
$glow-map: (
|
||||
'default': (
|
||||
border: 1px solid #0099ff71,
|
||||
box-shadow: 0 0 24px #0099ff71,
|
||||
),
|
||||
'dark-1': (
|
||||
border: 1px solid #3e8cf13e,
|
||||
box-shadow: 0 0 24px #3e8cf12b,
|
||||
),
|
||||
'light-1': (
|
||||
border: 1px solid #ab19ff47,
|
||||
box-shadow: 0 0 44px #c259ff8a,
|
||||
),
|
||||
'source-1': (
|
||||
border: 1px solid #ff000071,
|
||||
box-shadow: 0 0 44px #ff000071,
|
||||
),
|
||||
);
|
||||
@mixin glow($mode: 'default') {
|
||||
@include utils.apply-style-map(
|
||||
$mixin-map: $glow-map,
|
||||
$mode: $mode
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
$accent-map: (
|
||||
'blue-1': #3E8BF1,
|
||||
'blue-2': #3e8cf1c7,
|
||||
'blue-3': #3e8cf15b,
|
||||
'blue-grey-1': #77a7e5c7,
|
||||
'red-1': #F56868,
|
||||
'red-2': #f568681a,
|
||||
);
|
||||
@function accent($mode: 'blue-1') {
|
||||
@return utils.get-style(
|
||||
$function-map: $accent-map,
|
||||
$mode: $mode
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
export const getStyleValue = (className: string, property: string, defaultValue: string = "none"): string => {
|
||||
if (typeof document !== 'undefined') {
|
||||
const element = document.createElement("div");
|
||||
element.setAttribute("class", className);
|
||||
document.body.appendChild(element);
|
||||
const style = window.getComputedStyle(element);
|
||||
const value = style.getPropertyValue(property);
|
||||
document.body.removeChild(element);
|
||||
return value || defaultValue;
|
||||
}
|
||||
return defaultValue; // Return default value if not in a browser environment
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
@use '@/shared/styles/utils.module.scss' as utils;
|
||||
|
||||
$flex-map: (
|
||||
'vert': (
|
||||
flex-direction: column,
|
||||
),
|
||||
'horz': (
|
||||
flex-direction: row,
|
||||
),
|
||||
);
|
||||
@mixin flex($direction: 'vert') {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
@include utils.apply-style-map(
|
||||
$mixin-map: $flex-map,
|
||||
$mode: $direction
|
||||
);
|
||||
}
|
||||
|
||||
$flex-hug-map: (
|
||||
'default': (
|
||||
width: fit-content,
|
||||
height: fit-content,
|
||||
),
|
||||
'full-width': (
|
||||
width: 100%,
|
||||
),
|
||||
'full-height': (
|
||||
height: 100%,
|
||||
),
|
||||
);
|
||||
@mixin flex-hug($mode: 'default') {
|
||||
@include flex('horz');
|
||||
@include utils.apply-style-map(
|
||||
$mixin-map: $flex-hug-map,
|
||||
$mode: $mode
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
$scroll-map: (
|
||||
'hidden': (
|
||||
"&::-webkit-scrollbar": (
|
||||
display: none
|
||||
),
|
||||
-ms-overflow-style: none, /* IE and Edge */
|
||||
scrollbar-width: none, /* Firefox */
|
||||
),
|
||||
);
|
||||
@mixin scroll-bar($mode: 'hidden') {
|
||||
@include utils.apply-style-map(
|
||||
$mixin-map: $scroll-map,
|
||||
$mode: $mode
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
@use '@/shared/styles/color.module.scss' as g-color;
|
||||
@use '@/shared/styles/utils.module.scss' as utils;
|
||||
|
||||
|
||||
$font-map: (
|
||||
'default': 'Inter',
|
||||
'secondary': 'Times New Roman'
|
||||
);
|
||||
@function font($mode: 'default') {
|
||||
@return utils.get-style(
|
||||
$function-map: $font-map,
|
||||
$mode: $mode
|
||||
);
|
||||
}
|
||||
|
||||
$size-map: (
|
||||
'small': 12px,
|
||||
'small-medium': 14px,
|
||||
'medium': 16px,
|
||||
'large': 20px,
|
||||
'title': 30px,
|
||||
);
|
||||
@function size($mode: 'default') {
|
||||
@return utils.get-style(
|
||||
$function-map: $size-map,
|
||||
$mode: $mode
|
||||
);
|
||||
}
|
||||
|
||||
$weight-map: (
|
||||
'small': 400,
|
||||
'medium': 500,
|
||||
'large': 600,
|
||||
'title': 700,
|
||||
);
|
||||
@function weight($mode: 'default') {
|
||||
@return utils.get-style(
|
||||
$function-map: $weight-map,
|
||||
$mode: $mode
|
||||
);
|
||||
}
|
||||
|
||||
$text-map: (
|
||||
'default': (
|
||||
font-family: 'Inter',
|
||||
font-size: 16px,
|
||||
font-weight: 400,
|
||||
color: g-color.text('light-1'),
|
||||
),
|
||||
'title': (
|
||||
font-family: 'Inter',
|
||||
font-size: 40px,
|
||||
font-weight: 700,
|
||||
color: g-color.accent('blue-1'),
|
||||
line-height: 100%,
|
||||
)
|
||||
);
|
||||
@mixin text($mode: 'default') {
|
||||
@include utils.apply-style-map(
|
||||
$mixin-map: $text-map,
|
||||
$mode: $mode
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
@use "sass:map";
|
||||
@use "sass:meta";
|
||||
|
||||
// NOTE: Example map input:
|
||||
// $text-map: (
|
||||
// 'default': (
|
||||
// font-family: 'Inter',
|
||||
// font-size: 16px,
|
||||
// font-weight: 400,
|
||||
// ),
|
||||
// 'secondary': (∂
|
||||
// font-family: 'Times New Roman',
|
||||
// font-size: 16px,
|
||||
// font-weight: 400,
|
||||
// )
|
||||
// );
|
||||
@function construct-styles($mixin-map, $mode) {
|
||||
$styles: map.get($mixin-map, $mode);
|
||||
@if $styles == null {
|
||||
$available-modes: map.keys($styles);
|
||||
@error "Invalid style mode: '#{$mode}' -> Available modes are: #{$available-modes}.";
|
||||
}
|
||||
@return $styles;
|
||||
}
|
||||
@mixin apply-style-map($mixin-map, $mode) {
|
||||
$styles: construct-styles($mixin-map, $mode);
|
||||
@each $property, $value in $styles {
|
||||
@if meta.type-of($value) == map {
|
||||
// This is a nested selector
|
||||
#{$property} {
|
||||
@each $nested-property, $nested-value in $value {
|
||||
#{$nested-property}: $nested-value;
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
// This is a normal property-value pair
|
||||
#{$property}: $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// NOTE: Example map input:
|
||||
// $font-map: (
|
||||
// 'default': 'Inter',
|
||||
// 'secondary': 'Times New Roman',
|
||||
// )
|
||||
// );
|
||||
@function construct-style($function-map, $mode) {
|
||||
// Check if the mode exists in the map
|
||||
$style-value: map.get($function-map, $mode);
|
||||
@if $style-value == null {
|
||||
$available-modes: map.keys($function-map);
|
||||
@error "Invalid style mode: '#{$mode}' -> Available modes are: #{$available-modes}.";
|
||||
}
|
||||
|
||||
// Return the style value
|
||||
@return $style-value;
|
||||
}
|
||||
@function get-style($function-map, $mode) {
|
||||
$style: construct-style($function-map, $mode);
|
||||
@return $style;
|
||||
}
|
||||
@@ -29,7 +29,7 @@ fail_count = 0
|
||||
created_ids: list[str] = []
|
||||
|
||||
|
||||
def http(method: str, path: str, body=None, raw: bool = False):
|
||||
def http(method: str, path: str, body=None, raw: bool = False, extra_headers=None):
|
||||
url = f"{BASE}{path}"
|
||||
if body is None:
|
||||
data = None
|
||||
@@ -37,7 +37,10 @@ def http(method: str, path: str, body=None, raw: bool = False):
|
||||
data = body if isinstance(body, (bytes, bytearray)) else body.encode()
|
||||
else:
|
||||
data = json.dumps(body).encode()
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=HEADERS)
|
||||
headers = dict(HEADERS)
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return resp.status, json.loads(resp.read() or b"null")
|
||||
@@ -420,6 +423,43 @@ print(f" {DIM}(info) ends_at=3 days from now produced fires_per_month={fires}{R
|
||||
ok("fires_per_month honors ends_at (~3 fires not ~30)", fires <= 5,
|
||||
info=f"got {fires}, want <= 5; if this fails it's a known gap in scheduler.fires_in_window")
|
||||
|
||||
# ============ 20. If-Match optimistic concurrency ============
|
||||
section("20. PATCH with If-Match (optimistic concurrency)")
|
||||
code, r = http("POST", "/create", fresh_wf(title="if-match-test"))
|
||||
oc_id = r["id"]; created_ids.append(oc_id)
|
||||
stamp = r["updated_at"]
|
||||
# Stale If-Match -> 409
|
||||
code, _ = http("PATCH", f"/{oc_id}", {"title": "v2"}, extra_headers={"If-Match": "1999-01-01T00:00:00"})
|
||||
ok("stale If-Match returns 409", code == 409)
|
||||
# Fresh If-Match -> 200
|
||||
code, r = http("PATCH", f"/{oc_id}", {"title": "v2"}, extra_headers={"If-Match": stamp})
|
||||
ok("fresh If-Match returns 200", code == 200)
|
||||
# Missing If-Match -> still works (legacy back-compat)
|
||||
code, _ = http("PATCH", f"/{oc_id}", {"title": "v3"})
|
||||
ok("missing If-Match still accepted (legacy clients)", code == 200)
|
||||
|
||||
# ============ 21. /run returns skipped status on cost cap ============
|
||||
section("21. /run surfaces cost-cap skipped status")
|
||||
code, r = http("POST", "/create", fresh_wf(title="cap-immediate", cost_cap_usd_monthly=0.01))
|
||||
cap_id = r["id"]; created_ids.append(cap_id)
|
||||
# Run once and produce a real-looking run via the legacy 0-cost path —
|
||||
# the cap is checked against actual recorded cost_usd. Without a way to
|
||||
# inject a $5 run here we just verify the field surfaces correctly when
|
||||
# the cap is 0 (which should always exceed). With 0 the executor's >=
|
||||
# check skips immediately because spent (0.0) >= 0.0.
|
||||
# (Using cap=0 forces the skip path on first run.)
|
||||
http("PATCH", f"/{cap_id}", {"cost_cap_usd_monthly": 0.0})
|
||||
code, r = http("POST", f"/{cap_id}/run")
|
||||
# It may take a tick for the executor to land the skipped row; the
|
||||
# endpoint already polls up to 250ms internally.
|
||||
ok("run response includes status field", "status" in r)
|
||||
ok("run response includes error field", "error" in r)
|
||||
if r.get("status") == "skipped":
|
||||
ok("/run surfaces skipped status", True, info=r.get("error", ""))
|
||||
else:
|
||||
ok("/run surfaces skipped status", False,
|
||||
info=f"status was {r.get('status')!r} not skipped; may have raced")
|
||||
|
||||
# ============ Done ============
|
||||
print()
|
||||
if fail_count == 0:
|
||||
|
||||
Reference in New Issue
Block a user