sdcpp-job-wait.js 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. /**
  2. * @node sdcpp-job-wait
  3. * @name SD.cpp Wait For Job
  4. * @category sdcpp
  5. * @version 1.0.0
  6. * @description Poll an sdcpp-restapi job until it finishes, fails or the wait runs out
  7. * @icon hourglass
  8. */
  9. const configSchema = {
  10. type: 'object',
  11. properties: {
  12. serverUrl: {
  13. type: 'string',
  14. title: 'Server URL',
  15. description: 'Base address of the sdcpp-restapi server',
  16. default: 'http://localhost:8077'
  17. },
  18. credentialId: {
  19. type: 'string',
  20. title: 'Credential',
  21. description: 'A basic credential holding the sdcpp-restapi username and password',
  22. dynamicOptions: {
  23. source: 'credentials',
  24. filter: { type: ['basic'] }
  25. }
  26. },
  27. jobId: {
  28. type: 'string',
  29. title: 'Job ID',
  30. description: 'Id of the job to wait for, normally {{$json.jobId}} from an SD.cpp Generate node'
  31. },
  32. pollInterval: {
  33. type: 'number',
  34. title: 'Poll Interval (ms)',
  35. description: 'How long to pause between status checks. The server renders serially, so polling faster than a second gains nothing',
  36. default: 2000
  37. },
  38. maxWait: {
  39. type: 'number',
  40. title: 'Maximum Wait (ms)',
  41. description: 'Give up after this long and report the job as still running. This holds one execution slot for the whole wait, so keep it close to how long a render really takes on this server',
  42. default: 600000
  43. },
  44. failOnTimeout: {
  45. type: 'boolean',
  46. title: 'Fail If The Wait Runs Out',
  47. description: 'Throw when the maximum wait passes and the job is still going. Leave off to return the unfinished job and decide in the workflow. The job itself keeps running either way',
  48. default: true
  49. },
  50. failOnJobFailure: {
  51. type: 'boolean',
  52. title: 'Fail If The Job Failed',
  53. description: 'Throw when the job ends in the failed or cancelled state, instead of returning it as data',
  54. default: true
  55. },
  56. timeout: {
  57. type: 'number',
  58. title: 'Request Timeout (ms)',
  59. description: 'Timeout for each individual status request, not for the wait as a whole',
  60. default: 30000
  61. }
  62. },
  63. required: ['credentialId']
  64. };
  65. const inputSchema = {
  66. type: 'object',
  67. properties: {
  68. data: { type: 'any' }
  69. }
  70. };
  71. const outputSchema = {
  72. type: 'object',
  73. properties: {
  74. jobId: { type: 'string' },
  75. status: { type: 'string' },
  76. type: { type: 'string' },
  77. done: { type: 'boolean', description: 'False only when the wait ran out while the job was still going' },
  78. succeeded: { type: 'boolean' },
  79. timedOut: { type: 'boolean', description: 'True when the maximum wait passed before the job finished' },
  80. step: { type: 'number' },
  81. totalSteps: { type: 'number' },
  82. percent: { type: 'number' },
  83. outputs: { type: 'array', description: 'Relative output paths, ready for an SD.cpp Fetch Output node' },
  84. urls: { type: 'array' },
  85. error: { type: 'string' },
  86. waitedMs: { type: 'number', description: 'How long this node actually waited' },
  87. polls: { type: 'number', description: 'How many status requests it made' },
  88. params: { type: 'object' },
  89. modelSettings: { type: 'object' }
  90. }
  91. };
  92. function normalizeServer(url) {
  93. const value = String(url || '').trim();
  94. if (!value) {
  95. throw new Error('SD.cpp: a server URL is required, such as http://localhost:8077');
  96. }
  97. return value.replace(/\/+$/, '');
  98. }
  99. function readCredential(credentialId) {
  100. const auth = smartbotic.credentials.get(credentialId);
  101. if (!auth || auth.success !== true) {
  102. throw new Error('SD.cpp: could not read the credential: ' +
  103. ((auth && auth.error) || 'unknown error'));
  104. }
  105. const value = auth.headerValue || '';
  106. if (value.indexOf('Basic ') !== 0) {
  107. throw new Error('SD.cpp: the credential must be a basic one, holding the sdcpp-restapi ' +
  108. 'username and password');
  109. }
  110. const decoded = smartbotic.utils.base64Decode(value.substring(6));
  111. const separator = decoded.indexOf(':');
  112. if (separator < 1) {
  113. throw new Error('SD.cpp: the credential is malformed, expected a username and a password');
  114. }
  115. return {
  116. username: decoded.substring(0, separator),
  117. password: decoded.substring(separator + 1)
  118. };
  119. }
  120. function call(options) {
  121. const response = smartbotic.http.request(options);
  122. let body = response.data;
  123. if (typeof body === 'string' && body.length > 0) {
  124. try {
  125. body = JSON.parse(body);
  126. } catch (e) {
  127. const snippet = body.substring(0, 200).replace(/\s+/g, ' ');
  128. throw new Error('SD.cpp: ' + options.what + ' returned HTTP ' + response.status +
  129. ' with a body that is not JSON: ' + snippet);
  130. }
  131. }
  132. if (response.status < 200 || response.status >= 300) {
  133. const detail = (body && (body.message || body.error)) || ('HTTP ' + response.status);
  134. throw new Error('SD.cpp: ' + options.what + ' failed: ' + detail);
  135. }
  136. return body || {};
  137. }
  138. function login(server, credential, timeout) {
  139. const session = call({
  140. method: 'POST',
  141. url: server + '/auth/login',
  142. headers: { 'Content-Type': 'application/json' },
  143. body: JSON.stringify({
  144. username: credential.username,
  145. password: credential.password
  146. }),
  147. timeout: timeout,
  148. what: 'signing in'
  149. });
  150. if (!session.token) {
  151. throw new Error('SD.cpp: the server accepted the login but returned no token');
  152. }
  153. return session.token;
  154. }
  155. const TERMINAL = ['completed', 'failed', 'cancelled', 'deleted'];
  156. function shapeJob(job, server) {
  157. const status = job.status || 'unknown';
  158. const progress = job.progress || {};
  159. const step = progress.step !== undefined ? progress.step : 0;
  160. const totalSteps = progress.total_steps !== undefined ? progress.total_steps : 0;
  161. const outputs = job.outputs || [];
  162. const urls = [];
  163. for (let i = 0; i < outputs.length; i++) {
  164. urls.push(server + '/output/' + outputs[i]);
  165. }
  166. return {
  167. jobId: job.job_id || '',
  168. status: status,
  169. type: job.type || '',
  170. done: TERMINAL.indexOf(status) !== -1,
  171. succeeded: status === 'completed',
  172. step: step,
  173. totalSteps: totalSteps,
  174. percent: totalSteps > 0 ? Math.round((step / totalSteps) * 100) : -1,
  175. outputs: outputs,
  176. urls: urls,
  177. error: job.error || '',
  178. createdAt: job.created_at || '',
  179. startedAt: job.started_at || '',
  180. completedAt: job.completed_at || '',
  181. params: job.params || {},
  182. modelSettings: job.model_settings || {}
  183. };
  184. }
  185. async function execute(config, input, context) {
  186. const server = normalizeServer(config.serverUrl);
  187. const timeout = config.timeout || 30000;
  188. const pollInterval = config.pollInterval > 0 ? config.pollInterval : 2000;
  189. const maxWait = config.maxWait > 0 ? config.maxWait : 600000;
  190. const jobId = String(config.jobId || '').trim();
  191. if (!jobId) {
  192. throw new Error('SD.cpp: a job id is required');
  193. }
  194. const credential = readCredential(config.credentialId);
  195. // Signing in once and reusing the token for every poll. A long render can
  196. // mean hundreds of checks, and logging in for each one would triple the
  197. // request count for no reason.
  198. const token = login(server, credential, timeout);
  199. const startedAt = Date.now();
  200. let polls = 0;
  201. let result = null;
  202. while (true) {
  203. const job = call({
  204. method: 'GET',
  205. url: server + '/queue/' + encodeURIComponent(jobId),
  206. headers: { 'Authorization': 'Bearer ' + token },
  207. timeout: timeout,
  208. what: 'reading job ' + jobId
  209. });
  210. polls++;
  211. result = shapeJob(job, server);
  212. if (result.done) {
  213. break;
  214. }
  215. // Check the clock before sleeping rather than after, so the node cannot
  216. // overshoot the maximum wait by a whole poll interval.
  217. const elapsed = Date.now() - startedAt;
  218. if (elapsed + pollInterval >= maxWait) {
  219. result.timedOut = true;
  220. break;
  221. }
  222. smartbotic.utils.sleep(pollInterval);
  223. }
  224. result.waitedMs = Date.now() - startedAt;
  225. result.polls = polls;
  226. result.timedOut = result.timedOut === true;
  227. if (result.timedOut) {
  228. // The job is untouched and still queued on the server. Reporting the id
  229. // back means a later run can pick it up rather than re-rendering it.
  230. smartbotic.log.warn('SD.cpp: stopped waiting for job ' + jobId + ' after ' +
  231. result.waitedMs + 'ms, it is still ' + result.status);
  232. if (config.failOnTimeout !== false) {
  233. throw new Error('SD.cpp: job ' + jobId + ' was still ' + result.status +
  234. ' after ' + result.waitedMs + 'ms. It is still running on the server; ' +
  235. 'raise the maximum wait or check it again later with SD.cpp Job Status');
  236. }
  237. return result;
  238. }
  239. smartbotic.log.info('SD.cpp: job ' + jobId + ' finished as ' + result.status +
  240. ' after ' + result.waitedMs + 'ms and ' + polls + ' polls');
  241. if (config.failOnJobFailure !== false &&
  242. (result.status === 'failed' || result.status === 'cancelled')) {
  243. throw new Error('SD.cpp: job ' + jobId + ' ended as ' + result.status + ': ' +
  244. (result.error || 'no reason given'));
  245. }
  246. return result;
  247. }
  248. module.exports = { configSchema, inputSchema, outputSchema, execute };