mirror of
https://github.com/Comfy-Org/comfy-typescript-sdk.git
synced 2026-11-04 23:47:27 +00:00
[PR #162] [MERGED] feat(jobs): surface the job timestamps, progress and queue position on Job #161
Labels
No labels
audit:ai-readiness
audit:community
audit:github
audit:health-scoring
audit:policy
audit:release
audit:supply-chain
bug
cursor-review-pin
effort:medium
effort:small
effort:trivial
priority:nice-to-have
priority:recommended
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Comfy-Org/comfy-typescript-sdk#161
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
📋 Pull Request Information
Original PR: https://github.com/Comfy-Org/comfy-typescript-sdk/pull/162
Author: @mattmillerai
Created: 9/19/2026
Status: ✅ Merged
Merged: 10/9/2026
Merged by: @mattmillerai
Base:
main← Head:matt/be-15898-job-timestamp-accessors📝 Commits (6)
ab6eac7feat(jobs): surface the job timestamps, progress and queue position on Jobba133aaMerge remote-tracking branch 'origin/main' into matt/be-15898-job-timestamp-accessors7ac3186fix(jobs): read an omitted wire field as none, and de-duplicate the changelog headingba790dafix(jobs): read an unusable wire field as none, and announce a missing required one2354f64Merge origin/main into matt/be-15898-job-timestamp-accessorsc8445e7fix(jobs): return the events Progress from job.progress, matching the Python SDK📊 Changes
7 files changed (+427 additions, -11 deletions)
View changed files
📝
CHANGELOG.md(+19 -0)📝
README.md(+34 -0)📝
src/sdk/events.test.ts(+10 -1)📝
src/sdk/events.ts(+8 -1)📝
src/sdk/jobs.test.ts(+200 -1)📝
src/sdk/jobs.ts(+142 -3)📝
test/support/stub-server.ts(+14 -5)📄 Description
ELI-5
A
Jobknew far more about itself than it would tell you. The server sends back created/started/completed/expires timestamps, a progress snapshot, a queue position, run metrics and the job's own links — and the handle kept all of it in a private field while exposing onlyid,status,outputsanderror. So the most obvious question you have after a run — how long did that take? — had no answer on the public type, and callers were reaching pastprivatewith a cast to get at it. This adds the missing getters, so the answer isjob.completedAtminusjob.startedAt.What changed
Eight read-only accessors on
Job, in the same one-line-getter style asid/status:createdAt,expiresAtDatestartedAt,completedAtDate | null(both are nullable on the wire)progressProgress | nullqueuePositionnumber | nullmetricsRecord<string, number | null> | undefinedurlsJobUrlscreatedAt,expiresAtandurlsare non-nullable because the spec'srequiredlist makes those fields required and non-nullable; see the contract note below for what happens if a server breaks that.modelstays private — these are views onto it, and they read whatever state the handle currently holds, the same contract the existing getters document. Nothing re-fetches implicitly.Timestamps are returned as
Daterather than the raw ISO string or an epoch number, so the type says what the value is. The object-valued three (progress,metrics,urls) hand back a snapshot copy:refresh,cancel,eventsandgetLogsall navigate bymodel.urls.*, so returning the live object from a getter documented as read-only would have let a caller rewrite the address the handle polls and cancels through. There is a test that fails if the copy is removed.metricsdistinguishes absent from empty — it is optional on the wire, and a surface that measures nothing sends no key at all, which reads asundefinedrather than{}.Off-contract values are split the way the spec's
requiredlist splits the fields.started_at,completed_at,queue_positionandprogressare required-but-nullable, so "none" is a real answer and absent-or-unusable reads as none however the server spells it: the generated type declares exactly one absent-value per field, but testing for only that one let the other fall through to the value branch —toDate(undefined)is anInvalid Date, which compares false against everything instead of announcing itself,queue_positionreturnedundefinedthrough a declarednumber | nullso a caller's!== nullguard passed and the value entered arithmetic asNaN, and spreading an absentprogressyielded a{}typed as aProgresswith novalueornodes_total.toDatealso rejects a non-string or an unparseable string, since the transport doesJSON.parse(text) as Twith no runtime validation and anInvalid Dateis truthy — it would slip past theif (job.startedAt && job.completedAt)guard the README documents. This matches the defensive?? nullthe SSE side already applies to these same fields inevents.ts.created_at,expires_atandurlsare required and non-nullable, so there is no "none" for them to mean and softening them would invent one callers must then check forever. Reading one the server did not send raisesComfyErrorwithcode: "unexpected_response", naming the field — the same way themodelsroutes already report a response that breaks its contract. That beats the alternatives:new Date(undefined)compares false against everything,new Date(null)is the Unix epoch (a retention check reading a live job as expired in 1970), and{ ...undefined }is a{}typed as a fullJobUrlshanding backundefinedfor aselfthe type promises is astring. A spec-conformant response is unaffected either way.The stub server grows one knob,
jobFieldOverrides, shallow-merged over theGET /jobs/{id}body only (notPOST /jobs, not the cancel response) so a test can prove a handle adopted this state onrefresh()rather than having held it since submit.Docs: a new What a job carries section in the README lists every accessor with its type, shows the duration snippet with no cast, and flags the one real trap —
job.progressis the generated snake_case wire model, not the camelCaseProgressevent of the same name thatjob.events()yields. Changelog entry under Unreleased.Verification of the report
The report is about the published package, so I checked the published package rather than only my own diff:
npm pack @comfyorg/sdk@0.4.0and readingpackage/dist/sdk/jobs.d.tsshowsprivate model;plus exactly four getters (id,status,outputs,error) — the cast the report describes is the only way to reachstarted_aton that artifact, and it targets the real private field name. After this change,dist/sdk/jobs.d.tscarries all eight new getters. A repo-wide sweep for theprivate modelcast pattern found 0 remaining occurrences in this repo (the reported cast lives in consumer code, which is outside this repo and unswept).Provenance
origin/mainat9d2fa64(job labels, #200, and credits-used, #157):pnpm test699 passed, 5 skipped, 0 failedpnpm lint,pnpm format:checkandpnpm typecheckare clean, andpnpm check:spec-driftpasses both of its checksnode scripts/check-public-repo-hygiene.mjs: OKsrc/sdk/jobs.ts) reddened exactly the 7 absent/unusable/required-field testsnode scripts/sync-python-surface.mjsfails with the sameRouterRunResultExtractionError on a cleanmain, see Residual### Addedchangelog headingmainresolved conflicts injobs.ts, the stub server, README and CHANGELOG by keeping both sides.job.metadatafrom #200 now sits alongside the new accessors and is listed in the README's "What a job carries" table, and the stub'sjobFieldOverridesnow merges over the metadata-aware job bodyResidual
The Python sibling has not landed, so this is a one-sided addition. The Python SDK's
Jobneeds the same fields under the same names (created_at,started_at,completed_at,expires_at,progress,queue_position,metrics,urls) before the two surfaces match. I checked what that costs today: the parity harness insrc/sdk/surface-parity.test.tscompares themodelsmethod names, the router error class names, theirerror_typemapping, the status→bucket fallback table and the retry-policy fields — it does not introspectJobaccessors at all, andparity/python-surface.jsoncarries noJobentry. Sopnpm teststays green on this branch and noINTENTIONAL_ASYMMETRIESor*AheadOfPythonentry was needed. The gap is therefore invisible to CI, which is the part worth tracking: a human should land the Python twin, and whoever does should consider extending the parity extractor to coverJobso the next one-sided addition is caught.The
sdk-parityCI job is already red onmain, and this branch does not fix it.node scripts/sync-python-surface.mjsfails withExtractionError: src/comfy_sdk/models.py: unrecognized public class(es) RouterRunResult. I reproduced it on a cleanmaincheckout with no local changes, so it is pre-existing Python-side drift, unrelated to this diff, and the fix is the oneAGENTS.md§6 describes (teachPYTHON_MODELS_CLASSESinscripts/python-surface.mjsabout the new class, then re-runpnpm sync:python-surface). Deliberately left alone here to keep this diff scoped — and #167 (fix(parity): track the Python SDK's refactors) already carries that fix and is approved, so this branch'ssdk-parityshould go green once #167 lands.job.progresson Comfy Cloud is documented from a report, not from a measurement. The vendored contract (spec/openapi.yaml) says the poll returns the latest snapshot, "same data the SSE stream pushes"; the report is that Comfy Cloud sendsnullthere even for a running job. I could not falsify or confirm that: there are no Comfy credentials in this environment, and the only way to observe a running job's poll response is to submit a real paid generation, which is a mutating and billable action I did not take. The accessor ships regardless and denies nothing — it returns whatever the server sends — and both the README and the JSDoc attribute the Cloud behaviour rather than asserting it, and point atjob.events(), which is a working path for live progress on every surface. If the server gap is fixed, nothing here needs to change. If someone with Cloud access confirms it, the attribution can become a statement.Retention timestamps are now typed two different ways across the SDK.
Job.expiresAtreturns aDate(what was asked for, and whatOutput.getDownloadUrl()/Asset.getDownloadUrl()already return for the signed-URL expiry), whileAsset.expiresAtstill returns the rawstring | undefined. Both are retention deadlines and a reader will reasonably expect them to match. I did not changeAsset— it is a separate public surface, changing its return type is a breaking change, and it is outside what this asked for — but the inconsistency is real and someone should decide which way it goes.Summary by CodeRabbit
🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.