Skip to content

Commit 3e537e2

Browse files
gizmaxTomas Pflanzer
andauthored
feat(cli): sandcastle run --local (in-process, no server) + onboarding engine pick (#242)
Two first-run UX fixes. 1. `sandcastle run --local <workflow>` executes a workflow in-process via the engine executor, with no running server. Before this, `sandcastle run` was an HTTP client pointed at localhost:8080, so a fresh `pip install sandcastle-ai` could not run anything until `sandcastle serve` was started - the very first command failed with a connection error. --local resolves a .yaml path or a built-in template name, builds the plan, best-effort creates a local DB run row (so steps/audit persist and the run shows in the dashboard, silently skipped when no DB), and runs the engine with admin_trusted=True so code/transform steps work. The connection-error message now also suggests --local. 2. Onboarding: add a fifth featured template, Provider Consensus (provider-consensus-decision-engine), as an "Engine" pick that showcases the provider-neutral thesis (ask three providers, act on their agreement). Adds test_cli_local_run.py (code-only workflow runs with no server/keys, unknown name exits cleanly, a built-in name resolves). Existing CLI tests and the 794 dashboard tests stay green. Co-authored-by: Tomas Pflanzer <tom@wiseguys.co>
1 parent 6e9e7ab commit 3e537e2

3 files changed

Lines changed: 208 additions & 1 deletion

File tree

dashboard/src/components/onboarding/StepChooseTemplate.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
FileText,
77
Search,
88
Database,
9+
Network,
910
Loader2,
1011
} from "lucide-react";
1112
import { api } from "@/api/client";
@@ -64,6 +65,14 @@ const FEATURED_TEMPLATES: FeaturedTemplate[] = [
6465
icon: Database,
6566
color: "text-[#A78BFA] bg-[#A78BFA]/10",
6667
},
68+
{
69+
name: "provider-consensus-decision-engine",
70+
displayName: "Provider Consensus",
71+
description: "Ask three AI providers the same question and act on their agreement. Provider-neutral by design: swap or lose a provider and the decision holds.",
72+
category: "Engine",
73+
icon: Network,
74+
color: "text-[#2DD4BF] bg-[#2DD4BF]/10",
75+
},
6776
];
6877

6978
interface ApiTemplate {

src/sandcastle/__main__.py

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,8 @@ def _format_cli_error(exc: Exception) -> str:
225225
if "ConnectError" in exc_type or "ConnectionError" in exc_type:
226226
return (
227227
f"Connection failed: {exc}\n"
228-
" Is the Sandcastle server running? Start it with: sandcastle serve"
228+
" Is the Sandcastle server running? Start it with: sandcastle serve\n"
229+
" Or run without a server: sandcastle run --local <workflow.yaml>"
229230
)
230231
if "ConnectTimeout" in exc_type or "TimeoutException" in exc_type:
231232
return f"Connection timed out: {exc}\n Check that the server URL is correct."
@@ -916,6 +917,130 @@ def _cmd_serve(args: argparse.Namespace) -> None:
916917
)
917918

918919

920+
def _run_local(workflow: str, input_data: dict[str, Any], max_cost: float | None) -> None:
921+
"""Execute a workflow in-process, without a running server.
922+
923+
Resolves *workflow* as a path to a .yaml file or as a built-in template name,
924+
builds the plan, and runs it through the engine executor directly. The local
925+
caller is trusted, so code/transform steps are allowed. Run-step persistence is
926+
best-effort and silently skipped when no database is configured.
927+
"""
928+
import asyncio
929+
import tempfile
930+
from pathlib import Path
931+
932+
from sandcastle.engine.dag import build_plan, parse_yaml_string
933+
from sandcastle.engine.executor import execute_workflow
934+
from sandcastle.engine.storage import LocalStorage
935+
936+
wf_path = Path(workflow)
937+
try:
938+
if wf_path.suffix in (".yaml", ".yml") and wf_path.exists():
939+
content = wf_path.read_text()
940+
else:
941+
from sandcastle.templates import get_template
942+
943+
content, _ = get_template(workflow)
944+
wf = parse_yaml_string(content)
945+
plan = build_plan(wf)
946+
except FileNotFoundError:
947+
print(
948+
f"Error: workflow '{workflow}' not found as a file or a built-in template.",
949+
file=sys.stderr,
950+
)
951+
sys.exit(1)
952+
except Exception as exc:
953+
print(_format_cli_error(exc), file=sys.stderr)
954+
sys.exit(1)
955+
956+
storage = LocalStorage(tempfile.mkdtemp(prefix="sandcastle-local-"))
957+
print(_color(f"Running '{wf.name}' locally (in-process, no server)...", _C.CYAN))
958+
959+
async def _local_async() -> Any:
960+
import uuid
961+
962+
run_id = str(uuid.uuid4())
963+
persisted = False
964+
# Best-effort: set up the local DB and a parent run row so step/audit
965+
# persistence works and the run shows up in the dashboard. When no DB is
966+
# available this is silently skipped and the run still executes.
967+
try:
968+
from sandcastle.models.db import Run, RunStatus, async_session, init_db
969+
970+
await init_db()
971+
async with async_session() as session:
972+
session.add(
973+
Run(
974+
id=uuid.UUID(run_id),
975+
workflow_name=wf.name,
976+
status=RunStatus.RUNNING,
977+
input_data=input_data,
978+
max_cost_usd=max_cost,
979+
)
980+
)
981+
await session.commit()
982+
persisted = True
983+
except Exception:
984+
persisted = False
985+
986+
wf_result = await execute_workflow(
987+
workflow=wf,
988+
plan=plan,
989+
input_data=input_data,
990+
run_id=run_id if persisted else None,
991+
storage=storage,
992+
max_cost_usd=max_cost,
993+
admin_trusted=True,
994+
)
995+
996+
if persisted:
997+
try:
998+
from sandcastle.models.db import Run, RunStatus, async_session
999+
1000+
status_map = {
1001+
"completed": RunStatus.COMPLETED,
1002+
"failed": RunStatus.FAILED,
1003+
"error": RunStatus.FAILED,
1004+
"partial": RunStatus.PARTIAL,
1005+
"cancelled": RunStatus.CANCELLED,
1006+
"budget_exceeded": RunStatus.BUDGET_EXCEEDED,
1007+
}
1008+
async with async_session() as session:
1009+
db_run = await session.get(Run, uuid.UUID(run_id))
1010+
if db_run is not None:
1011+
db_run.status = status_map.get(
1012+
str(_attr(wf_result, "status", "")), RunStatus.COMPLETED
1013+
)
1014+
db_run.total_cost_usd = _attr(wf_result, "total_cost_usd", 0.0)
1015+
db_run.output_data = _attr(wf_result, "outputs", {})
1016+
db_run.error = _attr(wf_result, "error", None)
1017+
await session.commit()
1018+
except Exception:
1019+
pass
1020+
return wf_result
1021+
1022+
try:
1023+
result = asyncio.run(_local_async())
1024+
except Exception as exc:
1025+
print(_format_cli_error(exc), file=sys.stderr)
1026+
sys.exit(1)
1027+
1028+
status = _attr(result, "status", "unknown")
1029+
run_id = _attr(result, "run_id", "")
1030+
cost = _attr(result, "total_cost_usd", 0.0)
1031+
error = _attr(result, "error", None)
1032+
outputs = _attr(result, "outputs", {}) or {}
1033+
1034+
print(f"{_status_color(str(status))} run {run_id} cost ${float(cost or 0.0):.4f}")
1035+
if error:
1036+
print(_color(f"Error: {error}", _C.RED), file=sys.stderr)
1037+
if outputs:
1038+
print(_color("Outputs:", _C.BOLD))
1039+
print(json.dumps(outputs, indent=2, default=str))
1040+
if str(status) in ("failed", "error", "budget_exceeded"):
1041+
sys.exit(2)
1042+
1043+
9191044
def _cmd_run(args: argparse.Namespace) -> None:
9201045
"""Run a workflow via the SDK client."""
9211046
from pathlib import Path
@@ -931,6 +1056,15 @@ def _cmd_run(args: argparse.Namespace) -> None:
9311056
print("Error: workflow name cannot be empty.", file=sys.stderr)
9321057
sys.exit(1)
9331058

1059+
# Local in-process execution path - no running server required.
1060+
if getattr(args, "local", False):
1061+
input_data: dict[str, Any] = {}
1062+
if args.input_file:
1063+
input_data = _load_input_file(args.input_file)
1064+
input_data.update(_parse_input_pairs(args.input))
1065+
_run_local(workflow_name, input_data, args.max_cost)
1066+
return
1067+
9341068
client = _get_client(args)
9351069

9361070
# Build input data
@@ -4373,6 +4507,11 @@ def _build_parser() -> argparse.ArgumentParser:
43734507
p_run.add_argument(
43744508
"--max-cost", type=float, default=None, metavar="USD", help="Maximum cost limit in USD"
43754509
)
4510+
p_run.add_argument(
4511+
"--local",
4512+
action="store_true",
4513+
help="Run the workflow in-process without a server (no `sandcastle serve` needed)",
4514+
)
43764515
_add_connection_args(p_run)
43774516

43784517
# --- status ---

tests/test_cli_local_run.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Tests for `sandcastle run --local` - in-process execution without a server.
2+
3+
The local path fixes the first-run experience: a fresh `pip install` could not run
4+
anything because `sandcastle run` is an HTTP client that needs `sandcastle serve`
5+
first. `--local` parses the workflow and drives the engine executor directly.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import pytest
11+
12+
from sandcastle.__main__ import _run_local
13+
14+
CODE_ONLY_WORKFLOW = """name: cli-local-test
15+
description: A code-only workflow that runs with no server and no API keys.
16+
default_model: sonnet
17+
input_schema:
18+
required: []
19+
properties:
20+
who: { type: string, default: "world" }
21+
steps:
22+
- id: greet
23+
type: code
24+
code_config:
25+
code: |
26+
result = {"msg": "hi " + str(_input.get("who", "world")), "n": 6 * 7}
27+
"""
28+
29+
30+
def test_run_local_executes_code_only_workflow(tmp_path, capsys):
31+
"""A code-only workflow runs in-process and prints its output - no server, no keys."""
32+
wf = tmp_path / "wf.yaml"
33+
wf.write_text(CODE_ONLY_WORKFLOW)
34+
_run_local(str(wf), {"who": "tester"}, None)
35+
out = capsys.readouterr().out
36+
assert "completed" in out
37+
assert "hi tester" in out
38+
assert "42" in out # 6 * 7, proving the code step actually executed
39+
40+
41+
def test_run_local_unknown_workflow_exits_cleanly(capsys):
42+
"""An unknown name (not a file, not a built-in template) exits 1 with a clear error."""
43+
with pytest.raises(SystemExit) as exc_info:
44+
_run_local("/nonexistent/path/nope.yaml", {}, None)
45+
assert exc_info.value.code == 1
46+
assert "not found" in capsys.readouterr().err
47+
48+
49+
def test_run_local_resolves_builtin_template_name(tmp_path, capsys):
50+
"""A bare built-in template name resolves (does not error as 'not found'). It may
51+
fail later for lack of provider keys, but resolution + planning must succeed."""
52+
# 'summarize' is a built-in template; with no key it will fail at the LLM step,
53+
# which exits 2 (run failed) - never the 'not found' path (exit 1).
54+
try:
55+
_run_local("summarize", {"text": "hello"}, None)
56+
except SystemExit as exc:
57+
assert exc.code in (0, 2), f"unexpected exit code {exc.code}"
58+
combined = capsys.readouterr()
59+
assert "not found" not in (combined.out + combined.err)

0 commit comments

Comments
 (0)