@tscomfy/client 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +76 -2
  3. package/dist/channel.d.ts +54 -0
  4. package/dist/channel.d.ts.map +1 -0
  5. package/dist/channel.js +307 -0
  6. package/dist/channel.js.map +1 -0
  7. package/dist/client-error.d.ts +15 -0
  8. package/dist/client-error.d.ts.map +1 -0
  9. package/dist/client-error.js +16 -0
  10. package/dist/client-error.js.map +1 -0
  11. package/dist/connection.d.ts +33 -0
  12. package/dist/connection.d.ts.map +1 -0
  13. package/dist/connection.js +35 -0
  14. package/dist/connection.js.map +1 -0
  15. package/dist/event-queue.d.ts +16 -0
  16. package/dist/event-queue.d.ts.map +1 -0
  17. package/dist/event-queue.js +66 -0
  18. package/dist/event-queue.js.map +1 -0
  19. package/dist/events.d.ts +60 -0
  20. package/dist/events.d.ts.map +1 -0
  21. package/dist/events.js +2 -0
  22. package/dist/events.js.map +1 -0
  23. package/dist/files.d.ts +55 -0
  24. package/dist/files.d.ts.map +1 -0
  25. package/dist/files.js +69 -0
  26. package/dist/files.js.map +1 -0
  27. package/dist/frames.d.ts +24 -0
  28. package/dist/frames.d.ts.map +1 -0
  29. package/dist/frames.js +71 -0
  30. package/dist/frames.js.map +1 -0
  31. package/dist/http.d.ts +28 -0
  32. package/dist/http.d.ts.map +1 -0
  33. package/dist/http.js +96 -0
  34. package/dist/http.js.map +1 -0
  35. package/dist/index.d.ts +17 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +14 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/job.d.ts +59 -0
  40. package/dist/job.d.ts.map +1 -0
  41. package/dist/job.js +481 -0
  42. package/dist/job.js.map +1 -0
  43. package/dist/outcome.d.ts +47 -0
  44. package/dist/outcome.d.ts.map +1 -0
  45. package/dist/outcome.js +2 -0
  46. package/dist/outcome.js.map +1 -0
  47. package/dist/outputs.d.ts +53 -0
  48. package/dist/outputs.d.ts.map +1 -0
  49. package/dist/outputs.js +21 -0
  50. package/dist/outputs.js.map +1 -0
  51. package/dist/read-outputs.d.ts +11 -0
  52. package/dist/read-outputs.d.ts.map +1 -0
  53. package/dist/read-outputs.js +142 -0
  54. package/dist/read-outputs.js.map +1 -0
  55. package/dist/server-errors.d.ts +7 -0
  56. package/dist/server-errors.d.ts.map +1 -0
  57. package/dist/server-errors.js +46 -0
  58. package/dist/server-errors.js.map +1 -0
  59. package/dist/server.d.ts +30 -0
  60. package/dist/server.d.ts.map +1 -0
  61. package/dist/server.js +2 -0
  62. package/dist/server.js.map +1 -0
  63. package/dist/timers.d.ts +11 -0
  64. package/dist/timers.d.ts.map +1 -0
  65. package/dist/timers.js +38 -0
  66. package/dist/timers.js.map +1 -0
  67. package/dist/url.d.ts +14 -0
  68. package/dist/url.d.ts.map +1 -0
  69. package/dist/url.js +39 -0
  70. package/dist/url.js.map +1 -0
  71. package/dist/uuid.d.ts +7 -0
  72. package/dist/uuid.d.ts.map +1 -0
  73. package/dist/uuid.js +13 -0
  74. package/dist/uuid.js.map +1 -0
  75. package/package.json +46 -4
  76. package/src/channel.ts +334 -0
  77. package/src/client-error.ts +19 -0
  78. package/src/connection.ts +59 -0
  79. package/src/event-queue.ts +70 -0
  80. package/src/events.ts +76 -0
  81. package/src/files.ts +119 -0
  82. package/src/frames.ts +87 -0
  83. package/src/http.ts +113 -0
  84. package/src/index.ts +53 -0
  85. package/src/job.ts +562 -0
  86. package/src/outcome.ts +52 -0
  87. package/src/outputs.ts +69 -0
  88. package/src/read-outputs.ts +171 -0
  89. package/src/server-errors.ts +61 -0
  90. package/src/server.ts +29 -0
  91. package/src/timers.ts +35 -0
  92. package/src/url.ts +41 -0
  93. package/src/uuid.ts +12 -0
package/src/job.ts ADDED
@@ -0,0 +1,562 @@
1
+ import {
2
+ type ApiPrompt,
3
+ createDiagnostic,
4
+ type Diagnostic,
5
+ type JsonObject,
6
+ type JsonValue,
7
+ type NodeId,
8
+ stringify,
9
+ toApi,
10
+ type Workflow,
11
+ } from "@tscomfy/core";
12
+ import { type Channel, channelOf, type JobListener } from "./channel.ts";
13
+ import { ClientError } from "./client-error.ts";
14
+ import type { Connection } from "./connection.ts";
15
+ import { EventQueue } from "./event-queue.ts";
16
+ import type { JobEvent } from "./events.ts";
17
+ import type { PreviewFrame, TextFrame } from "./frames.ts";
18
+ import { isList, isObject, readObject, send, unexpected } from "./http.ts";
19
+ import type { ExecutionError, Outcome } from "./outcome.ts";
20
+ import type { NodeOutput, NodeRef, Outputs } from "./outputs.ts";
21
+ import { nodeIdOf, outputsInOrder, readNodeOutput } from "./read-outputs.ts";
22
+ import { serverDiagnostics } from "./server-errors.ts";
23
+ import type { Server } from "./server.ts";
24
+ import { sleep } from "./timers.ts";
25
+ import { randomUuid } from "./uuid.ts";
26
+
27
+ /** How the server draws live previews: `auto` is latent2rgb; `taesd` needs its decoder model. */
28
+ export type PreviewMethod = "auto" | "latent2rgb" | "taesd" | "none";
29
+
30
+ /** Options for {@link submit}. */
31
+ export interface SubmitOptions {
32
+ /**
33
+ * Live previews for this job (`extra_data.preview_method`). Without it the server's default
34
+ * applies, which is `none` unless it was started with `--preview-method`.
35
+ */
36
+ readonly previews?: PreviewMethod;
37
+ /** Run only these output nodes (`partial_execution_targets`). */
38
+ readonly targets?: readonly NodeRef[];
39
+ /** Queue ahead of the jobs already waiting. */
40
+ readonly front?: boolean;
41
+ /**
42
+ * More `extra_data` for the server, such as `api_key_comfy_org` for partner nodes. The keys the
43
+ * client sets (`client_id`, `preview_method`) are refused.
44
+ */
45
+ readonly extraData?: JsonObject;
46
+ /**
47
+ * Aborting it cancels the job on the server and rejects the outcome with the signal's reason:
48
+ * `AbortSignal.timeout(600_000)`. There is no default timeout.
49
+ */
50
+ readonly signal?: AbortSignal;
51
+ }
52
+
53
+ /** A submitted job. */
54
+ export interface Job {
55
+ /** The prompt id, minted by the client. */
56
+ readonly id: string;
57
+ /**
58
+ * What the job reports while it runs, for one consumer, from the moment it was submitted. Ends
59
+ * when the outcome settles; never throws for the job's failure.
60
+ */
61
+ readonly events: AsyncIterable<JobEvent>;
62
+ /**
63
+ * How the job ended, as the server's record says. Rejects only with the signal's reason (see
64
+ * {@link SubmitOptions.signal}) or a {@link ClientError}: the server couldn't be reached, the
65
+ * connection was closed, or the server broke the protocol.
66
+ */
67
+ readonly outcome: Promise<Outcome>;
68
+ /**
69
+ * Asks the server to cancel the job, queued or running. The outcome then settles, normally as
70
+ * `cancelled` (a job that was finishing may still succeed). Rejects with a {@link ClientError}
71
+ * if the server can't be asked.
72
+ */
73
+ cancel(): Promise<void>;
74
+ }
75
+
76
+ /**
77
+ * Submits a workflow, or a prompt, to run on the server. A workflow is exported with `toApi`,
78
+ * which validates it and throws a `ValidationError` for errors (D21); a prompt is sent as given.
79
+ * The job is registered under a prompt id the client mints before anything is sent, so no event
80
+ * is missed. Throws a `TypeError` for invalid options or a closed connection.
81
+ */
82
+ export function submit(
83
+ connection: Connection,
84
+ input: Workflow | ApiPrompt,
85
+ options: SubmitOptions = {},
86
+ ): Job {
87
+ const channel = channelOf(connection);
88
+ if (channel.closed) throw new TypeError("submit: the connection is closed");
89
+ const prompt = isWorkflow(input) ? toApi(input) : input;
90
+ const body = requestBody(channel.clientId, prompt, options);
91
+ const run = new JobRun(channel, body.id, Object.keys(prompt), options.signal);
92
+ run.start(body.text);
93
+ return run.job;
94
+ }
95
+
96
+ /** A workflow has a schema and a graph of nodes; a prompt's entries are nodes with a class. */
97
+ function isWorkflow(input: Workflow | ApiPrompt): input is Workflow {
98
+ const graph: unknown = Reflect.get(input, "graph");
99
+ const nodes: unknown =
100
+ typeof graph === "object" && graph !== null ? Reflect.get(graph, "nodes") : undefined;
101
+ return (
102
+ Reflect.has(input, "schema") &&
103
+ typeof nodes === "object" &&
104
+ nodes !== null &&
105
+ typeof Reflect.get(nodes, "get") === "function"
106
+ );
107
+ }
108
+
109
+ const PREVIEW_METHODS: ReadonlySet<unknown> = new Set(["auto", "latent2rgb", "taesd", "none"]);
110
+ const RESERVED_EXTRA_DATA = ["client_id", "preview_method"];
111
+
112
+ /** The body of `POST /prompt`, serialized losslessly, and the prompt id it carries. */
113
+ function requestBody(
114
+ clientId: string,
115
+ prompt: ApiPrompt,
116
+ options: SubmitOptions,
117
+ ): { readonly id: string; readonly text: string } {
118
+ if (options.previews !== undefined && !PREVIEW_METHODS.has(options.previews)) {
119
+ throw new TypeError(`submit: unknown preview method ${options.previews}`);
120
+ }
121
+ const extra = options.extraData ?? {};
122
+ for (const key of RESERVED_EXTRA_DATA) {
123
+ if (Object.hasOwn(extra, key)) throw new TypeError(`submit: extraData can't set ${key}`);
124
+ }
125
+ const id = randomUuid();
126
+ const body: JsonObject = {
127
+ client_id: clientId,
128
+ prompt_id: id,
129
+ prompt,
130
+ extra_data: {
131
+ ...extra,
132
+ ...(options.previews === undefined ? {} : { preview_method: options.previews }),
133
+ },
134
+ ...(options.front === true ? { front: true } : {}),
135
+ ...(options.targets === undefined
136
+ ? {}
137
+ : { partial_execution_targets: options.targets.map(nodeIdOf) }),
138
+ };
139
+ return { id, text: stringify(body) };
140
+ }
141
+
142
+ /** How often to poll the job record after a reconnect, when events may be lost for good. */
143
+ const POLL_MS = [1000, 2000, 5000, 10_000];
144
+ /** At most one record read per this many milliseconds on queue changes. */
145
+ const QUEUE_CHECK_MS = 1000;
146
+ /** Retries of a record read that couldn't reach the server. */
147
+ const READ_RETRY_MS = [500, 1000, 2000, 4000, 8000];
148
+
149
+ /** One job: its events, its record reads and its outcome. */
150
+ class JobRun implements JobListener {
151
+ readonly id: string;
152
+ readonly job: Job;
153
+ readonly #channel: Channel;
154
+ readonly #order: readonly NodeId[];
155
+ readonly #signal: AbortSignal | undefined;
156
+ readonly #events = new EventQueue();
157
+ readonly #diagnostics: Diagnostic[] = [];
158
+ readonly #outputs = new Map<NodeId, NodeOutput>();
159
+ /** The node a server node id is displayed as (they differ for nodes expanded at run time). */
160
+ readonly #display = new Map<string, NodeId>();
161
+ readonly #progress = new Map<NodeId, string>();
162
+ readonly #accepted: Promise<boolean>;
163
+ #accept: (accepted: boolean) => void = () => undefined;
164
+ #resolve: (outcome: Outcome) => void = () => undefined;
165
+ #reject: (error: unknown) => void = () => undefined;
166
+ #settled = false;
167
+ #started = false;
168
+ #reading = false;
169
+ #readAgain = false;
170
+ #lastQueueCheck = 0;
171
+ #queueCheck: ReturnType<typeof setTimeout> | undefined;
172
+ #poll: ReturnType<typeof setTimeout> | undefined;
173
+ /** Aborted when the job settles: ends its waits, so no timer outlives it. */
174
+ readonly #done = new AbortController();
175
+ readonly #abort = (): void => {
176
+ this.#abandon(this.#signal?.reason);
177
+ };
178
+
179
+ constructor(
180
+ channel: Channel,
181
+ id: string,
182
+ order: readonly NodeId[],
183
+ signal: AbortSignal | undefined,
184
+ ) {
185
+ this.#channel = channel;
186
+ this.id = id;
187
+ this.#order = order;
188
+ this.#signal = signal;
189
+ this.#accepted = new Promise((resolve) => {
190
+ this.#accept = resolve;
191
+ });
192
+ const outcome = new Promise<Outcome>((resolve, reject) => {
193
+ this.#resolve = resolve;
194
+ this.#reject = reject;
195
+ });
196
+ // Handled here so a rejection nobody awaits doesn't crash the process; awaiting still throws.
197
+ outcome.catch(() => undefined);
198
+ this.job = Object.freeze({
199
+ id,
200
+ events: this.#events,
201
+ outcome,
202
+ cancel: () => this.#cancel(),
203
+ });
204
+ }
205
+
206
+ start(body: string): void {
207
+ if (this.#signal?.aborted === true) {
208
+ this.#abandon(this.#signal.reason);
209
+ return;
210
+ }
211
+ this.#signal?.addEventListener("abort", this.#abort, { once: true });
212
+ void this.#post(body);
213
+ }
214
+
215
+ async #post(body: string): Promise<void> {
216
+ try {
217
+ await this.#channel.ready(this.#signal);
218
+ if (this.#settled) {
219
+ this.#accept(false);
220
+ return;
221
+ }
222
+ this.#channel.register(this);
223
+ // Not abortable: once sent, the prompt may be queued, and only its answer says so. An
224
+ // abort meanwhile cancels the job after the answer (`#abandon`).
225
+ const response = await send(this.#channel.server, { method: "POST", path: "prompt", body });
226
+ if (response.status === 200) {
227
+ // Queued: from here on the job can be cancelled, whatever the body says.
228
+ this.#accept(true);
229
+ const answer = await readObject(response).catch(() => undefined);
230
+ const id = answer?.["prompt_id"];
231
+ if (typeof id === "string" && id !== this.id) {
232
+ // A server or proxy that ignores the client's id: the record would never be found.
233
+ void cancelJob(this.#channel.server, id);
234
+ throw new ClientError(
235
+ `The server queued the prompt as ${id}, not as the id the client chose (${this.id})`,
236
+ );
237
+ }
238
+ this.#diagnostics.push(...serverDiagnostics(undefined, answer?.["node_errors"]));
239
+ return;
240
+ }
241
+ if (response.status === 400) {
242
+ const answer = await readObject(response);
243
+ this.#accept(false);
244
+ this.#settle({
245
+ status: "rejected",
246
+ diagnostics: serverDiagnostics(answer["error"] ?? "", answer["node_errors"]),
247
+ });
248
+ return;
249
+ }
250
+ throw await unexpected(response);
251
+ } catch (error) {
252
+ this.#accept(false);
253
+ this.fail(error);
254
+ }
255
+ }
256
+
257
+ message(type: string, data: JsonObject): void {
258
+ if (this.#settled) return;
259
+ switch (type) {
260
+ case "execution_start":
261
+ this.#started = true;
262
+ this.#events.push({ type: "started" });
263
+ return;
264
+ case "execution_cached":
265
+ this.#started = true;
266
+ this.#events.push({ type: "cached", nodes: Object.freeze(strings(data["nodes"])) });
267
+ return;
268
+ case "executing": {
269
+ const node = data["display_node"] ?? data["node"];
270
+ if (node === null) {
271
+ this.#read();
272
+ } else if (typeof node === "string") {
273
+ this.#events.push({ type: "executing", node });
274
+ }
275
+ return;
276
+ }
277
+ case "progress_state":
278
+ this.#progressState(data["nodes"]);
279
+ return;
280
+ case "executed":
281
+ this.#executed(data);
282
+ return;
283
+ case "execution_error":
284
+ this.#executionError(data);
285
+ return;
286
+ default:
287
+ // execution_success, execution_interrupted: the record decides, read on the final
288
+ // `executing`. Anything else isn't ours to interpret.
289
+ return;
290
+ }
291
+ }
292
+
293
+ frame(frame: PreviewFrame | TextFrame): void {
294
+ if (this.#settled) return;
295
+ const node = this.#display.get(frame.node) ?? frame.node;
296
+ if (frame.kind === "preview") {
297
+ this.#events.push({ type: "preview", node, mime: frame.mime, bytes: frame.bytes });
298
+ } else {
299
+ this.#events.push({ type: "text", node, text: frame.text });
300
+ }
301
+ }
302
+
303
+ queueChanged(): void {
304
+ // A job cancelled while queued sends no event; the record shows it.
305
+ if (this.#started || this.#settled) return;
306
+ // At most one read per interval; a change inside it is read at its end, not dropped.
307
+ if (this.#queueCheck !== undefined) return;
308
+ const wait = this.#lastQueueCheck + QUEUE_CHECK_MS - Date.now();
309
+ this.#queueCheck = setTimeout(
310
+ () => {
311
+ this.#queueCheck = undefined;
312
+ this.#lastQueueCheck = Date.now();
313
+ this.#read();
314
+ },
315
+ Math.max(0, wait),
316
+ );
317
+ }
318
+
319
+ reconnected(): void {
320
+ // Events sent while the socket was down are gone, and a reconnect can leave the server
321
+ // sending none (two sockets with one id): poll the record until the job settles.
322
+ this.#read();
323
+ if (this.#poll === undefined) this.#schedulePoll(0);
324
+ }
325
+
326
+ fail(error: unknown): void {
327
+ if (this.#settled) return;
328
+ this.#finish();
329
+ this.#reject(error);
330
+ }
331
+
332
+ #progressState(nodes: JsonValue | undefined): void {
333
+ if (!isObject(nodes)) return;
334
+ for (const [key, entry] of Object.entries(nodes)) {
335
+ if (!isObject(entry)) continue;
336
+ const { value, max } = entry;
337
+ const display = entry["display_node_id"] ?? entry["node_id"] ?? key;
338
+ if (typeof value !== "number" || typeof max !== "number" || typeof display !== "string") {
339
+ continue;
340
+ }
341
+ const seen = `${String(value)}/${String(max)}`;
342
+ if (this.#progress.get(display) === seen) continue;
343
+ this.#progress.set(display, seen);
344
+ this.#events.push({ type: "progress", node: display, value, max });
345
+ }
346
+ }
347
+
348
+ #executed(data: JsonObject): void {
349
+ const node = data["node"];
350
+ const display = data["display_node"] ?? node;
351
+ if (typeof node !== "string" || typeof display !== "string") return;
352
+ this.#display.set(node, display);
353
+ // A cached node without UI output reports `null`.
354
+ if (!isObject(data["output"])) return;
355
+ const output = readNodeOutput(display, data["output"]);
356
+ this.#outputs.set(display, output);
357
+ this.#events.push({ type: "output", node: display, output });
358
+ }
359
+
360
+ #executionError(data: JsonObject): void {
361
+ // Only a blocked node is reported here; a real error ends the run, and the record has it.
362
+ if (data["exception_type"] !== "ExecutionBlocked") return;
363
+ const node = data["node_id"];
364
+ if (typeof node !== "string") return;
365
+ const reported = data["exception_message"];
366
+ const message = typeof reported === "string" ? reported : "Execution blocked";
367
+ this.#diagnostics.push(createDiagnostic("W_NODE_BLOCKED", [node], message));
368
+ this.#events.push({ type: "blocked", node, message });
369
+ }
370
+
371
+ /** Reads the job record and settles from it; one read at a time, again if asked meanwhile. */
372
+ #read(): void {
373
+ if (this.#settled) return;
374
+ if (this.#reading) {
375
+ this.#readAgain = true;
376
+ return;
377
+ }
378
+ this.#reading = true;
379
+ void this.#readRecord()
380
+ .catch((error: unknown) => {
381
+ this.fail(error);
382
+ })
383
+ .finally(() => {
384
+ this.#reading = false;
385
+ if (this.#readAgain && !this.#settled) {
386
+ this.#readAgain = false;
387
+ this.#read();
388
+ }
389
+ });
390
+ }
391
+
392
+ async #readRecord(): Promise<void> {
393
+ if (!(await this.#accepted) || this.#settled) return;
394
+ const response = await this.#retried(() =>
395
+ send(this.#channel.server, { method: "GET", path: `api/jobs/${this.id}` }),
396
+ );
397
+ // Settled meanwhile (aborted, closed): the answer comes too late.
398
+ if (this.#isSettled()) return;
399
+ if (response.status === 404) {
400
+ // An unknown job: cancelled while it was queued. A server without the job API answers
401
+ // with a 404 that isn't JSON.
402
+ const answer = await readObject(response).catch(() => undefined);
403
+ if (answer === undefined) throw await unexpected(response);
404
+ if (this.#started) {
405
+ // It ran, and its record is gone: the server restarted, or its history moved on.
406
+ throw new ClientError(
407
+ `The server has no record of job ${this.id} any more (restarted?); its outcome is unknown`,
408
+ { status: 404 },
409
+ );
410
+ }
411
+ this.#settle({ status: "cancelled", outputs: this.#liveOutputs(), diagnostics: [] });
412
+ return;
413
+ }
414
+ if (!response.ok) throw await unexpected(response);
415
+ const record = await readObject(response);
416
+ const status = record["status"];
417
+ if (status === "pending" || status === "in_progress") return;
418
+ if (status === "completed") {
419
+ this.#settle({ status: "success", outputs: this.#recordOutputs(record), diagnostics: [] });
420
+ } else if (status === "failed") {
421
+ this.#settle({
422
+ status: "failed",
423
+ error: executionError(record["execution_error"]),
424
+ outputs: this.#recordOutputs(record),
425
+ diagnostics: [],
426
+ });
427
+ } else if (status === "cancelled") {
428
+ this.#settle({ status: "cancelled", outputs: this.#recordOutputs(record), diagnostics: [] });
429
+ } else {
430
+ throw new ClientError(`The server reported job status ${stringify(status ?? null)}`);
431
+ }
432
+ }
433
+
434
+ #isSettled(): boolean {
435
+ return this.#settled;
436
+ }
437
+
438
+ /** Retries a request that couldn't reach the server, a few times, with growing waits. */
439
+ async #retried(request: () => Promise<Response>): Promise<Response> {
440
+ for (const delay of READ_RETRY_MS) {
441
+ try {
442
+ // oxlint-disable-next-line no-await-in-loop -- retries are sequential, with growing waits
443
+ return await request();
444
+ } catch (error) {
445
+ if (!(error instanceof ClientError) || this.#settled) throw error;
446
+ // oxlint-disable-next-line no-await-in-loop -- retries are sequential, with growing waits
447
+ await sleep(delay, this.#done.signal);
448
+ // Settled meanwhile: no more requests; the error goes nowhere.
449
+ if (this.#isSettled()) throw error;
450
+ }
451
+ }
452
+ return request();
453
+ }
454
+
455
+ /**
456
+ * The record's outputs, by the nodes they are displayed as, in prompt order. A node whose
457
+ * output the client saw live keeps that one: the record rewrites some entries (any string
458
+ * with a 3D file extension becomes a file, `text` included), the live output is as the node
459
+ * reported it.
460
+ */
461
+ #recordOutputs(record: JsonObject): Outputs {
462
+ const outputs = new Map<NodeId, NodeOutput>();
463
+ const recorded = record["outputs"];
464
+ if (isObject(recorded)) {
465
+ for (const [id, ui] of Object.entries(recorded)) {
466
+ if (!isObject(ui)) continue;
467
+ const node = this.#display.get(id) ?? id;
468
+ outputs.set(node, this.#outputs.get(node) ?? readNodeOutput(node, ui));
469
+ }
470
+ }
471
+ return outputsInOrder(outputs, this.#order);
472
+ }
473
+
474
+ #liveOutputs(): Outputs {
475
+ return outputsInOrder(this.#outputs, this.#order);
476
+ }
477
+
478
+ #settle(outcome: Outcome): void {
479
+ if (this.#settled) return;
480
+ this.#finish();
481
+ const diagnostics = Object.freeze([...this.#diagnostics, ...outcome.diagnostics]);
482
+ this.#resolve(Object.freeze({ ...outcome, diagnostics }));
483
+ }
484
+
485
+ #finish(): void {
486
+ this.#settled = true;
487
+ this.#done.abort();
488
+ this.#signal?.removeEventListener("abort", this.#abort);
489
+ if (this.#poll !== undefined) clearTimeout(this.#poll);
490
+ if (this.#queueCheck !== undefined) clearTimeout(this.#queueCheck);
491
+ this.#channel.unregister(this);
492
+ this.#events.end();
493
+ }
494
+
495
+ #schedulePoll(step: number): void {
496
+ if (this.#poll !== undefined) clearTimeout(this.#poll);
497
+ if (this.#settled) return;
498
+ const delay = POLL_MS[Math.min(step, POLL_MS.length - 1)] ?? 10_000;
499
+ this.#poll = setTimeout(() => {
500
+ this.#read();
501
+ this.#schedulePoll(step + 1);
502
+ }, delay);
503
+ }
504
+
505
+ async #cancel(): Promise<void> {
506
+ if (this.#settled || !(await this.#accepted)) return;
507
+ const response = await send(this.#channel.server, {
508
+ method: "POST",
509
+ path: `api/jobs/${this.id}/cancel`,
510
+ body: "{}",
511
+ });
512
+ if (!response.ok) throw await unexpected(response);
513
+ await response.body?.cancel();
514
+ this.#read();
515
+ }
516
+
517
+ /** The caller gave up: cancel on the server, best effort, and reject with the reason. */
518
+ #abandon(reason: unknown): void {
519
+ if (this.#settled) return;
520
+ const accepted = this.#accepted;
521
+ const server = this.#channel.server;
522
+ const id = this.id;
523
+ this.fail(reason);
524
+ void accepted.then((wasAccepted) => (wasAccepted ? cancelJob(server, id) : undefined));
525
+ }
526
+ }
527
+
528
+ /** Asks the server to cancel a job, best effort: for a caller who has already given up. */
529
+ async function cancelJob(server: Server, id: string): Promise<void> {
530
+ try {
531
+ const response = await send(server, {
532
+ method: "POST",
533
+ path: `api/jobs/${id}/cancel`,
534
+ body: "{}",
535
+ });
536
+ await response.body?.cancel();
537
+ } catch {
538
+ // Nobody is left to tell.
539
+ }
540
+ }
541
+
542
+ /** The strings of a JSON list. */
543
+ function strings(value: JsonValue | undefined): string[] {
544
+ return isList(value) ? value.filter((item): item is string => typeof item === "string") : [];
545
+ }
546
+
547
+ /** The record's `execution_error`, read leniently. */
548
+ function executionError(value: JsonValue | undefined): ExecutionError {
549
+ const error = isObject(value) ? value : {};
550
+ const text = (key: string): string => {
551
+ const item = error[key];
552
+ return typeof item === "string" ? item : "";
553
+ };
554
+ return Object.freeze({
555
+ node: text("node_id"),
556
+ classType: text("node_type"),
557
+ exceptionType: text("exception_type"),
558
+ message: text("exception_message"),
559
+ traceback: Object.freeze(strings(error["traceback"])),
560
+ executed: Object.freeze(strings(error["executed"])),
561
+ });
562
+ }
package/src/outcome.ts ADDED
@@ -0,0 +1,52 @@
1
+ import type { Diagnostic, NodeId } from "@tscomfy/core";
2
+ import type { Outputs } from "./outputs.ts";
3
+
4
+ /** The job ran to the end. Outputs left out by a partial acceptance are in `diagnostics`. */
5
+ export interface SuccessOutcome {
6
+ readonly status: "success";
7
+ readonly outputs: Outputs;
8
+ /**
9
+ * `E_SERVER_VALIDATION` for outputs the server refused while running the others, and
10
+ * `W_NODE_BLOCKED` for nodes that blocked execution.
11
+ */
12
+ readonly diagnostics: readonly Diagnostic[];
13
+ }
14
+
15
+ /** The server refused the prompt: nothing ran. */
16
+ export interface RejectedOutcome {
17
+ readonly status: "rejected";
18
+ /** `E_PROMPT_REJECTED` for the server's error, and `E_SERVER_VALIDATION` for each node error. */
19
+ readonly diagnostics: readonly Diagnostic[];
20
+ }
21
+
22
+ /** A node raised an error; the run stopped there. */
23
+ export interface FailedOutcome {
24
+ readonly status: "failed";
25
+ readonly error: ExecutionError;
26
+ /** What the nodes that finished before the error reported. */
27
+ readonly outputs: Outputs;
28
+ readonly diagnostics: readonly Diagnostic[];
29
+ }
30
+
31
+ /** The job was cancelled or interrupted, by this client or another. */
32
+ export interface CancelledOutcome {
33
+ readonly status: "cancelled";
34
+ /** What the nodes that finished before the cancel reported. */
35
+ readonly outputs: Outputs;
36
+ readonly diagnostics: readonly Diagnostic[];
37
+ }
38
+
39
+ /** How a job ended, as the server's record of it says. */
40
+ export type Outcome = SuccessOutcome | RejectedOutcome | FailedOutcome | CancelledOutcome;
41
+
42
+ /** The error a node raised. */
43
+ export interface ExecutionError {
44
+ readonly node: NodeId;
45
+ readonly classType: string;
46
+ /** The exception's class, qualified: `torch.OutOfMemoryError`, `ValueError`. */
47
+ readonly exceptionType: string;
48
+ readonly message: string;
49
+ readonly traceback: readonly string[];
50
+ /** The nodes that had finished. */
51
+ readonly executed: readonly NodeId[];
52
+ }
package/src/outputs.ts ADDED
@@ -0,0 +1,69 @@
1
+ import type { NodeHandle, NodeId, JsonObject, Out } from "@tscomfy/core";
2
+ import type { Outcome } from "./outcome.ts";
3
+ import { nodeIdOf } from "./read-outputs.ts";
4
+
5
+ /** Which of the server's folders a file is in. */
6
+ export type FolderType = "output" | "temp" | "input";
7
+
8
+ /** A file on the server, as outputs name it and `/view` serves it. */
9
+ export interface FileRef {
10
+ readonly filename: string;
11
+ /** The folder under {@link FileRef.type}'s directory; `""` for none. */
12
+ readonly subfolder: string;
13
+ readonly type: FolderType;
14
+ }
15
+
16
+ /** What a file holds, from the server's metadata, the file's extension, or its output key. */
17
+ export type FileKind = "image" | "video" | "audio" | "3d" | "file";
18
+
19
+ /** A file a node produced. */
20
+ export interface OutputFile extends FileRef {
21
+ readonly kind: FileKind;
22
+ /**
23
+ * The node marked its images animated. Animated WEBP and PNG stay `image`; core's video nodes
24
+ * report their files under `images` with this flag, and their kind is `video`.
25
+ */
26
+ readonly animated: boolean;
27
+ /** The key of the node's UI output it was listed under: `images`, `audio`, `3d`, `gifs`, … */
28
+ readonly key: string;
29
+ /** Pixels, when the server measured the file. */
30
+ readonly width?: number;
31
+ readonly height?: number;
32
+ }
33
+
34
+ /** What one node reported when it ran: its files, its text, and the raw record. */
35
+ export interface NodeOutput {
36
+ /** The node, by its id in the prompt (the builder's id for tscomfy workflows). */
37
+ readonly node: NodeId;
38
+ /** Every file entry under any key, in the order the node listed them. */
39
+ readonly files: readonly OutputFile[];
40
+ /** The strings under `text` (PreviewAny, text previews). */
41
+ readonly text: readonly string[];
42
+ /** The node's UI output as the server sent it: lists by key. */
43
+ readonly ui: JsonObject;
44
+ }
45
+
46
+ /** Every node's output, by node id, in prompt order. */
47
+ export type Outputs = ReadonlyMap<NodeId, NodeOutput>;
48
+
49
+ /** A node: its id, its handle (`n.SaveImage(…)`), or one of its outputs. */
50
+ export type NodeRef = NodeId | NodeHandle | Out<string>;
51
+
52
+ /**
53
+ * What a node (by id, handle or output) reported, or `undefined` when it reported nothing, didn't
54
+ * run, or the job was rejected.
55
+ */
56
+ export function outputOf(outcome: Outcome, node: NodeRef): NodeOutput | undefined {
57
+ return outcome.status === "rejected" ? undefined : outcome.outputs.get(nodeIdOf(node));
58
+ }
59
+
60
+ /**
61
+ * The files of the given nodes (by id, handle or output), or of every node when none is given, in
62
+ * prompt order. Empty for a rejected job, which has no outputs.
63
+ */
64
+ export function outputFiles(outcome: Outcome, ...nodes: readonly NodeRef[]): readonly OutputFile[] {
65
+ if (outcome.status === "rejected") return [];
66
+ const outputs = outcome.outputs;
67
+ if (nodes.length === 0) return [...outputs.values()].flatMap((output) => output.files);
68
+ return nodes.flatMap((node) => outputs.get(nodeIdOf(node))?.files ?? []);
69
+ }