Skip to content
Draft
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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,27 @@ notes for each version.

### Added

- `Job.get_logs()` / `AsyncJob.get_logs()` fetch a job's execution log — what
the run printed — returning a `JobLogs` (`text`, `truncated`, `captured_at`,
`complete`) or `None`. Fetched on demand and never cached: submitting and
polling a job downloads no log, and each call re-reads rather than replaying
the first, so polling for a log that has not landed yet works. `None` is an
ordinary answer covering every reason there is nothing to read — the
deployment captures no logs at all (Comfy Cloud and self-hosted never do;
only serverless deployments have them), the job has not finished, it predates
capture, the run was killed before the worker could report an outcome (an
out-of-memory kill, a crashed worker, a timeout, a job past its maximum
runtime), capture failed, or the job ran on the public demo deployment,
which captures a log but withholds it from anonymous callers — and the
contract deliberately does not distinguish them. Do not branch on which; a
job that has not finished may have a log once it has, so read again after a
terminal status. On a surface that offers a logs link a missing job still
raises `NotFound`; where there is no link there is no request, so that job
raises nothing and returns `None` too.
- `Output.node_id` is documented as possibly empty — the workflow node that
reported a file, or `""` when the worker named none. `Job.get_outputs()`
filters on this value, so an output the worker named no node for is not
reachable by any real node id.
- Every exception `client.models.run()` raises **for a failed call** now
carries the `Idempotency-Key` it was made under, on `.idempotency_key` — the
typed `RouterError` buckets, a `RouterError` whose `error_type` this version
Expand Down
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,57 @@ controls — jobs submitted through this SDK always get `"api"` today, since v2
submission has no version-pinning fields yet. (`AsyncJob.get_workflow()`
mirrors this with `await`.)

## Reading what a run printed

`get_logs()` fetches the job's execution log. It is a resource of its own, not
a field on the job, so running and polling a job never downloads it — you pay
for a log only when you ask for one:

```python
# submit() + wait(), not run(): run() raises JobFailed on a failed job and the
# exception carries no handle, so there would be nothing left to ask for a log
# — which is exactly when you want one.
job = client.submit(wf)
job.wait()

logs = job.get_logs()
if logs is not None:
print(logs.text)
```

`None` is an ordinary answer, not an error, and the reasons are deliberately
not distinguished: the deployment does not capture logs at all (Comfy Cloud
and self-hosted never do — only serverless deployments have them), the job has
not finished, it predates log capture, the run was killed before the worker
could report an outcome (an out-of-memory kill, a crashed worker, a timeout, a
job past its maximum runtime), capture failed, or the job ran on the
public demo deployment, which captures a log but withholds it from the
anonymous callers that surface accepts.

The killed-run case is a known gap rather than an oversight: a log is read
back off the worker, so a run the platform killed never produced one — the
failures you most want a log for are the ones least likely to have left one.

Do not branch on which one it is — a `None` never says. But one of them
resolves itself: a job that has not finished may have a log once it does, so
call again after a terminal status. `None` on a job that has already finished
is final, and so is `None` from a deployment that offers no log link at all.

Nothing is cached — each call re-reads, so that retry works. On a log that did
land:

- `text` is untrusted output. A workflow chooses what goes in it, so render it
as plain text rather than interpreting it.
- `truncated` means the *beginning* was dropped and you have the tail, which is
where a failure normally is. True with an empty `text` means the log was
captured and then shed entirely to fit — which is not the same as never
having had one.
- `complete` means nothing more will be appended. Always true today, since a
log is read back off the worker once, when the run ends.

Live log streaming is not available yet. (`AsyncJob.get_logs()` mirrors this
with `await`.)

## Downloading outputs

A finished job exposes its results as `Output` handles — `job.outputs`, or
Expand Down
159 changes: 158 additions & 1 deletion spec/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,134 @@ paths:
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/UpstreamError'
/api/v2/jobs/{id}/logs:
get:
operationId: getJobLogs
tags:
- jobs
summary: What the run printed
description: 'Returns the job''s captured execution log. Fetched on demand: a log

is a debugging artifact a caller wants occasionally, while

`GET /api/v2/jobs/{id}` is polled to terminal on every run, so the

log is a resource of its own rather than a field that would ride

every one of those polls to be read at most once.


Captured whenever the worker reports its own outcome, success and

failure alike, since a job that succeeds while producing the wrong

thing is exactly what a failure-only log cannot explain. A run the

platform or the provider killed — out of memory, a crashed worker, a

timeout, a job past its maximum runtime — never gets that far, so it

reaches a terminal status carrying no log at all. That is a real gap

and worth stating: the failures a caller most wants a log for are

the ones least likely to have produced one.


**`204` is the normal answer for a job with no log**, and the cases

behind it are deliberately not distinguished: this surface does not

capture logs at all, the job has not finished, the job predates log

capture, the run was killed before the worker could report one,

capture was attempted and failed, or the job ran on the public demo

deployment, which captures and stores the log like every other

serverless deployment but withholds it on read, because that surface

takes callers with no credential and a job id would otherwise be the

only thing between one anonymous caller and another''s run.


Because a `204` never says which of those it is, do not branch on the

reason — but do note that one of them resolves itself. A job that has

not finished may have a log once it does, so a caller that wants one

reads again after a terminal status. A `204` on a job already in a

terminal state is final, and so is a missing `urls.logs`; both mean

stop asking.


**Only jobs run on the serverless platform** (a

`{deployment}.run.comfy.app` host) have one today. An implementation

that captures no logs must still serve this operation, answering

`204` for every job it can read, so that the two answers stay

distinct — Comfy Cloud does. A self-hosted deployment on a build

predating this operation has not implemented it yet and will answer

a routing `404` instead, which is the case `job.urls.logs` exists to

keep a client out of: its absence says the surface has no logs at

all, without a request.


Tied to the job''s own retention: this `404`s under the same

conditions `GET /api/v2/jobs/{id}` does (unknown, not-yours, or past

its retention deadline). Nothing ages a log out ahead of the job''s

own `expires_at`, so a job never outlives its log.


Live tailing is not offered here yet. When it is, it arrives on this

same path under `Accept: text/event-stream`, leaving this

JSON snapshot the default; its resume semantics will be defined

then, against a capture that is incremental. Until then the SSE

`log` event on `GET /api/v2/jobs/{id}/events` is the reserved live

rail, and this is the authoritative snapshot it reconciles against.

'
parameters:
- $ref: '#/components/parameters/JobId'
responses:
'200':
description: The captured log.
content:
application/json:
schema:
$ref: '#/components/schemas/JobLogs'
'204':
description: This job has no log. A normal answer, not an error — see the description for the cases it covers.
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/UpstreamError'
/api/v2/jobs/{id}/events:
get:
operationId: getJobEvents
Expand Down Expand Up @@ -556,7 +684,7 @@ paths:
description: 'Emitted the moment each output asset is committed, carrying the same `Output` object that appears on `job.outputs[]`. A latency optimization only: it lets a client render each result as it lands instead of waiting for the terminal `status` event. It is delivered best-effort over the live broadcast path — an output whose durable asset record is not yet resolvable when its node finishes may be delivered on a slightly later event or, failing that, only in the terminal `status` snapshot — so the authoritative, complete set of outputs is always `job.outputs[]` on `GET /api/v2/jobs/{id}` and on the terminal `status` event. A client must therefore treat these as additive hints and must not assume it receives one per output.'
schema: '#/components/schemas/Output'
log:
description: Selected execution log lines. Best-effort diagnostics; the one event type with no snapshot equivalent. NOT YET EMITTED by the server in the first iteration — reserved in the catalog so the wire contract is stable. Clients must not depend on receiving this event yet.
description: 'Selected execution log lines, carried while the run is still going. Best-effort diagnostics, and lossy by the same rule as the rest of this stream: lines emitted while a client was disconnected are gone and no `Last-Event-ID` replays them. The authoritative, complete log is the snapshot at `GET /api/v2/jobs/{id}/logs`, which a client re-reads after a terminal status to reconcile whatever it missed — on a surface that captures logs at all. Comfy Cloud does not, and answers `204` there for every job, so this event has nothing to be the live view of; see that operation for what a self-hosted deployment answers. NOT YET EMITTED by the server in the first iteration — reserved in the catalog so the wire contract is stable. Clients must not depend on receiving this event yet: to get a log today, stream to a terminal status and read the snapshot.'
x-sse-not-yet-emitted: true
schema: '#/components/schemas/LogEvent'
parameters:
Expand Down Expand Up @@ -821,6 +949,28 @@ components:
execution_ms: 42000
urls:
$ref: '#/components/schemas/JobUrls'
JobLogs:
type: object
description: 'A job''s captured execution log — the body of `GET /api/v2/jobs/{id}/logs`. Diagnostics, not a contract on content: this is whatever the workflow''s own code and nodes wrote to standard output, in the order they wrote it, so nothing about its shape is stable between runs or between releases of a build. It is **untrusted text** — a workflow chooses what goes in it — and must be rendered as plain text rather than interpreted.'
required:
- text
- truncated
- captured_at
- complete
properties:
text:
type: string
description: The captured output.
truncated:
type: boolean
description: 'The BEGINNING of the captured output was discarded — `text` is the TAIL of a longer run. Implementations bound what they capture and store, so a workflow that prints megabytes keeps its last lines, where a failure normally is, instead of being dropped whole. True with an empty `text` means the log was captured and then shed entirely to fit. This describes the stored log, never the response: it does not mean a caller asked for part of one.'
captured_at:
type: string
format: date-time
description: When the run's output was read back off the worker.
complete:
type: boolean
description: No further output will be appended to this log. Always `true` today, because a log is read back off the worker once, when the run ends, so a log that exists is already whole. Sent so that a surface which later captures output while a run is still going can say so, and a client written now against `false` keeps working when it does. `false` does not promise that more output will arrive, only that this snapshot may not be the last one.
JobWorkflowResponse:
type: object
description: The workflow behind a job. See GET /api/v2/jobs/{id}/workflow's description for exactly when `format` is `save` vs `api`.
Expand Down Expand Up @@ -872,6 +1022,12 @@ components:
cancel:
type: string
format: uri-reference
logs:
type: string
format: uri-reference
description: 'Where to read what this run printed. Present on any surface that captures execution logs, which is why it is the one link here that is optional: absent means this surface captures none, for any job, so a client can stop looking without spending a request on an answer it already has.

Follow this link rather than building the path from the job id. The two are not interchangeable: a surface may be mounted under a prefix this link already carries and a hand-built path would not, and a surface that does not implement the operation at all answers a routing `404` — indistinguishable, to the client, from the `404` that means the job itself is gone. Present does NOT mean this job has a log, and it is deliberately not a signal about one: a surface that captures logs offers the link on every job, including those it will answer `204` for and those whose log it withholds. Read the log, not the link.'
Progress:
type: object
description: Server-computed progress snapshot (node-count and sampler-step weighted). Complete per snapshot — one fully re-syncs a client.
Expand Down Expand Up @@ -929,6 +1085,7 @@ components:
properties:
node_id:
type: string
description: The workflow node that reported this file; empty when the worker named none.
example: '9'
name:
type: string
Expand Down
2 changes: 2 additions & 0 deletions src/comfy_low/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"postJobs",
"getJob",
"getJobWorkflow",
"getJobLogs",
"getJobEvents",
"cancelJob",
}
Expand All @@ -62,6 +63,7 @@
"postJobs": "post_jobs",
"getJob": "get_job",
"getJobWorkflow": "get_job_workflow",
"getJobLogs": "get_job_logs",
"getJobEvents": "get_job_events",
"cancelJob": "cancel_job",
}
Expand Down
2 changes: 2 additions & 0 deletions src/comfy_low/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
Format,
Job,
JobError,
JobLogs,
JobStatus,
JobUrls,
JobWorkflowResponse,
Expand All @@ -39,6 +40,7 @@
"Format",
"Job",
"JobError",
"JobLogs",
"JobStatus",
"JobUrls",
"JobWorkflowResponse",
Expand Down
38 changes: 37 additions & 1 deletion src/comfy_low/models/_generated.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,30 @@ class Asset(BaseModel):
] = None


class JobLogs(BaseModel):
"""
A job's captured execution log — the body of `GET /api/v2/jobs/{id}/logs`. Diagnostics, not a contract on content: this is whatever the workflow's own code and nodes wrote to standard output, in the order they wrote it, so nothing about its shape is stable between runs or between releases of a build. It is **untrusted text** — a workflow chooses what goes in it — and must be rendered as plain text rather than interpreted.
"""

text: Annotated[str, Field(description='The captured output.')]
truncated: Annotated[
bool,
Field(
description='The BEGINNING of the captured output was discarded — `text` is the TAIL of a longer run. Implementations bound what they capture and store, so a workflow that prints megabytes keeps its last lines, where a failure normally is, instead of being dropped whole. True with an empty `text` means the log was captured and then shed entirely to fit. This describes the stored log, never the response: it does not mean a caller asked for part of one.'
),
]
captured_at: Annotated[
AwareDatetime,
Field(description="When the run's output was read back off the worker."),
]
complete: Annotated[
bool,
Field(
description='No further output will be appended to this log. Always `true` today, because a log is read back off the worker once, when the run ends, so a log that exists is already whole. Sent so that a surface which later captures output while a run is still going can say so, and a client written now against `false` keeps working when it does. `false` does not promise that more output will arrive, only that this snapshot may not be the last one.'
),
]


class Format(Enum):
"""
Discriminates the `workflow` field's shape. `save`: the original authoring workflow JSON, at the version pinned to the job. `api`: the executed API-format prompt graph.
Expand Down Expand Up @@ -100,6 +124,12 @@ class JobUrls(BaseModel):
self: str
events: str
cancel: str
logs: Annotated[
str | None,
Field(
description='Where to read what this run printed. Present on any surface that captures execution logs, which is why it is the one link here that is optional: absent means this surface captures none, for any job, so a client can stop looking without spending a request on an answer it already has.\nFollow this link rather than building the path from the job id. The two are not interchangeable: a surface may be mounted under a prefix this link already carries and a hand-built path would not, and a surface that does not implement the operation at all answers a routing `404` — indistinguishable, to the client, from the `404` that means the job itself is gone. Present does NOT mean this job has a log, and it is deliberately not a signal about one: a surface that captures logs offers the link on every job, including those it will answer `204` for and those whose log it withholds. Read the log, not the link.'
),
] = None


class Progress(BaseModel):
Expand Down Expand Up @@ -251,7 +281,13 @@ class Output(BaseModel):
A committed job output. Outputs are assets: `id` is the asset UUID, retrievable via GET /api/v2/assets/{id} for as long as the job is retained. `hash` is lazily computed and may be null on the retrieval hot path.
"""

node_id: Annotated[str, Field(examples=['9'])]
node_id: Annotated[
str,
Field(
description='The workflow node that reported this file; empty when the worker named none.',
examples=['9'],
),
]
name: Annotated[str, Field(examples=['ComfyUI_00001_.png'])]
type: OutputType
content_type: Annotated[str, Field(examples=['image/png'])]
Expand Down
Loading
Loading