Skip to content
Open
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
3 changes: 3 additions & 0 deletions comfy_cli/command/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -1934,6 +1934,9 @@ def validate_cmd(

from comfy_cli.command import workflow_edit as _wedit # noqa: E402

app.command("insert-workflow", help="Insert a complete workflow; emits one insert_workflow op.")(
Comment thread
christian-byrne marked this conversation as resolved.
_wedit.insert_workflow_cmd
)
app.command("add-node", help="Add a node to the graph; emits an add_node op.")(_wedit.add_node_cmd)
app.command("connect", help="Wire an output slot to an input slot; emits a connect op.")(_wedit.connect_cmd)
app.command("set-widget", help="Set a widget by name (`<id>.<widget>`); emits a set_widget op.")(_wedit.set_widget_cmd)
Expand Down
44 changes: 43 additions & 1 deletion comfy_cli/command/workflow_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,14 @@ def _finish(renderer, p, workflow: dict, op: dict, base_version: int, stdout: bo
renderer.emit(payload, command=command, changed=not stdout)


def _emit_op(renderer, p: Path, op: dict, base_version: int, command: str) -> None:
"""Emit an op without applying it or writing the source workflow."""
payload = {"workflow": str(p), "op": op, "base_version": base_version, "wrote": None}
if renderer.is_pretty():
rprint(f"[bold green]✓[/bold green] {op['op']} emitted for [dim]{p}[/dim]")
renderer.emit(payload, command=command, changed=False)
Comment thread
christian-byrne marked this conversation as resolved.


def _graph_or_exit(input_path, host, port, renderer, where=None):
return _get_graph(input_path, host, port, where=where)

Expand All @@ -137,6 +145,34 @@ def _graph_or_exit(input_path, host, port, renderer, where=None):
# ---------------------------------------------------------------------------


@tracking.track_command("workflow")
def insert_workflow_cmd(
file: Annotated[str, typer.Argument(help="Source frontend-format workflow JSON; emit-only, file is not modified.")],
template: Annotated[str, typer.Argument(help="Frontend-format workflow JSON to insert, or '-' for stdin.")],
actor: ActorOpt = "cli",
base_version: BaseVersionOpt = 0,
):
"""Insert a workflow template and emit one atomic ``insert_workflow`` op."""
renderer = get_renderer()
renderer.command = "workflow insert-workflow"
p, workflow = _load_workflow_or_fail(renderer, file)
try:
if template == "-":
import sys

raw = sys.stdin.read()
else:
raw = Path(template).expanduser().read_text(encoding="utf-8")
inserted = json.loads(raw)
if not isinstance(inserted, dict):
Comment thread
christian-byrne marked this conversation as resolved.
raise ValueError("template must be a JSON object")
_, op = workflow_ops.insert_workflow(workflow, inserted, actor=actor, base_version=base_version)
except (OSError, json.JSONDecodeError, UnicodeDecodeError, ValueError) as e:
_emit_edit_error(renderer, e, hint="provide a frontend-format workflow template JSON file")
raise typer.Exit(code=1) from e
_emit_op(renderer, p, op, base_version, "workflow insert-workflow")


@tracking.track_command("workflow")
def add_node_cmd(
file: Annotated[str, typer.Argument(help="Frontend-format workflow JSON.")],
Expand Down Expand Up @@ -760,7 +796,13 @@ def apply_cmd(
except workflow_ops.NotBatchableError as e:
# A standalone-only op (clear) inside the batch: its own registered code,
# with the hint naming the standalone command to run instead.
renderer.error(code=e.code, message=f"batch failed: {e}", hint=e.hint)
details = None
if ack == "summary":
details = {
"failed": {"index": e.spec_index, "op": e.spec_op, "code": e.code},
"applied_count": e.applied_count,
}
renderer.error(code=e.code, message=f"batch failed: {e}", hint=e.hint, details=details)
raise typer.Exit(code=1) from e
except workflow_ops.DeprecatedNodeType as e:
renderer.error(
Expand Down
1 change: 1 addition & 0 deletions comfy_cli/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@
"comfy workflow notes": "workflow",
"comfy workflow print": "workflow",
# structured edit primitives + recipes (CRDT op-based authoring)
"comfy workflow insert-workflow": "workflow",
"comfy workflow add-node": "workflow",
"comfy workflow connect": "workflow",
"comfy workflow set-widget": "workflow",
Expand Down
6 changes: 6 additions & 0 deletions comfy_cli/error_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,12 @@ class ErrorCode:
"(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_insert_workflow_not_batchable",
"A batch contained an `insert_workflow` op. A complete workflow insertion is one standalone atomic op, "
"so nesting it in the spec batch protocol is rejected and nothing is applied.",
"run `comfy workflow insert-workflow <file> <template>` instead",
),
ErrorCode(
"workflow_reset_doc_unconfirmed",
"`comfy workflow reset-doc` was called without `--confirm`. The command fails closed: it erases every "
Expand Down
21 changes: 13 additions & 8 deletions comfy_cli/skills/comfy/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,14 +352,17 @@ of a live graph, use the structured-edit primitives below — never raw `jq`/`se
(This rule exists because that exact jq-on-`id==128` hand-edit is the anti-pattern
`decompose` — and these primitives — were built to kill.)

## Structured graph edits — `add-node` / `connect` / `set-widget` / `delete-node`
## Structured graph edits — `insert-workflow` / `add-node` / `connect` / `set-widget` / `delete-node`
Comment thread
christian-byrne marked this conversation as resolved.

The **sanctioned** way to mutate a graph's *structure* from code — the
alternative to `jq`/`sed` on `nodes`/`links`/`widgets_values`. Each edit is
validated against `object_info` (node class, widget name, widget value **shape**,
and connection **type** are hard-checked; unknown COMBO values / out-of-range
numbers come back as soft `warnings`) and emits a replayable **operation** in
`data.op`.
alternative to `jq`/`sed` on `nodes`/`links`/`widgets_values`. `insert-workflow`
is emit-only: the CLI checks JSON shape and forwards the workflow without
semantic validation or id remapping. The cmp applier on the server validates node
types, links, and definition ids, remaps ids, and returns errors that the CLI
surfaces verbatim. The other edits are validated against `object_info` (node
class, widget name, widget value **shape**, and connection **type** are
hard-checked; unknown COMBO values / out-of-range numbers come back as soft
`warnings`) and emit a replayable **operation** in `data.op`.

**When to use which editing path:**
- **Reusable / human-authored workflow** → fragments + blueprint (above). *Default.*
Expand All @@ -368,8 +371,8 @@ numbers come back as soft `warnings`) and emits a replayable **operation** in
nodes; the in-app agent's path; any edit that must merge with a concurrent
human editor) → the primitives here.

> **Live co-editing / CRDT:** only the structured-edit primitives (`add-node`/
> `connect`/`set-widget`/`delete-node`/`apply`) emit a mergeable **op** in
> **Live co-editing / CRDT:** only the structured-edit primitives (`insert-workflow`/
> `add-node`/`connect`/`set-widget`/`delete-node`/`apply`) emit a mergeable **op** in
> `data.op`/`data.ops` (`op_id` + `actor` + `base_version` + `stamp`). Fragments +
> `compose` produce a **whole-document** graph — fine for authoring a *fresh*
> draft (the base), but it does **not** emit ops and will clobber a concurrent
Expand All @@ -385,6 +388,8 @@ CAT="--where cloud"
# Start from an existing graph, or an empty one:
echo '{"nodes":[],"links":[],"last_node_id":0,"last_link_id":0}' > wf.json

comfy --json workflow insert-workflow wf.json template.json # emits one atomic insert_workflow op
cat template.json | comfy --json workflow insert-workflow wf.json - # '-' reads the template from stdin
comfy --json workflow add-node wf.json KSampler --at 400,200 $CAT # → data.op.node_id (minted)
comfy --json workflow connect wf.json 7.LATENT 3.samples $CAT # source out-slot → target in-slot
comfy --json workflow set-widget wf.json 3.steps 35 $CAT # widget by NAME; op carries {old,value}
Expand Down
55 changes: 49 additions & 6 deletions comfy_cli/workflow_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,20 @@ def _new_op(kind: str, actor: str, base_version: int, **fields: Any) -> dict[str
# ---------------------------------------------------------------------------

#: Every op kind in the v1 vocabulary, including defined-but-deferred kinds.
FROZEN_OPS: tuple[str, ...] = ("add_node", "connect", "set_widget", "delete_node", "clear", "reset_doc")

#: Kinds frozen in the contract whose replay is not implemented yet.
#: ``apply_op`` must keep rejecting these. Empty since amendment v1.1:
#: ``reset_doc`` was un-deferred by the bulk-writers ticket (V1-038).
DEFERRED_OPS: tuple[str, ...] = ()
FROZEN_OPS: tuple[str, ...] = (
"add_node",
"connect",
"set_widget",
"delete_node",
"clear",
"reset_doc",
"insert_workflow",
)

#: Kinds frozen in the contract whose replay is not implemented in the CLI.
#: ``insert_workflow`` is emitted for cmp to validate and apply; the CLI must
#: keep rejecting local replay to preserve that ownership boundary.
DEFERRED_OPS: tuple[str, ...] = ("insert_workflow",)

#: Kinds a batch (``apply_specs``) dispatches. ``clear`` and ``reset_doc`` are
#: standalone-only: they rewrite the whole document, so they never ride inside
Expand All @@ -117,6 +125,11 @@ def _new_op(kind: str, actor: str, base_version: int, **fields: Any) -> dict[str
"command": "comfy workflow reset-doc <file> --confirm",
"does": "resets the whole document to the empty baseline and erases its replay history",
},
"insert_workflow": {
"code": "workflow_insert_workflow_not_batchable",
"command": "comfy workflow insert-workflow <file> <template>",
"does": "inserts a complete workflow in one transaction",
},
}


Expand All @@ -141,6 +154,9 @@ def __init__(self, index: int, kind: str = "clear"):
command = entry["command"]
self.code = entry["code"]
self.kind = kind
self.spec_index = index
self.spec_op = kind
self.applied_count = 0
self.hint = f"run the standalone `{command}` first, then apply the remaining ops as a batch"
super().__init__(
f"spec #{index}: `{kind}` {entry['does']} and is standalone-only (op-vocabulary-v1: "
Expand Down Expand Up @@ -1295,6 +1311,31 @@ def replace_ops(old: dict, new: dict, *, actor: str = "cli", base_version: int =
return ops


def insert_workflow(
workflow: dict,
template: dict,
*,
actor: str = "cli",
base_version: int = 0,
) -> tuple[dict, dict]:
"""Structurally validate and emit an insert op without applying it.

The CLI deliberately preserves all source IDs. Per the vetoable contract
decision recorded in the TDD, cmp owns deterministic ID remapping from the
op envelope ID when it applies this payload.
"""
if not isinstance(template, dict):
raise ValueError("insert_workflow workflow must be a JSON object")
if "nodes" not in template:
raise ValueError("insert_workflow missing required field: nodes")
for field in ("nodes", "links", "groups"):
Comment thread
christian-byrne marked this conversation as resolved.
if field in template and not isinstance(template[field], list):
raise ValueError(f"insert_workflow field {field} must be an array")
if "definitions" in template and not isinstance(template["definitions"], dict):
raise ValueError("insert_workflow field definitions must be an object")
return workflow, _new_op("insert_workflow", actor, base_version, workflow=copy.deepcopy(template))
Comment thread
christian-byrne marked this conversation as resolved.


def delete_node(
workflow: dict,
graph,
Expand Down Expand Up @@ -1795,6 +1836,8 @@ def apply_op(workflow: dict, op: dict, graph) -> dict:
if op["op_id"] in applied:
return workflow
kind = op["op"]
if kind != "insert_workflow" and "definitions" in op:
raise ValueError(f"malformed_op: {kind} does not accept definitions")
# Snapshot the LWW bookkeeping so an exception escaping a handler cannot
# leave a stamp committed WITHOUT its op_id recorded below. That pairing is
# the poison state: a retry of the identical op loses to the failed
Expand Down
30 changes: 29 additions & 1 deletion docs/op-vocabulary-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ citation must point at a commit on that branch.

## 1. Frozen op kinds

Six kinds. No other kind is valid in v1: `apply_op` rejects an unknown kind with
Seven kinds. No other kind is valid in v1: `apply_op` rejects an unknown kind with
`ValueError("unknown op ...")` — it never ignores one.

| Kind | Batchable | Standalone command | Summary |
Expand All @@ -28,6 +28,7 @@ Six kinds. No other kind is valid in v1: `apply_op` rejects an unknown kind with
| `delete_node` | yes | `comfy workflow delete` | Remove one node and its incident links |
| `clear` | no | `comfy workflow clear` | Remove every node, link, and group |
| `reset_doc` | no | `comfy workflow reset-doc --confirm` | Reset the whole document to an empty baseline |
| `insert_workflow` | no | `comfy workflow insert-workflow` | Merge a complete workflow template in one transaction |

Batchable = the kind is accepted by `apply_specs` (the `workflow apply` /
`workflow foreach` batch surface). `clear` and `reset_doc` rewrite the whole
Expand Down Expand Up @@ -178,6 +179,33 @@ pre-reset `base_version` do not replay across it.
* Never emitted implicitly: no `--emit-ops` surface and no bulk writer (§8.8)
mints one. It exists only where a caller asked for it by name.

### 1.7 `insert_workflow` — standalone only

Command: `comfy workflow insert-workflow <file> <template>`, where `<template>`
may be `-` to read the template payload from stdin. The CLI preserves the
template payload verbatim and emits exactly one stamped op:

```json
{
"op": "insert_workflow",
"op_id": "<uuid4 hex>",
"actor": "cli",
"base_version": 0,
"stamp": [0, "cli"],
"workflow": {"nodes": [], "links": [], "groups": [], "definitions": {"subgraphs": []}}
}
```

The `workflow` payload is authoritative and requires a top-level `nodes` array;
`links`, `groups`, and `definitions` are optional. When present, `links` and
`groups` must be arrays and `definitions` must be an object. The CLI checks only
this outer shape. It does not validate graph semantics, remap IDs, apply the op,
or write a mutated workflow document. Per the contract decision recorded in the TDD
(vetoable), cmp owns deterministic ID remapping from the op envelope ID. Only `define_subgraph` and
`insert_workflow` ops may carry definitions; edit ops reject a `definitions`
field as `malformed_op`. The kind is not batchable and a spec batch rejects it as
`workflow_insert_workflow_not_batchable`.

## 2. Idempotency and identity

* Every op carries `op_id`: uuid4 hex, minted by the **creator, before
Expand Down
26 changes: 26 additions & 0 deletions tests/comfy_cli/command/test_ack_flag.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,32 @@ def run_once(extra: list[str]) -> str:


class TestAckSummaryPartialFailure:
def test_ack_summary_not_batchable_reports_index_and_code(self, patched_graph, tmp_path, capsys):
path = _empty(tmp_path)
ops_path = _ops_file(tmp_path, [{"op": "insert_workflow", "workflow": {}}])

env = _run(["apply", str(path), "--ops", str(ops_path), "--ack", "summary"], capsys)

assert env["ok"] is False
assert env["error"]["code"] == "workflow_insert_workflow_not_batchable"
assert env["error"]["details"] == {
"failed": {
"index": 0,
"op": "insert_workflow",
"code": "workflow_insert_workflow_not_batchable",
},
"applied_count": 0,
}

def test_full_mode_not_batchable_failure_envelope_unchanged(self, patched_graph, tmp_path, capsys):
path = _empty(tmp_path)
ops_path = _ops_file(tmp_path, [{"op": "insert_workflow", "workflow": {}}])

env = _run(["apply", str(path), "--ops", str(ops_path)], capsys)

assert env["error"]["code"] == "workflow_insert_workflow_not_batchable"
assert env["error"]["details"] is None

def test_ack_summary_partial_failure_reports_index_and_code(self, patched_graph, tmp_path, capsys):
"""Op 2 of 3 (0-based index 1) fails → same code/atomicity as full
mode, plus a structured receipt: failed.{index,op,code} + applied_count.
Expand Down
Loading
Loading