Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
41 changes: 40 additions & 1 deletion comfy_cli/command/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

import typer

from comfy_cli import tracking
from comfy_cli import tracking, workflow_ops
from comfy_cli.file_utils import atomic_write_bytes
from comfy_cli.http import ResponseTooLarge, plain_urlopen, read_capped
from comfy_cli.output import get_renderer, rprint
Expand Down Expand Up @@ -633,6 +633,22 @@ def fetch_cmd(
bool,
typer.Option("--refresh", help="Re-fetch the gallery index from GitHub before resolving."),
] = False,
emit_ops: Annotated[
bool,
typer.Option(
"--emit-ops",
help=(
"Also emit `ops`: the stamped op batch that turns the file being replaced INTO this "
"template (delete_node + add_node + connect, frozen vocabulary). Replays through a merge "
"consumer AND is a legal `comfy workflow apply --ops` batch. Omitted, with `ops_skipped` "
"saying why, for templates the vocabulary cannot express (subgraphs, groups)."
),
),
] = False,
actor: Annotated[str, typer.Option("--actor", help="Op author id for --emit-ops (CRDT stamping).")] = "cli",
base_version: Annotated[
int, typer.Option("--base-version", help="Draft version the emitted ops are stamped against.")
] = 0,
):
renderer = get_renderer()

Expand Down Expand Up @@ -698,6 +714,18 @@ def fetch_cmd(
)
raise typer.Exit(code=1) from e

# The graph this fetch is REPLACING, read before the write clobbers it —
# `--emit-ops` needs it to emit the delete_node half of the batch. Only read
# when asked: an unparseable file at the target is not an error for a plain
# fetch (it is about to be overwritten), so it must not become one here.
previous: dict[str, Any] = {}
if emit_ops and out:
try:
loaded = json.loads(Path(out).expanduser().read_text(encoding="utf-8"))
previous = loaded if isinstance(loaded, dict) else {}
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
previous = {}

if out:
out_path = Path(out).expanduser()
out_path.parent.mkdir(parents=True, exist_ok=True)
Expand Down Expand Up @@ -727,6 +755,17 @@ def fetch_cmd(
# can get the workflow — emit() owns stdout in JSON mode, so without this
# the fetch would produce nothing but metadata.
payload["workflow"] = wf
if emit_ops:
# A bulk writer that emits ops stops being a whole-document replacement:
# the consumer folds the batch into the document it already has, so the
# replaced canvas keeps ONE identity and an attributed history instead of
# being re-seeded (op-vocabulary-v1 §8.6). Failure is NOT fatal — the
# fetch itself succeeded and the file is written; the consumer falls back
# to whatever it did before ops existed, and `ops_skipped` says why.
try:
payload["ops"] = workflow_ops.replace_ops(previous, wf, actor=actor, base_version=base_version)
except workflow_ops.NotExpressibleError as e:
payload["ops_skipped"] = str(e)
if renderer.is_pretty() and out:
rprint(f"[green]✓[/green] wrote {len(body):,} bytes ({payload['node_count']} nodes) to {target_repr}")
renderer.emit(payload, command="templates fetch")
Expand Down
4 changes: 4 additions & 0 deletions comfy_cli/command/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -1483,6 +1483,10 @@ def delete_cmd(
app.command("set-widget", help="Set a widget by name (`<id>.<widget>`); emits a set_widget op.")(_wedit.set_widget_cmd)
app.command("delete-node", help="Delete a node and its links; emits a delete_node op.")(_wedit.delete_cmd)
app.command("clear", help="Remove every node, link, and group; emits one clear op.")(_wedit.clear_cmd)
app.command(
"reset-doc",
help="Reset the document to the empty baseline — nodes, ids AND replay history. Requires --confirm.",
)(_wedit.reset_doc_cmd)
app.command("ls-nodes", help="List nodes (id/type/title) in a workflow file.")(_wedit.ls_nodes_cmd)
app.command("apply", help="Apply a recipe / batch of edits in one pass; supports node aliases + --param.")(
_wedit.apply_cmd
Expand Down
48 changes: 48 additions & 0 deletions comfy_cli/command/workflow_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,54 @@ def clear_cmd(
_finish(renderer, p, workflow, op, base_version, stdout, "workflow clear")


# ---------------------------------------------------------------------------
# reset-doc — the guarded document reset (op-vocabulary-v1 §1.6)
# ---------------------------------------------------------------------------


@tracking.track_command("workflow")
def reset_doc_cmd(
file: Annotated[str, typer.Argument(help="Frontend-format workflow JSON.")],
confirm: Annotated[
bool,
typer.Option(
"--confirm",
help="REQUIRED. Without it the command fails closed and writes nothing.",
),
] = False,
actor: ActorOpt = "cli",
base_version: BaseVersionOpt = 0,
stdout: StdoutOpt = False,
where: WhereOpt = None, # accepted for caller uniformity; reset needs no catalog
):
"""Reset the document to the empty baseline — nodes, links, groups, ids AND
the applied-op history.

Guarded, unlike every other edit command, because it is the only one whose
effect no later op can undo: it is a history barrier, so ops minted against
a pre-reset base_version do not replay across it. The check runs BEFORE the
file is read, so an unconfirmed call cannot even fail halfway.
"""
renderer = get_renderer()
renderer.command = "workflow reset-doc"
if not confirm:
renderer.error(
code="workflow_reset_doc_unconfirmed",
message=(
"`workflow reset-doc` erases every node AND the document's replay history; "
"it requires an explicit --confirm. Nothing was written."
),
hint=(
"re-run with --confirm if that is really what you want — otherwise "
"`comfy workflow clear <file>` empties the graph while keeping the document's history"
),
)
raise typer.Exit(code=1)
p, workflow = _load_workflow_or_fail(renderer, file)
workflow, op = workflow_ops.reset_doc(workflow, actor=actor, base_version=base_version)
_finish(renderer, p, workflow, op, base_version, stdout, "workflow reset-doc")


# ---------------------------------------------------------------------------
# Litegraph node modes worth surfacing on ls-nodes. 0 (always) and 1 (on-event)
# are normal execution and are deliberately unlabeled. Mirrors workflow_to_api's
Expand Down
14 changes: 14 additions & 0 deletions comfy_cli/error_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,20 @@ class ErrorCode:
"rejected atomically — nothing was applied.",
"run the standalone `comfy workflow clear <file>` first, then apply the remaining ops as a batch",
),
ErrorCode(
"workflow_reset_doc_not_batchable",
"A batch (`workflow apply` / `workflow foreach`) contained a `reset_doc` op. `reset_doc` resets the "
"whole document to the empty baseline and erases its replay history, so it is standalone-only "
"(docs/op-vocabulary-v1.md: batchable = no) and the batch was rejected atomically — nothing was applied.",
"run the standalone `comfy workflow reset-doc <file> --confirm` first, then apply the remaining ops as a batch",
),
ErrorCode(
"workflow_reset_doc_unconfirmed",
"`comfy workflow reset-doc` was called without `--confirm`. The command fails closed: it erases every "
"node AND the document's replay history, which no later op can undo.",
"re-run with `--confirm` if that is really what you want — otherwise `comfy workflow clear <file>` "
"empties the graph while keeping the document's history",
),
ErrorCode(
"normalized_value",
"Warning (not fatal): a set-widget value wasn't an exact COMBO option, so "
Expand Down
Loading
Loading