From eb561d7187d3f4703603943f21080582fe5c94ce Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 18 May 2026 00:04:07 -0700 Subject: [PATCH] [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 --- CONTRIBUTING.md | 191 +++++++++ backend/apps/settings/credentials.py | 21 - backend/apps/workflows/executor.py | 53 ++- backend/apps/workflows/scheduler.py | 7 +- backend/apps/workflows/workflows.py | 41 +- backend/tests/test_workflows_semantics.py | 116 +++++ frontend/src/app/components/Animated.tsx | 115 ----- .../src/app/pages/AgentChat/AgentChat.tsx | 25 +- .../app/pages/AgentChat/BranchNavigator.tsx | 60 --- .../src/app/pages/Analytics/PixelChart.tsx | 402 ------------------ .../app/pages/Dashboard/CloseAgentDialog.tsx | 60 --- .../src/app/pages/Workflows/ScheduleFacet.tsx | 124 ++++-- .../pages/Workflows/ScheduleThisPopover.tsx | 105 +++-- frontend/src/app/pages/Workflows/StepList.tsx | 168 ++++++++ .../src/app/pages/Workflows/WorkflowCard.tsx | 158 ++++++- .../pages/Workflows/WorkflowCardSubviews.tsx | 255 +++++++++-- .../app/pages/Workflows/WorkflowEditViews.tsx | 28 +- .../app/pages/Workflows/WorkflowsHubCard.tsx | 2 +- .../app/pages/Workflows/workflowVisuals.tsx | 390 +++++++++++++++++ .../UnderConstruction.module.scss | 28 -- .../UnderConstruction/UnderConstruction.tsx | 15 - frontend/src/shared/state/workflowsSlice.ts | 51 ++- frontend/src/shared/styles/color.module.scss | 128 ------ frontend/src/shared/styles/getStyleValue.tsx | 12 - frontend/src/shared/styles/layout.module.scss | 62 --- frontend/src/shared/styles/text.module.scss | 63 --- frontend/src/shared/styles/utils.module.scss | 63 --- scripts/exhaustive-stress.py | 44 +- 28 files changed, 1612 insertions(+), 1175 deletions(-) create mode 100644 CONTRIBUTING.md delete mode 100644 frontend/src/app/components/Animated.tsx delete mode 100644 frontend/src/app/pages/AgentChat/BranchNavigator.tsx delete mode 100644 frontend/src/app/pages/Analytics/PixelChart.tsx delete mode 100644 frontend/src/app/pages/Dashboard/CloseAgentDialog.tsx create mode 100644 frontend/src/app/pages/Workflows/StepList.tsx create mode 100644 frontend/src/app/pages/Workflows/workflowVisuals.tsx delete mode 100644 frontend/src/shared/modals/UnderConstruction/UnderConstruction.module.scss delete mode 100644 frontend/src/shared/modals/UnderConstruction/UnderConstruction.tsx delete mode 100644 frontend/src/shared/styles/color.module.scss delete mode 100644 frontend/src/shared/styles/getStyleValue.tsx delete mode 100644 frontend/src/shared/styles/layout.module.scss delete mode 100644 frontend/src/shared/styles/text.module.scss delete mode 100644 frontend/src/shared/styles/utils.module.scss diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..8b4ba35b --- /dev/null +++ b/CONTRIBUTING.md @@ -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` | diff --git a/backend/apps/settings/credentials.py b/backend/apps/settings/credentials.py index 86b572cf..a5177ea9 100644 --- a/backend/apps/settings/credentials.py +++ b/backend/apps/settings/credentials.py @@ -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. diff --git a/backend/apps/workflows/executor.py b/backend/apps/workflows/executor.py index 6d803f1f..ec31b685 100644 --- a/backend/apps/workflows/executor.py +++ b/backend/apps/workflows/executor.py @@ -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) diff --git a/backend/apps/workflows/scheduler.py b/backend/apps/workflows/scheduler.py index 12b78879..d05a5be3 100644 --- a/backend/apps/workflows/scheduler.py +++ b/backend/apps/workflows/scheduler.py @@ -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: diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py index c97a6363..d249e11a 100644 --- a/backend/apps/workflows/workflows.py +++ b/backend/apps/workflows/workflows.py @@ -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") diff --git a/backend/tests/test_workflows_semantics.py b/backend/tests/test_workflows_semantics.py index 9355642d..79a887e7 100644 --- a/backend/tests/test_workflows_semantics.py +++ b/backend/tests/test_workflows_semantics.py @@ -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 diff --git a/frontend/src/app/components/Animated.tsx b/frontend/src/app/components/Animated.tsx deleted file mode 100644 index 5df99d77..00000000 --- a/frontend/src/app/components/Animated.tsx +++ /dev/null @@ -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. - * - * {(v) => {v}} - * Old value fades to 30% while new value fades in. Cancels on rapid changes. - * - * `$${n.toFixed(4)}`} /> - * RAF-tweens from previous to new value. Caps duration on big jumps. - */ - -interface CrossFadeProps { - value: T; - children: (currentValue: T) => React.ReactNode; - /** Defaults to DURATION_MS.quick (140ms). */ - durationMs?: number; -} - -export function CrossFadeOnChange({ value, children, durationMs }: CrossFadeProps) { - 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 ( - - {children(displayed)} - - ); -} - -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 = ({ - value, - format = (n) => String(Math.round(n)), - maxDurationMs = 500, -}) => { - const reduced = useReducedMotion(); - const [displayed, setDisplayed] = useState(value); - const startedAtRef = useRef(null); - const fromRef = useRef(value); - const toRef = useRef(value); - const rafRef = useRef(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)}; -}; diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index c3b6113a..faaed4ad 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -946,13 +946,24 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose )} {!isDraft && id && ( - - setScheduleAnchor(e.currentTarget)} - sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }}> - - + + 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' }, + }}> + + Schedule + )} {!isDraft && id && ( diff --git a/frontend/src/app/pages/AgentChat/BranchNavigator.tsx b/frontend/src/app/pages/AgentChat/BranchNavigator.tsx deleted file mode 100644 index e506f8c5..00000000 --- a/frontend/src/app/pages/AgentChat/BranchNavigator.tsx +++ /dev/null @@ -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 = ({ currentIndex, totalBranches, onPrevious, onNext }) => { - const c = useClaudeTokens(); - if (totalBranches <= 1) return null; - - return ( - - - - - - - {currentIndex + 1} / {totalBranches} - - - - - - - ); -}; - -export default BranchNavigator; diff --git a/frontend/src/app/pages/Analytics/PixelChart.tsx b/frontend/src/app/pages/Analytics/PixelChart.tsx deleted file mode 100644 index f889517b..00000000 --- a/frontend/src/app/pages/Analytics/PixelChart.tsx +++ /dev/null @@ -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 = ({ - data, - palette = 'salmon', - height = 140, - pixelSize = 6, - formatValue, - glow = true, - showXLabels = true, - showYScale = true, - mode = 'bar', -}) => { - const canvasRef = useRef(null); - const containerRef = useRef(null); - const animRef = useRef(0); - const progressRef = useRef(0); - const hoverIdxRef = useRef(-1); - const tooltipRef = useRef(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 ( - - - {/* X-axis labels */} - {showXLabels && data.length > 0 && ( - - {xLabels.map((xl) => ( - - {xl.label} - - ))} - - )} - {/* Tooltip */} - - - ); -}; - -export default PixelChart; diff --git a/frontend/src/app/pages/Dashboard/CloseAgentDialog.tsx b/frontend/src/app/pages/Dashboard/CloseAgentDialog.tsx deleted file mode 100644 index 541dbf83..00000000 --- a/frontend/src/app/pages/Dashboard/CloseAgentDialog.tsx +++ /dev/null @@ -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 = ({ open, onCancel, onConfirm }) => { - const c = useClaudeTokens(); - return ( - - - Agent still running - - - - This agent is still running. Closing it will pause the agent. - You can resume it later from the chat history. - - - - - - - - ); -}; - -export default CloseAgentDialog; diff --git a/frontend/src/app/pages/Workflows/ScheduleFacet.tsx b/frontend/src/app/pages/Workflows/ScheduleFacet.tsx index abdb98b0..a1cf5608 100644 --- a/frontend/src/app/pages/Workflows/ScheduleFacet.tsx +++ b/frontend/src/app/pages/Workflows/ScheduleFacet.tsx @@ -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 {s.repeat_unit === 'week' && ( - ↳ on + on {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 )} - ↳ at + at {/* 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 AM PM - {s.timezone === 'local' ? 'system tz' : s.timezone} + {friendlyTzLabel(s.timezone)} {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 } }}> - Forever + Until I turn it off Until a date - After N runs + After a number of runs {endKind === 'on_date' && ( )} + {/* 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 ( + + This date is in the past. The schedule will turn itself off. + + ); + } + } + if (endKind === 'after_n' && s.max_runs != null && s.runs_count >= s.max_runs) { + return ( + + This workflow has already run {s.runs_count}× (limit {s.max_runs}). Raise the number or reset the counter to re-arm. + + ); + } + 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. */} setDraft({ ...draft, cost_cap_usd_monthly: v })} /> {/* Row 6: action surface (freeze). */} - Which actions can the agent use? + What can the agent do while it runs? - {/* 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. */} - If a run was missed (computer asleep): + If your computer was asleep when a run was due: @@ -346,7 +393,7 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se /> ))} {canAddBackup && ( - + add a backup + + Escalate if I don't respond )} ); @@ -365,11 +412,11 @@ function AppOpenStatusBadge({ info, hour, minute, onFix }: { info: AppOpenInfo; }}> - {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.`} {!good && ( - - Fix + + Always-on )} @@ -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 ( + + + {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.`} + + + A monthly cost cap doesn't apply here. Your plan handles the usage limits. + + + ); + } + return ( {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.'} - - Monthly cost cap: + {liveFires > 0 && lastRun > 0 && ( + + {`$${lastRun.toFixed(4)} × ${liveFires} runs`} + + )} + + Monthly cap: + $ 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 }} /> - USD. Skips runs once exceeded; visible in History. + + We'll skip runs once you hit this for the month. You'll see the skip in History. + ); } diff --git a/frontend/src/app/pages/Workflows/ScheduleThisPopover.tsx b/frontend/src/app/pages/Workflows/ScheduleThisPopover.tsx index e3218b0d..869d2773 100644 --- a/frontend/src/app/pages/Workflows/ScheduleThisPopover.tsx +++ b/frontend/src/app/pages/Workflows/ScheduleThisPopover.tsx @@ -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(sessionName || 'Untitled'); const [busy, setBusy] = useState(false); const [error, setError] = useState(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(() => { + 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)); 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, + })); + 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 ( SCHEDULE THIS CHAT + {existing && ( + + + This chat is already scheduled. + + + "{existing.title}" was made from this conversation. Adding another would fire twice. + + + Open existing → + + + )} Name: Custom… - Open the full editor + Open the editor without saving yet {error && ( {error} diff --git a/frontend/src/app/pages/Workflows/StepList.tsx b/frontend/src/app/pages/Workflows/StepList.tsx new file mode 100644 index 00000000..33c93a09 --- /dev/null +++ b/frontend/src/app/pages/Workflows/StepList.tsx @@ -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 ( + + {/* Connector spine. SVG so the live-fill segment can clip cleanly. */} + {steps.length > 1 && ( + + )} + {steps.length > 1 && activeStepIdx !== null && ( + + )} + + {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 ( + + + {Icon ? : (idx + 1)} + + + {onChangeStep ? ( + ) => 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 }, + }} + /> + ) : ( + + {s.text} + + )} + {duration && ( + + + ~{duration} + + + )} + + + ); + })} + + + ); +} + +// 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)); +} diff --git a/frontend/src/app/pages/Workflows/WorkflowCard.tsx b/frontend/src/app/pages/Workflows/WorkflowCard.tsx index c75c2b22..6d11a102 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCard.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCard.tsx @@ -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 = ({ // 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(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(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 = ({ }, [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 = ({ }} > + {title} - {workflow?.last_run_status && ( - - {workflow.last_run_status} - - )} + {runs && runs.length > 0 && } = ({ icon={} 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 = ({ label="Edit" icon={} 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' } }))} /> = ({ 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`. */} - + = ({ }} /> )} - {card.view === 'saved' && workflow && } + {card.view === 'saved' && workflow && ( + r.status === 'running')?.id || null} + /> + )} {card.view === 'edit' && workflow && ( dispatch(updateWorkflowCard({ workflowId, patch: { editFacet: f } }))} + onDirtyChange={setEditDirty} /> )} {card.view === 'history' && workflow && ( @@ -476,13 +556,38 @@ const WorkflowCard: React.FC = ({ }} /> ))} + {/* 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. */} + 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. */} + setCloseConfirmOpen(false)}> + Close this workflow card? + + The schedule will keep firing in the background even after you close this card. Choose what you want to happen. + + + + + + + ); }; -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 = ( 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 && ( + + )} ); + if (dot && dotTooltip) { + return {btn}; + } + if (breathe && breatheTooltip) { + return {btn}; + } + return btn; } export default React.memo(WorkflowCard); diff --git a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx index 6ac22562..704ae390 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx @@ -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): string { if (s === 'success') return c.status.success; @@ -30,7 +34,7 @@ export function statusBg(s: string, c: ReturnType): 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 ( {description} - - {steps.map((s, idx) => ( - - {idx + 1} - {s.text} - - ))} - - - + + {/* 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. */} + + + ); } -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 ( - Scheduled: {describeSchedule(workflow.schedule)} - Permissions: {describePermissions(workflow)} - {workflow.description} - - {steps.map((s, idx) => ( - - {idx + 1} - {s.text} - - ))} + {/* Pill chips replace the two text rows. Same info, glanceable. */} + + + + + + + {workflow.description} + ); } +// 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(null); + const [entries, setEntries] = useState }> | null>(null); + const [loading, setLoading] = useState(false); + const open = useCallback(async (e: React.MouseEvent) => { + 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 ( + <> + + + + {entries === null ? 'edits' : `${count} edit${count === 1 ? '' : 's'}`} + + + + + + RECENT EDITS + + {loading && Loading…} + {!loading && (entries === null || entries.length === 0) && ( + No edits yet. + )} + {!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 ( + + + {e.who || 'user'} + {relTimeShort(e.ts)} + + {summary} + + ); + })} + + + + ); +} + +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(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 No runs yet; } return ( - {runs.map((r) => ( - 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 } }}> - - {labelForStatus(r.status)} + + + {recent.map((r) => ( + + ))} + + + {(['all', 'failure', 'ran_late'] as const).map((k) => ( + 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'} - {formatRunDate(r.started_at)} - Open → + ))} + + {groups.map(({ key, runs: gRuns }) => ( + + + {key.toUpperCase()} + + {gRuns.map((r) => { + const expanded = expandedId === r.id; + const dur = runDuration(r); + return ( + + 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 } }}> + + {labelForStatus(r.status)} + + {formatRunDate(r.started_at)} + {dur && {dur}} + {r.cost_usd > 0 && ${r.cost_usd.toFixed(4)}} + {/* Chevron makes the row read as expandable instead of + static text. Rotates 180° while open so the affordance + stays visible after click. */} + + + {expanded && ( + + {r.error ? ( + {r.error} + ) : ( + + {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. + + )} + + { e.stopPropagation(); onOpen(r); }} role="button" sx={{ fontSize: '0.74rem', fontWeight: 600, color: c.accent.primary, cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}> + See full conversation → + + + + )} + + ); + })} ))} diff --git a/frontend/src/app/pages/Workflows/WorkflowEditViews.tsx b/frontend/src/app/pages/Workflows/WorkflowEditViews.tsx index 531dab3c..1d9373a1 100644 --- a/frontend/src/app/pages/Workflows/WorkflowEditViews.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowEditViews.tsx @@ -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); @@ -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); diff --git a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx index d89cc438..12c469f8 100644 --- a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx @@ -288,7 +288,7 @@ const WorkflowsHubCard: React.FC = ({ New - + ; + +export function statusDotColor(status: LastRunStatus | null | undefined, c: ReturnType) { + 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 ( + + + + + {word} + + + + ); +} + +// ---------- 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 ( + + {WEEKDAY_LABEL.map((lbl, idx) => { + const active = on_days.includes(idx); + return ( + + {lbl} + + ); + })} + + ); +} + +function permIcon(kind: PermissionTier['kind'], size = 13) { + if (kind === 'text') return ; + if (kind === 'call') return ; + return ; +} + +// 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 ( + + + {tiers.map((t, i) => ( + + {permIcon(t.kind)} + {i < tiers.length - 1 && } + + ))} + + + ); +} + +export function ScheduleChip({ workflow }: { workflow: Workflow }) { + const c = useClaudeTokens(); + const enabled = workflow.schedule.enabled; + return ( + + + + {scheduleShort(workflow.schedule)} + {enabled && workflow.schedule.repeat_unit === 'week' && ( + + )} + + + ); +} + +// 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 ( + + + + {route.subLabel || 'Subscription'} + + + ); + } + return ( + + + + ~{est.fires_per_month} runs/mo + + + ); + } + + // 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 ( + + + + {monthly < 0.01 ? '<0.01' : monthly.toFixed(2)}/mo + + + ); +} + +function chipSx(c: ReturnType) { + 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 ( + Last ran {ago} + ); +} + +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 ( + + + {slice.map((r) => ( + + ))} + + + ); +} + +// ---------- 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; +} diff --git a/frontend/src/shared/modals/UnderConstruction/UnderConstruction.module.scss b/frontend/src/shared/modals/UnderConstruction/UnderConstruction.module.scss deleted file mode 100644 index 3e660d5c..00000000 --- a/frontend/src/shared/modals/UnderConstruction/UnderConstruction.module.scss +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/frontend/src/shared/modals/UnderConstruction/UnderConstruction.tsx b/frontend/src/shared/modals/UnderConstruction/UnderConstruction.tsx deleted file mode 100644 index 0c1b746f..00000000 --- a/frontend/src/shared/modals/UnderConstruction/UnderConstruction.tsx +++ /dev/null @@ -1,15 +0,0 @@ - -import React from 'react'; -import styles from './UnderConstruction.module.scss'; // CSS for styling the overlay - -const UnderConstruction = () => { - return ( -
- Console Icon -

Under Construction

-

This feature is coming soon!

-
- ); -}; - -export { UnderConstruction }; \ No newline at end of file diff --git a/frontend/src/shared/state/workflowsSlice.ts b/frontend/src/shared/state/workflowsSlice.ts index c84373d8..68197ea4 100644 --- a/frontend/src/shared/state/workflowsSlice.ts +++ b/frontend/src/shared/state/workflowsSlice.ts @@ -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; ifMatch?: string | null }, + { rejectValue: { kind: 'stale' | 'network' | 'server'; message: string; current_updated_at?: string } } +>( 'workflows/update', - async ({ id, patch }: { id: string; patch: Partial }) => { - 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 = { '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( diff --git a/frontend/src/shared/styles/color.module.scss b/frontend/src/shared/styles/color.module.scss deleted file mode 100644 index 439d43cb..00000000 --- a/frontend/src/shared/styles/color.module.scss +++ /dev/null @@ -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 - ); -} diff --git a/frontend/src/shared/styles/getStyleValue.tsx b/frontend/src/shared/styles/getStyleValue.tsx deleted file mode 100644 index 7f6162fd..00000000 --- a/frontend/src/shared/styles/getStyleValue.tsx +++ /dev/null @@ -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 -}; \ No newline at end of file diff --git a/frontend/src/shared/styles/layout.module.scss b/frontend/src/shared/styles/layout.module.scss deleted file mode 100644 index eb5d9958..00000000 --- a/frontend/src/shared/styles/layout.module.scss +++ /dev/null @@ -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 - ); -} \ No newline at end of file diff --git a/frontend/src/shared/styles/text.module.scss b/frontend/src/shared/styles/text.module.scss deleted file mode 100644 index 8e218db1..00000000 --- a/frontend/src/shared/styles/text.module.scss +++ /dev/null @@ -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 - ); -} \ No newline at end of file diff --git a/frontend/src/shared/styles/utils.module.scss b/frontend/src/shared/styles/utils.module.scss deleted file mode 100644 index 9401ca4a..00000000 --- a/frontend/src/shared/styles/utils.module.scss +++ /dev/null @@ -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; -} diff --git a/scripts/exhaustive-stress.py b/scripts/exhaustive-stress.py index 8a889437..456edfd1 100644 --- a/scripts/exhaustive-stress.py +++ b/scripts/exhaustive-stress.py @@ -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: