- Durable Job Inbox: every async submit is journaled to the shared sqlite store and survives ComfyUI restarts; the Fal Job Inbox node lists pending/collected jobs and outputs the newest pending request_id/endpoint for one-wire recovery via Result by Request ID. - Provenance: Fal Save Media from URL writes a <file>.fal.json sidecar and embeds a fal_provenance PNG text chunk (preserving existing text chunks and ICC profiles), returning the request_id as a second output; new Fal Provenance from File node reads saved files back into endpoint_id + request_id for free re-materialization. - First web extension (web/ + /fal_api server routes): per-node cost pills rendered above every priced fal node (live estimates on free-typed endpoint fields), a fal sidebar tab with session spend, account balance and a job list with copy/cancel, and in-canvas endpoint search with price labels. All features degrade silently on older frontends; routes are exception-guarded and cached. Review fixes: PNG provenance embed preserves source text chunks and ICC profile; jobs route limit clamped. 15 new tests (91 total).
98 lines
3.5 KiB
Python
98 lines
3.5 KiB
Python
"""Fal Job Inbox: list async fal jobs recorded across ComfyUI sessions."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from .fal_utils import JobStore, logger
|
|
|
|
_CATEGORY = "FAL/Platform"
|
|
|
|
_STATUS_CHOICES = ("all", "submitted", "collected")
|
|
|
|
|
|
class FalJobInbox:
|
|
"""List async fal jobs from the persistent store; jobs survive restarts."""
|
|
|
|
RETURN_TYPES = ("STRING", "STRING", "STRING")
|
|
RETURN_NAMES = ("report", "latest_request_id", "latest_endpoint")
|
|
FUNCTION = "inbox"
|
|
CATEGORY = _CATEGORY
|
|
OUTPUT_NODE = True
|
|
DESCRIPTION = (
|
|
"Inbox of async fal jobs recorded by Fal Submit. The store is "
|
|
"persistent, so jobs queued in a previous session survive a ComfyUI "
|
|
"restart: submit tonight, restart, then wire latest_request_id and "
|
|
"latest_endpoint into Fal Result by Request ID to collect tomorrow "
|
|
"without re-paying. Never fails the graph."
|
|
)
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls) -> dict[str, Any]:
|
|
return {
|
|
"required": {},
|
|
"optional": {
|
|
"status_filter": (
|
|
list(_STATUS_CHOICES),
|
|
{
|
|
"default": "all",
|
|
"tooltip": (
|
|
"Which jobs to list: 'submitted' shows jobs still "
|
|
"waiting to be collected (including ones queued "
|
|
"before a restart), 'collected' shows finished ones."
|
|
),
|
|
},
|
|
),
|
|
"limit": (
|
|
"INT",
|
|
{
|
|
"default": 20,
|
|
"min": 1,
|
|
"max": 200,
|
|
"tooltip": "Maximum number of jobs to list, newest first",
|
|
},
|
|
),
|
|
},
|
|
}
|
|
|
|
@classmethod
|
|
def IS_CHANGED(cls, **kwargs: Any) -> Any:
|
|
# The job store mutates outside the graph; always re-run.
|
|
return float("nan")
|
|
|
|
@staticmethod
|
|
def _filtered_report(store: JobStore, status: str, limit: int) -> str:
|
|
"""Plain listing of jobs with one status (report() covers 'all')."""
|
|
entries = store.entries(limit=limit, status=status)
|
|
lines = [f"Fal job inbox ({status}): {len(entries)} shown, newest first"]
|
|
lines.extend(
|
|
f" {entry.get('endpoint') or '(unknown endpoint)'} "
|
|
f"req={entry.get('request_id') or '-'}"
|
|
for entry in entries
|
|
)
|
|
return "\n".join(lines)
|
|
|
|
def inbox(self, status_filter: str = "all", limit: int = 20) -> tuple[str, str, str]:
|
|
try:
|
|
store = JobStore()
|
|
if status_filter in _STATUS_CHOICES[1:]:
|
|
report = self._filtered_report(store, status_filter, int(limit))
|
|
else:
|
|
report = store.report(limit=int(limit))
|
|
latest = next(iter(store.pending(limit=1)), None)
|
|
latest_request_id = str(latest.get("request_id") or "") if latest else ""
|
|
latest_endpoint = str(latest.get("endpoint") or "") if latest else ""
|
|
return (report, latest_request_id, latest_endpoint)
|
|
except Exception as exc: # This node must never fail the graph.
|
|
logger.warning("FalJobInbox: could not read job store: %s", exc)
|
|
return ("Fal job inbox: report unavailable", "", "")
|
|
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"FalJobInbox_fal": FalJobInbox,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"FalJobInbox_fal": "Fal Job Inbox (fal)",
|
|
}
|