Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
a8b2420
feat: implicit flash endpoint resolution via sentinel headers
KAJdev Apr 16, 2026
f2ceab2
refactor: drop flash.toml support in favor of env vars
KAJdev Apr 16, 2026
f92db36
feat: rename flash run to flash dev, require explicit context for rem…
KAJdev Apr 21, 2026
ad90740
merge: resolve conflict in execute_class.py
KAJdev Apr 21, 2026
978ba69
feat: rename flash run to flash dev, require explicit context for rem…
KAJdev Apr 21, 2026
fb37042
refactor: clean up CLI output formatting
KAJdev Apr 21, 2026
5485e3c
refactor: establish consistent color palette across CLI
KAJdev Apr 21, 2026
6df78e1
refactor: flatten deploy output, remove nesting
KAJdev Apr 21, 2026
d0161aa
refactor: use tree chars for deploy endpoint listing
KAJdev Apr 22, 2026
04c6d7b
fix: handle hyphenated directory names in flash dev codegen
KAJdev Apr 22, 2026
cec8019
refactor: route worker logs through print() instead of logging
KAJdev Apr 22, 2026
40c518f
fix: improve worker log filtering and add color to runtime output
KAJdev Apr 22, 2026
8015207
fix: indent user stdout under request, print before completion line
KAJdev Apr 22, 2026
f11911d
fix: worker log filters now handle timezone offsets and JSON-wrapped …
KAJdev Apr 22, 2026
04744f0
fix: drop Rich Status spinner for pull progress
KAJdev Apr 22, 2026
da65c5d
fix: duplicate logs on subsequent requests to warm workers
KAJdev Apr 22, 2026
409a4cd
feat: redesign dev console lifecycle output
KAJdev Apr 22, 2026
8d7d33c
fix: strip 'live-' prefix from endpoint names in dev console output
KAJdev Apr 22, 2026
a24b9ec
feat: redesign flash dev startup and shutdown output
KAJdev Apr 22, 2026
bfad91e
fix: detect duplicate endpoint names across files in manifest builder
KAJdev Apr 22, 2026
939182a
fix: clean up flash dev startup route table
KAJdev Apr 22, 2026
23486a7
feat: redesign flash deploy output
KAJdev Apr 22, 2026
b91ac4d
fix: standardize spinner styles and add completion lines
KAJdev Apr 22, 2026
7e7ee8a
feat: add upload progress bar to flash deploy
KAJdev Apr 22, 2026
d3c06f7
feat: redesign flash app and env command output
KAJdev Apr 22, 2026
f6f4bd5
feat: add column headers to app and env list/get output
KAJdev Apr 22, 2026
a8baa6a
feat: simplify app list and env list output
KAJdev Apr 22, 2026
0379d5d
feat: redesign undeploy command output
KAJdev Apr 22, 2026
478068e
feat: G1a log format for flash dev runtime
KAJdev Apr 22, 2026
e318a3a
fix: align name columns in dev console output
KAJdev Apr 22, 2026
6074f67
fix: use resource_name not name on WorkerInfo
KAJdev Apr 22, 2026
a80033c
fix: set_name_width in generated server.py not parent process
KAJdev Apr 22, 2026
3074db6
fix: catch remote execution errors in dev server route handlers
KAJdev Apr 22, 2026
07cae82
Update pyproject.toml
KAJdev Apr 22, 2026
781f900
style: run ruff format
KAJdev Apr 22, 2026
f5ffdfa
fix: lint errors (F541 f-string, F401 unused import)
KAJdev Apr 22, 2026
38f11dc
Merge branch 'main' into zeke/ae-2741-implicit-flash-endpoint-resolut…
KAJdev Apr 22, 2026
3deb9ca
fix: unused variable lint errors
KAJdev Apr 22, 2026
368fbb7
fix: update tests to match new CLI output format
KAJdev Apr 22, 2026
89cf61e
fix: set FLASH_IS_LIVE_PROVISIONING in integration tests
KAJdev Apr 22, 2026
6a4e833
fix: set .name on mock resources in LB and live serverless tests
KAJdev Apr 22, 2026
6420b78
fix: set FLASH_IS_LIVE_PROVISIONING in concurrency integration tests
KAJdev Apr 22, 2026
3c7c7ac
fix: pad empty sentinel input to prevent runpod dropping input field
KAJdev Apr 22, 2026
4759db0
style: format
KAJdev Apr 22, 2026
741be70
fix: remove unused os import
KAJdev Apr 22, 2026
dbcf20e
fix: update handler generator tests for empty input acceptance
KAJdev Apr 22, 2026
528c7d4
fix: skip sentinel for client-mode endpoints, update empty input tests
KAJdev Apr 22, 2026
5a4080a
fix: keep sentinel for client endpoints, set live provisioning in ima…
KAJdev Apr 22, 2026
562962d
fix: live provisioning only in flash dev, guard fallback path
KAJdev Apr 22, 2026
eee09e3
fix: use Live resource classes for all non-deploy contexts
KAJdev Apr 22, 2026
1fe3394
fix: catch sentinel timeout with clear error message, 30s default
KAJdev Apr 22, 2026
b2731b4
fix: sentinel timeout 90s
KAJdev Apr 22, 2026
844b106
fix: update _is_live_provisioning tests for new default behavior
KAJdev Apr 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions src/runpod_flash/cli/commands/_run_server_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,12 @@ async def call_with_body(func, body):
model_fields_set) to match RunPod platform behavior. Plain dict
bodies bypass this check since they originate from LB local routes
where zero-param functions legitimately receive empty input.

Remote execution errors (timeouts, worker failures) are caught and
returned as JSON responses instead of raising through FastAPI.
"""
from fastapi.responses import JSONResponse

if hasattr(body, "model_fields_set") and not body.model_fields_set:
raise HTTPException(
status_code=422,
Expand All @@ -88,11 +93,22 @@ async def call_with_body(func, body):
'optional parameters, e.g. {"input": {"param_name": null}}.'
),
)
if hasattr(body, "model_dump"):
return await func(**body.model_dump())
raw = body.get("input", body) if isinstance(body, dict) else body
kwargs = _map_body_to_params(func, raw)
return await func(**kwargs)
try:
if hasattr(body, "model_dump"):
return await func(**body.model_dump())
raw = body.get("input", body) if isinstance(body, dict) else body
kwargs = _map_body_to_params(func, raw)
return await func(**kwargs)
except Exception as exc:
msg = str(exc)
# strip the "Remote execution failed: " wrapper if present
prefix = "Remote execution failed: "
if msg.startswith(prefix):
msg = msg[len(prefix) :]
return JSONResponse(
status_code=500,
content={"error": msg},
)


def to_dict(body) -> dict:
Expand Down Expand Up @@ -138,13 +154,13 @@ async def lb_execute(resource_config, func, body: dict):
if routing and routing.get("method")
else func.__name__
)
log.info(f"[REMOTE] {resource_config} | {route_label}")
log.debug(f"{resource_config} | {route_label}")

try:
result = await stub(
func, dependencies, system_dependencies, accelerate_downloads, **kwargs
)
log.info(f"[REMOTE] {resource_config} | Execution complete")
log.debug(f"{resource_config} | execution complete")
return result
except TimeoutError as e:
raise HTTPException(status_code=504, detail=str(e))
Expand Down
99 changes: 37 additions & 62 deletions src/runpod_flash/cli/commands/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,20 @@
from rich.console import Console

from runpod_flash.cli.utils.formatting import (
STATE_STYLE,
format_datetime,
print_error,
state_dot,
)
from runpod_flash.core.resources.app import FlashApp

console = Console()
console = Console(highlight=False)

apps_app = typer.Typer(short_help="Manage existing apps", name="app")


def _plural(n: int, word: str) -> str:
return f"{n} {word}{'s' if n != 1 else ' '}"


@apps_app.command("create", short_help="Create a new flash app")
def create(app_name: str = typer.Argument(..., help="Name for the new flash app")):
return asyncio.run(create_flash_app(app_name))
Expand Down Expand Up @@ -45,101 +47,74 @@ def delete(
async def list_flash_apps():
apps = await FlashApp.list()
if not apps:
console.print("\nNo Flash apps found.")
console.print(" Run [bold]flash deploy[/bold] to create one.\n")
console.print("\n no apps found")
console.print(" run [bold]flash deploy[/bold] to create one\n")
return

console.print()
rows = []
for app_data in apps:
name = app_data.get("name", "(unnamed)")
app_id = app_data.get("id", "")
environments = app_data.get("flashEnvironments") or []
builds = app_data.get("flashBuilds") or []
ec = len(app_data.get("flashEnvironments") or [])
bc = len(app_data.get("flashBuilds") or [])
rows.append((name, ec, bc))

mn = max(len(r[0]) for r in rows)

env_count = len(environments)
build_count = len(builds)
console.print()
for name, ec, bc in rows:
console.print(
f" [bold]{name}[/bold] "
f"{env_count} env{'s' if env_count != 1 else ''}, "
f"{build_count} build{'s' if build_count != 1 else ''} "
f"[dim]{app_id}[/dim]"
f" [white]{name:<{mn}}[/white]"
f" [dim]{_plural(ec, 'env')} {_plural(bc, 'build')}[/dim]"
)

for env in environments:
state = env.get("state", "UNKNOWN")
env_name = env.get("name", "?")
console.print(
f" {state_dot(state)} {env_name} [dim]{state.lower()}[/dim]"
)

console.print()
console.print()


async def create_flash_app(app_name: str):
with console.status(f"Creating flash app: {app_name}"):
app = await FlashApp.create(app_name)

console.print(
f"[green]✓[/green] Created app [bold]{app_name}[/bold] [dim]{app.id}[/dim]"
)
with console.status("[dim]creating...[/dim]"):
await FlashApp.create(app_name)
console.print(f"[green]\u2713[/green] created app [bold]{app_name}[/bold]")


async def get_flash_app(app_name: str):
with console.status(f"Fetching flash app: {app_name}"):
with console.status("[dim]fetching...[/dim]"):
app = await FlashApp.from_name(app_name)
envs, builds = await asyncio.gather(app.list_environments(), app.list_builds())

console.print(f"\n [bold]{app.name}[/bold] [dim]{app.id}[/dim]")
console.print(f"\n [bold]{app.name}[/bold]\n")

# environments
console.print("\n [bold]Environments[/bold]")
if envs:
mn = max(len(e.get("name", "") or "") for e in envs)
for env in envs:
state = env.get("state", "UNKNOWN")
color = STATE_STYLE.get(state, "yellow")
name = env.get("name", "(unnamed)")
build_id = env.get("activeBuildId")
build_id = env.get("activeBuildId") or "-"
short_build = build_id[:12] if len(build_id) > 12 else build_id
created = format_datetime(env.get("createdAt"))

console.print(
f" {state_dot(state)} [bold]{name}[/bold] "
f"[{color}]{state.lower()}[/{color}]"
f" [white]{name:<{mn}}[/white] [dim]{short_build} {created}[/dim]"
)
parts = []
if build_id:
parts.append(f"build {build_id}")
parts.append(f"created {created}")
console.print(f" [dim]{' · '.join(parts)}[/dim]")
else:
console.print(" [dim]None yet — run [/dim][bold]flash deploy[/bold]")
console.print(" [dim]no environments[/dim]")

# builds — show most recent, summarize the rest
max_shown = 5
console.print(f"\n [bold]Builds ({len(builds)})[/bold]")
if builds:
recent = builds[:max_shown]
for build in recent:
console.print(f"\n [dim]{_plural(len(builds), 'build')}[/dim]")
for build in builds[:3]:
build_id = build.get("id", "")
short_id = build_id[:12] if len(build_id) > 12 else build_id
created = format_datetime(build.get("createdAt"))
console.print(f" {build_id} [dim]{created}[/dim]")
if len(builds) > max_shown:
console.print(
f" [dim]… and {len(builds) - max_shown} older builds[/dim]"
)
else:
console.print(" [dim]None yet — run [/dim][bold]flash build[/bold]")
console.print(f" [dim]{short_id} {created}[/dim]")
if len(builds) > 3:
console.print(f" [dim]+ {len(builds) - 3} more[/dim]")

console.print()


async def delete_flash_app(app_name: str):
with console.status(f"Deleting flash app: {app_name}"):
with console.status("[dim]deleting...[/dim]"):
success = await FlashApp.delete(app_name=app_name)

if success:
console.print(f"[green][/green] Deleted app [bold]{app_name}[/bold]")
console.print(f"[green]\u2713[/green] deleted app [bold]{app_name}[/bold]")
else:
print_error(console, f"Failed to delete app '{app_name}'")
print_error(console, f"failed to delete app '{app_name}'")
raise typer.Exit(1)


Expand Down
26 changes: 17 additions & 9 deletions src/runpod_flash/cli/commands/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ def _bundle_runpod_flash(build_dir: Path, flash_pkg: Path) -> None:
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".pytest_cache"),
)

console.print(f"[cyan]Bundled runpod_flash from {flash_pkg}[/cyan]")
logger.debug("bundled runpod_flash from %s", flash_pkg)


def _extract_runpod_flash_dependencies(flash_pkg_dir: Path) -> list[str]:
Expand Down Expand Up @@ -409,9 +409,9 @@ def run_build(
requirements = filtered_requirements

if auto_matched:
console.print(
f"[dim]Auto-excluded size-prohibitive packages: "
f"{', '.join(sorted(auto_matched))}[/dim]"
logger.debug(
"auto-excluded size-prohibitive packages: %s",
", ".join(sorted(auto_matched)),
)

# Only warn about unmatched user-specified packages (not auto-excludes)
Expand All @@ -423,7 +423,10 @@ def run_build(
)

if requirements:
with console.status(f"Installing {len(requirements)} packages..."):
import time as _time

t0 = _time.monotonic()
with console.status("[dim]installing dependencies...[/dim]"):
success = install_dependencies(
build_dir,
requirements,
Expand All @@ -434,6 +437,10 @@ def run_build(
if not success:
print_error(console, "Failed to install dependencies")
raise typer.Exit(1)
console.print(
f"[green]\u2713[/green] installed {len(requirements)} packages "
f"[dim]{_time.monotonic() - t0:.1f}s[/dim]"
)

# Always bundle the installed runpod_flash
flash_pkg = _find_runpod_flash(project_dir)
Expand All @@ -460,7 +467,7 @@ def run_build(
archive_name = output_name or "artifact.tar.gz"
archive_path = project_dir / ".flash" / archive_name

with console.status("Creating archive..."):
with console.status("[dim]creating archive...[/dim]"):
create_tarball(
build_dir, archive_path, app_name, excluded_packages=excluded_packages
)
Expand Down Expand Up @@ -928,8 +935,9 @@ def install_dependencies(
local_version = f"{sys.version_info.major}.{sys.version_info.minor}"
pip_python_version = target_python_version or local_version
if target_python_version and target_python_version != local_version:
console.print(
f"[dim]Downloading wheels for Python {target_python_version} (container runtime)[/dim]"
logger.debug(
"downloading wheels for python %s (container runtime)",
target_python_version,
)

# Build pip command with platform-specific flags for RunPod serverless
Expand Down Expand Up @@ -1086,7 +1094,7 @@ def _display_build_summary(
):
"""Display build summary."""
console.print(
f"[green]Built[/green] [bold]{app_name}[/bold] "
f"[green]\u2713[/green] built {app_name} "
f"[dim]{file_count} files, {dep_count} deps, {size_mb:.1f} MB[/dim]"
)
if verbose:
Expand Down
28 changes: 9 additions & 19 deletions src/runpod_flash/cli/commands/build_utils/handler_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,13 @@


def handler(job):
"""Handler for deployed QB endpoint. Accepts plain JSON kwargs; rejects empty or null input."""
"""Handler for deployed QB endpoint. Accepts plain JSON kwargs."""
raw_input = job.get("input")
if raw_input is None or (isinstance(raw_input, dict) and not raw_input):
return {{
"success": False,
"error": (
"Empty or null input. RunPod serverless requires at least one "
"field in the input dict. Use explicit values or pass null for "
'optional parameters, e.g. {{\\"input\\": {{\\"param_name\\": null}}}}.'
),
}}
if raw_input is None:
raw_input = {{}}
if not isinstance(raw_input, dict):
return {{"success": False, "error": f"Malformed input: expected dict, got {{type(raw_input).__name__}}"}}
raw_input.pop("__empty", None)
job_input = raw_input
try:
result = {function_name}(**job_input)
Expand Down Expand Up @@ -98,6 +92,7 @@ def handler(job):
async def handler(job):
"""Async handler for concurrent QB endpoint. Accepts plain JSON kwargs."""
job_input = job.get("input", {{}})
job_input.pop("__empty", None)
try:
result = await {function_name}(**job_input)
return result
Expand Down Expand Up @@ -170,17 +165,11 @@ def handler(job):
include a "method" key to select the target.
"""
raw_input = job.get("input")
if raw_input is None or (isinstance(raw_input, dict) and not raw_input):
return {{
"success": False,
"error": (
"Empty or null input. RunPod serverless requires at least one "
"field in the input dict. Use explicit values or pass null for "
'optional parameters, e.g. {{\\"input\\": {{\\"param_name\\": null}}}}.'
),
}}
if raw_input is None:
raw_input = {{}}
if not isinstance(raw_input, dict):
return {{"success": False, "error": f"Malformed input: expected dict, got {{type(raw_input).__name__}}"}}
raw_input.pop("__empty", None)
job_input = raw_input
try:
if len(_METHODS) == 1:
Expand Down Expand Up @@ -259,6 +248,7 @@ async def handler(job):
include a "method" key to select the target.
"""
job_input = job.get("input", {{}})
job_input.pop("__empty", None)
try:
if len(_METHODS) == 1:
method_name = next(iter(_METHODS))
Expand Down
10 changes: 10 additions & 0 deletions src/runpod_flash/cli/commands/build_utils/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,16 @@ def build(self) -> Dict[str, Any]:
resources[func.resource_config_name] = []
resources[func.resource_config_name].append(func)

# detect the same resource name defined in multiple files
for name, funcs in resources.items():
files = {str(f.file_path) for f in funcs}
if len(files) > 1:
paths = ", ".join(sorted(files))
raise ValueError(
f"endpoint '{name}' is defined in multiple files: {paths}. "
f"each endpoint name must be unique across the project."
)

# Build manifest structure
resources_dict: Dict[str, Dict[str, Any]] = {}
function_registry: Dict[str, str] = {}
Expand Down
Loading
Loading