Files
gokayfem-ComfyUI-fal-API/nodes/inbox_node.py
T
Gökay Aydoğan d6597fb81e feat: v2.3.0 — durable job inbox, provenance, in-canvas cost UI (#77)
- 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).
2026-07-02 15:19:54 +03:00

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)",
}