openclaw-zero-video 0.0.0-stage → 0.1.4

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.
package/dist/jobs.js ADDED
@@ -0,0 +1,689 @@
1
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
+ import { createReadStream } from 'node:fs';
3
+ import { copyFile, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { execFile } from 'node:child_process';
6
+ import { promisify } from 'node:util';
7
+ import { ToolError, assertJobId, assertPrompt } from './config.js';
8
+ const execFileAsync = promisify(execFile);
9
+ const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
10
+ function nowIso() {
11
+ return new Date().toISOString();
12
+ }
13
+ function isTerminal(state) {
14
+ return TERMINAL.has(state);
15
+ }
16
+ function sleep(ms, signal) {
17
+ return new Promise((resolve, reject) => {
18
+ if (signal?.aborted) {
19
+ reject(new ToolError('CANCELLED'));
20
+ return;
21
+ }
22
+ const timer = setTimeout(() => {
23
+ signal?.removeEventListener('abort', onAbort);
24
+ resolve();
25
+ }, ms);
26
+ const onAbort = () => {
27
+ clearTimeout(timer);
28
+ reject(new ToolError('CANCELLED'));
29
+ };
30
+ signal?.addEventListener('abort', onAbort, { once: true });
31
+ });
32
+ }
33
+ function parseBalance(text) {
34
+ const match = text.replace(/,/g, '').match(/-?\d+(?:\.\d+)?/);
35
+ if (!match)
36
+ return null;
37
+ const value = Number(match[0]);
38
+ return Number.isFinite(value) ? value : null;
39
+ }
40
+ function sha256Text(value) {
41
+ return createHash('sha256').update(value).digest('hex');
42
+ }
43
+ function errorCode(error) {
44
+ return error instanceof ToolError ? error.code : 'TOOL_FAILED';
45
+ }
46
+ function errorMessage(error) {
47
+ const message = error instanceof Error ? error.message : String(error);
48
+ return message.replace(/https?:\/\/\S+/g, '[url]').slice(0, 400);
49
+ }
50
+ function classifySubmitError(message) {
51
+ if (/moderation|policy|violat|blocked|inappropriate|safety|not allowed/i.test(message)) {
52
+ return 'MODERATION_BLOCKED';
53
+ }
54
+ if (/credit|quota|limit|insufficient/i.test(message))
55
+ return 'QUOTA_EXHAUSTED';
56
+ return 'SUBMIT_FAILED';
57
+ }
58
+ function safeName(value) {
59
+ return value.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 120) || 'file';
60
+ }
61
+ function quoteRemote(value) {
62
+ return `'${value.replace(/'/g, `'\\''`)}'`;
63
+ }
64
+ export class JobStore {
65
+ config;
66
+ jobsDir;
67
+ constructor(config) {
68
+ this.config = config;
69
+ this.jobsDir = path.join(config.stateDir, 'jobs');
70
+ }
71
+ async init() {
72
+ await mkdir(this.jobsDir, { recursive: true, mode: 0o700 });
73
+ }
74
+ async write(record) {
75
+ await this.init();
76
+ const target = path.join(this.jobsDir, `${record.id}.json`);
77
+ const temp = `${target}.tmp-${process.pid}-${Date.now()}`;
78
+ await writeFile(temp, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
79
+ await rename(temp, target);
80
+ }
81
+ async create(record) {
82
+ await this.write(record);
83
+ return record;
84
+ }
85
+ async get(id) {
86
+ assertJobId(id);
87
+ try {
88
+ const raw = await readFile(path.join(this.jobsDir, `${id}.json`), 'utf8');
89
+ return JSON.parse(raw);
90
+ }
91
+ catch (error) {
92
+ if (error.code === 'ENOENT')
93
+ throw new ToolError('UNKNOWN_JOB');
94
+ throw new ToolError('JOB_FILE_INVALID');
95
+ }
96
+ }
97
+ async update(id, patch) {
98
+ const current = await this.get(id);
99
+ const next = { ...current, ...patch, updatedAt: nowIso() };
100
+ await this.write(next);
101
+ return next;
102
+ }
103
+ async listRecent(limit = 20) {
104
+ await this.init();
105
+ const names = (await readdir(this.jobsDir)).filter((name) => name.endsWith('.json'));
106
+ const records = [];
107
+ for (const name of names) {
108
+ try {
109
+ records.push(JSON.parse(await readFile(path.join(this.jobsDir, name), 'utf8')));
110
+ }
111
+ catch {
112
+ // Skip a corrupt record; it is reported by explicit get().
113
+ }
114
+ }
115
+ records.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
116
+ return records.slice(0, limit);
117
+ }
118
+ async findActiveByProvider(site) {
119
+ const records = await this.listRecent(500);
120
+ return records.find((record) => record.site === site && !isTerminal(record.state));
121
+ }
122
+ }
123
+ export class LedgerStore {
124
+ config;
125
+ ledgerDir;
126
+ constructor(config) {
127
+ this.config = config;
128
+ this.ledgerDir = path.join(config.stateDir, 'ledger');
129
+ }
130
+ async read(providerId) {
131
+ await mkdir(this.ledgerDir, { recursive: true, mode: 0o700 });
132
+ try {
133
+ const raw = await readFile(path.join(this.ledgerDir, `${providerId}.json`), 'utf8');
134
+ return JSON.parse(raw);
135
+ }
136
+ catch (error) {
137
+ if (error.code !== 'ENOENT')
138
+ throw new ToolError('LEDGER_INVALID');
139
+ return {
140
+ providerId,
141
+ lastBalance: null,
142
+ spentToday: 0,
143
+ windowStart: nowIso(),
144
+ updatedAt: nowIso(),
145
+ };
146
+ }
147
+ }
148
+ async write(ledger) {
149
+ await mkdir(this.ledgerDir, { recursive: true, mode: 0o700 });
150
+ const target = path.join(this.ledgerDir, `${ledger.providerId}.json`);
151
+ const temp = `${target}.tmp-${process.pid}-${Date.now()}`;
152
+ await writeFile(temp, `${JSON.stringify(ledger, null, 2)}\n`, { mode: 0o600 });
153
+ await rename(temp, target);
154
+ }
155
+ async get(providerId) {
156
+ return this.read(providerId);
157
+ }
158
+ async observe(providerId, balance) {
159
+ const ledger = await this.read(providerId);
160
+ if (balance !== null) {
161
+ if (ledger.lastBalance !== null && balance > ledger.lastBalance) {
162
+ ledger.spentToday = 0;
163
+ ledger.windowStart = nowIso();
164
+ }
165
+ ledger.lastBalance = balance;
166
+ }
167
+ ledger.updatedAt = nowIso();
168
+ await this.write(ledger);
169
+ return ledger;
170
+ }
171
+ canSpend(provider, ledger, balance) {
172
+ if (provider.dailyCap > 0 && ledger.spentToday + provider.creditCost > provider.dailyCap) {
173
+ throw new ToolError('DAILY_CAP_REACHED');
174
+ }
175
+ if (balance !== null && balance < provider.creditCost) {
176
+ throw new ToolError('QUOTA_EXHAUSTED');
177
+ }
178
+ }
179
+ async record(providerId, before, after, fallbackCost) {
180
+ const ledger = await this.read(providerId);
181
+ let spent = fallbackCost;
182
+ if (before !== null && after !== null)
183
+ spent = Math.max(0, before - after);
184
+ ledger.spentToday += spent;
185
+ if (after !== null)
186
+ ledger.lastBalance = after;
187
+ ledger.updatedAt = nowIso();
188
+ await this.write(ledger);
189
+ return ledger;
190
+ }
191
+ }
192
+ export class Ssh {
193
+ config;
194
+ constructor(config) {
195
+ this.config = config;
196
+ }
197
+ validate(remote) {
198
+ const prefix = `${this.config.cosmosDownloadsDir}/`;
199
+ if (!remote.startsWith(prefix) || remote.includes('..'))
200
+ throw new ToolError('INVALID_REMOTE_PATH');
201
+ }
202
+ async run(command, timeout = 30_000) {
203
+ const { stdout } = await execFileAsync('ssh', [
204
+ '-i', this.config.sshKey,
205
+ '-o', 'BatchMode=yes',
206
+ '-o', 'ConnectTimeout=6',
207
+ '-o', 'StrictHostKeyChecking=no',
208
+ `root@${this.config.sshHost}`,
209
+ command,
210
+ ], { timeout, maxBuffer: 8 * 1024 * 1024 });
211
+ return String(stdout);
212
+ }
213
+ container() {
214
+ return this.config.containerName;
215
+ }
216
+ transferDir() {
217
+ return `/tmp/zero-video-transfer/${randomUUID()}`;
218
+ }
219
+ async mkdir(remote) {
220
+ this.validate(remote);
221
+ await this.run(`docker exec ${this.container()} mkdir -p ${quoteRemote(remote)}`);
222
+ }
223
+ async list(remote) {
224
+ this.validate(remote);
225
+ const stdout = await this.run(`docker exec ${this.container()} find ${quoteRemote(remote)} -maxdepth 1 -type f -printf '%T@\\t%f\\t%s\\n' 2>/dev/null | sort -rn | head -20`);
226
+ return stdout
227
+ .split('\n')
228
+ .map((line) => line.trim())
229
+ .filter(Boolean)
230
+ .map((line) => {
231
+ const [mtime, name, size] = line.split('\t');
232
+ return { mtime: Number(mtime), name, size: Number(size) };
233
+ })
234
+ .filter((item) => item.name && Number.isFinite(item.size));
235
+ }
236
+ async copyFrom(remote, local) {
237
+ this.validate(remote);
238
+ const hostDir = this.transferDir();
239
+ const name = path.posix.basename(remote);
240
+ try {
241
+ await this.run(`mkdir -p ${quoteRemote(hostDir)}`);
242
+ await this.run(`docker cp ${quoteRemote(`${this.container()}:${remote}`)} ${quoteRemote(hostDir)}/`, 120_000);
243
+ await execFileAsync('scp', [
244
+ '-q',
245
+ '-i', this.config.sshKey,
246
+ '-o', 'BatchMode=yes',
247
+ '-o', 'ConnectTimeout=6',
248
+ '-o', 'StrictHostKeyChecking=no',
249
+ `root@${this.config.sshHost}:${hostDir}/${name}`,
250
+ local,
251
+ ], { timeout: 120_000, maxBuffer: 8 * 1024 * 1024 });
252
+ }
253
+ finally {
254
+ await this.run(`rm -rf ${quoteRemote(hostDir)}`).catch(() => undefined);
255
+ }
256
+ }
257
+ async put(local, remote) {
258
+ this.validate(remote);
259
+ const hostDir = this.transferDir();
260
+ const name = safeName(path.basename(local));
261
+ try {
262
+ await this.run(`mkdir -p ${quoteRemote(hostDir)}`);
263
+ await execFileAsync('scp', [
264
+ '-q',
265
+ '-i', this.config.sshKey,
266
+ '-o', 'BatchMode=yes',
267
+ '-o', 'ConnectTimeout=6',
268
+ '-o', 'StrictHostKeyChecking=no',
269
+ local,
270
+ `root@${this.config.sshHost}:${hostDir}/${name}`,
271
+ ], { timeout: 120_000, maxBuffer: 8 * 1024 * 1024 });
272
+ await this.run(`docker cp ${quoteRemote(`${hostDir}/${name}`)} ${quoteRemote(`${this.container()}:${remote}`)}`, 120_000);
273
+ }
274
+ finally {
275
+ await this.run(`rm -rf ${quoteRemote(hostDir)}`).catch(() => undefined);
276
+ }
277
+ }
278
+ async remove(remote) {
279
+ this.validate(remote);
280
+ await this.run(`docker exec ${this.container()} rm -rf ${quoteRemote(remote)}`);
281
+ }
282
+ }
283
+ export class MediaStore {
284
+ config;
285
+ constructor(config) {
286
+ this.config = config;
287
+ }
288
+ async ffprobe(file) {
289
+ let stdout;
290
+ try {
291
+ const result = await execFileAsync('ffprobe', ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', file], { timeout: 30_000, maxBuffer: 8 * 1024 * 1024 });
292
+ stdout = String(result.stdout);
293
+ }
294
+ catch {
295
+ throw new ToolError('VERIFY_FAILED');
296
+ }
297
+ const data = JSON.parse(stdout);
298
+ const stream = data.streams?.find((item) => item.codec_type === 'video');
299
+ const durationSec = Number(data.format?.duration ?? stream?.duration ?? 0);
300
+ if (!stream || !Number.isFinite(durationSec) || durationSec <= 0)
301
+ throw new ToolError('INVALID_MEDIA');
302
+ return {
303
+ durationSec,
304
+ width: stream.width,
305
+ height: stream.height,
306
+ codec: stream.codec_name,
307
+ };
308
+ }
309
+ async sha256(file) {
310
+ const hash = createHash('sha256');
311
+ for await (const chunk of createReadStream(file))
312
+ hash.update(chunk);
313
+ return hash.digest('hex');
314
+ }
315
+ async verify(file) {
316
+ const info = await stat(file);
317
+ if (info.size <= 0)
318
+ throw new ToolError('INVALID_MEDIA');
319
+ if (info.size > this.config.maxDownloadBytes)
320
+ throw new ToolError('TOO_LARGE');
321
+ const ext = path.extname(file).toLowerCase();
322
+ if (!['.mp4', '.webm', '.mov', '.mkv'].includes(ext))
323
+ throw new ToolError('INVALID_MEDIA');
324
+ const probe = await this.ffprobe(file);
325
+ return {
326
+ path: file,
327
+ bytes: info.size,
328
+ sha256: await this.sha256(file),
329
+ ...probe,
330
+ };
331
+ }
332
+ async store(tempFile, job) {
333
+ const artifact = await this.verify(tempFile);
334
+ const date = new Date();
335
+ const dir = path.join(this.config.mediaDir, String(date.getUTCFullYear()), String(date.getUTCMonth() + 1).padStart(2, '0'));
336
+ await mkdir(dir, { recursive: true });
337
+ const ext = path.extname(tempFile).toLowerCase();
338
+ const target = path.join(dir, `${job.id}${ext}`);
339
+ try {
340
+ await rename(tempFile, target);
341
+ }
342
+ catch {
343
+ await copyFile(tempFile, target);
344
+ await rm(tempFile, { force: true });
345
+ }
346
+ artifact.path = target;
347
+ const manifest = {
348
+ ...job,
349
+ artifact,
350
+ storedAt: nowIso(),
351
+ };
352
+ await writeFile(`${target}.json`, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
353
+ return artifact;
354
+ }
355
+ }
356
+ export class Runner {
357
+ config;
358
+ driver;
359
+ providers;
360
+ jobs;
361
+ ledgers;
362
+ media;
363
+ ssh;
364
+ log;
365
+ active = new Map();
366
+ constructor(config, driver, providers, jobs, ledgers, media, ssh, log = () => undefined) {
367
+ this.config = config;
368
+ this.driver = driver;
369
+ this.providers = providers;
370
+ this.jobs = jobs;
371
+ this.ledgers = ledgers;
372
+ this.media = media;
373
+ this.ssh = ssh;
374
+ this.log = log;
375
+ }
376
+ async submit(request, signal) {
377
+ const provider = await this.providers.get(request.site);
378
+ if (!provider.enabled)
379
+ throw new ToolError('PROVIDER_DISABLED');
380
+ if (!provider.busyIndicator && !provider.readyIndicator)
381
+ throw new ToolError('PROVIDER_INCOMPLETE');
382
+ if (this.active.has(provider.id))
383
+ throw new ToolError('PROVIDER_BUSY');
384
+ if (this.active.size >= this.config.maxParallel)
385
+ throw new ToolError('TOO_MANY_JOBS');
386
+ const prompt = assertPrompt(request.prompt, this.config);
387
+ if (request.negativePrompt !== undefined && request.negativePrompt.length > 2000) {
388
+ throw new ToolError('INVALID_REQUEST');
389
+ }
390
+ if (request.durationSec !== undefined && (!Number.isInteger(request.durationSec) || request.durationSec < 1 || request.durationSec > 60)) {
391
+ throw new ToolError('INVALID_REQUEST');
392
+ }
393
+ if (request.imagePath !== undefined) {
394
+ const info = await stat(request.imagePath).catch(() => undefined);
395
+ if (!info?.isFile())
396
+ throw new ToolError('IMAGE_NOT_FOUND');
397
+ if (info.size > 50 * 1024 * 1024)
398
+ throw new ToolError('IMAGE_TOO_LARGE');
399
+ if (!['.jpg', '.jpeg', '.png', '.webp'].includes(path.extname(request.imagePath).toLowerCase())) {
400
+ throw new ToolError('INVALID_IMAGE');
401
+ }
402
+ }
403
+ const id = `${provider.id}-${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`;
404
+ let job = await this.jobs.create({
405
+ id,
406
+ site: provider.id,
407
+ state: 'queued',
408
+ prompt: this.config.storePrompts ? prompt : undefined,
409
+ promptHash: sha256Text(prompt),
410
+ negativePrompt: request.negativePrompt,
411
+ model: request.model,
412
+ durationSec: request.durationSec,
413
+ aspect: request.aspect,
414
+ imagePath: request.imagePath,
415
+ createdAt: nowIso(),
416
+ updatedAt: nowIso(),
417
+ });
418
+ let page;
419
+ try {
420
+ job = await this.jobs.update(job.id, { state: 'opening' });
421
+ page = await this.driver.open(provider.url, signal);
422
+ const promptReady = await page.waitFor(provider.promptInput, 20_000, signal);
423
+ if (!promptReady) {
424
+ if (provider.loginCheck && !(await page.isVisible(provider.loginCheck).catch(() => false))) {
425
+ throw new ToolError('LOGIN_REQUIRED');
426
+ }
427
+ throw new ToolError('INPUT_NOT_FOUND');
428
+ }
429
+ if (provider.loginCheck && !(await page.isVisible(provider.loginCheck))) {
430
+ throw new ToolError('LOGIN_REQUIRED');
431
+ }
432
+ // Let the SPA finish mounting the editor and submit handlers before typing.
433
+ await sleep(provider.readinessMs ?? 3000, signal);
434
+ if (request.imagePath) {
435
+ if (!provider.imageInput)
436
+ throw new ToolError('IMAGE_INPUT_NOT_CONFIGURED');
437
+ await this.ssh.mkdir(`${this.config.cosmosDownloadsDir}/uploads/${job.id}`);
438
+ const remote = `${this.config.cosmosDownloadsDir}/uploads/${job.id}/${safeName(path.basename(request.imagePath))}`;
439
+ await this.ssh.put(request.imagePath, remote);
440
+ await page.setFileInput(provider.imageInput, remote);
441
+ await sleep(750, signal);
442
+ }
443
+ await page.fill(provider.promptInput, prompt);
444
+ await sleep(300, signal);
445
+ await this.applyOptions(page, provider, request);
446
+ const balance = await this.readBalance(page, provider);
447
+ const ledger = await this.ledgers.observe(provider.id, balance);
448
+ this.ledgers.canSpend(provider, ledger, balance);
449
+ const enabled = await this.waitEnabled(page, provider.submitButton, 8_000, signal);
450
+ if (!enabled)
451
+ throw new ToolError('SUBMIT_DISABLED');
452
+ if (!(await page.click(provider.submitButton)))
453
+ throw new ToolError('SUBMIT_DISABLED');
454
+ await sleep(1000, signal);
455
+ if (provider.errorText && (await page.isVisible(provider.errorText).catch(() => false))) {
456
+ const message = (await page.text(provider.errorText).catch(() => '')).slice(0, 500);
457
+ throw new ToolError(classifySubmitError(message), message || 'SUBMIT_FAILED');
458
+ }
459
+ job = await this.jobs.update(job.id, {
460
+ state: 'submitted',
461
+ submittedAt: nowIso(),
462
+ targetId: page.targetId,
463
+ providerUrl: provider.url,
464
+ creditsBefore: balance,
465
+ });
466
+ this.active.set(provider.id, { jobId: job.id, page });
467
+ this.log({ event: 'submitted', jobId: job.id, provider: provider.id, balance });
468
+ return job;
469
+ }
470
+ catch (error) {
471
+ if (page)
472
+ await page.close().catch(() => undefined);
473
+ job = await this.jobs.update(job.id, {
474
+ state: 'failed',
475
+ errorCode: errorCode(error),
476
+ errorMessage: errorMessage(error),
477
+ completedAt: nowIso(),
478
+ });
479
+ this.log({ event: 'submit_failed', jobId: job.id, provider: provider.id, errorCode: job.errorCode });
480
+ throw error;
481
+ }
482
+ }
483
+ async status(request, signal) {
484
+ let job = await this.jobs.get(assertJobId(request.jobId));
485
+ if (isTerminal(job.state))
486
+ return job;
487
+ const provider = await this.providers.get(job.site);
488
+ const runtime = this.active.get(provider.id);
489
+ if (!runtime || runtime.jobId !== job.id) {
490
+ return this.jobs.update(job.id, {
491
+ state: 'needs_attention',
492
+ errorCode: 'BROWSER_LOST',
493
+ errorMessage: 'The provider tab is no longer attached; the render may still exist in the provider library.',
494
+ });
495
+ }
496
+ if (request.retryDownload) {
497
+ return this.download(job, provider, runtime.page);
498
+ }
499
+ const deadline = Date.now() + Math.min(Math.max(request.waitSec ?? 0, 0), 10) * 1000;
500
+ do {
501
+ if (signal?.aborted)
502
+ throw new ToolError('CANCELLED');
503
+ if (provider.errorText) {
504
+ const visible = await runtime.page.isVisible(provider.errorText).catch(() => false);
505
+ if (visible) {
506
+ const message = (await runtime.page.text(provider.errorText).catch(() => '')).slice(0, 500);
507
+ return this.jobs.update(job.id, {
508
+ state: 'failed',
509
+ errorCode: classifySubmitError(message),
510
+ errorMessage: message || 'GENERATION_FAILED',
511
+ completedAt: nowIso(),
512
+ });
513
+ }
514
+ }
515
+ let busy = false;
516
+ if (provider.busyIndicator) {
517
+ busy = await runtime.page.isVisible(provider.busyIndicator).catch(() => false);
518
+ }
519
+ const readySignal = provider.readyIndicator
520
+ ? await runtime.page.isVisible(provider.readyIndicator).catch(() => false)
521
+ : true;
522
+ const ready = readySignal
523
+ && await runtime.page.isVisible(provider.downloadButton).catch(() => false)
524
+ && await runtime.page.enabled(provider.downloadButton).catch(() => false);
525
+ if (ready && !busy)
526
+ return this.download(job, provider, runtime.page);
527
+ if (busy && job.state === 'submitted') {
528
+ job = await this.jobs.update(job.id, { state: 'generating' });
529
+ }
530
+ const started = Date.parse(job.submittedAt ?? job.createdAt);
531
+ if (Date.now() - started > provider.timeoutMs) {
532
+ return this.jobs.update(job.id, {
533
+ state: 'failed',
534
+ errorCode: 'TIMEOUT',
535
+ errorMessage: `No downloadable result after ${provider.timeoutMs} ms.`,
536
+ completedAt: nowIso(),
537
+ });
538
+ }
539
+ if (Date.now() >= deadline)
540
+ break;
541
+ await sleep(5000, signal);
542
+ } while (Date.now() < deadline);
543
+ return this.jobs.get(job.id);
544
+ }
545
+ async cancel(jobId) {
546
+ const job = await this.jobs.get(assertJobId(jobId));
547
+ if (isTerminal(job.state))
548
+ return job;
549
+ const provider = await this.providers.get(job.site);
550
+ const runtime = this.active.get(provider.id);
551
+ if (runtime && runtime.jobId === job.id) {
552
+ if (provider.cancelButton)
553
+ await runtime.page.click(provider.cancelButton).catch(() => false);
554
+ await runtime.page.close().catch(() => undefined);
555
+ this.active.delete(provider.id);
556
+ }
557
+ return this.jobs.update(job.id, {
558
+ state: 'cancelled',
559
+ errorCode: 'CANCELLED',
560
+ completedAt: nowIso(),
561
+ });
562
+ }
563
+ hasActive(providerId) {
564
+ return this.active.has(providerId);
565
+ }
566
+ activeSummary() {
567
+ return [...this.active.entries()].map(([providerId, runtime]) => ({
568
+ providerId,
569
+ jobId: runtime.jobId,
570
+ }));
571
+ }
572
+ async providerView() {
573
+ const providers = await this.providers.list();
574
+ return Promise.all(providers.map(async (provider) => ({
575
+ ...provider,
576
+ ledger: await this.ledgers.get(provider.id),
577
+ activeJobId: this.active.get(provider.id)?.jobId ?? null,
578
+ })));
579
+ }
580
+ async applyOptions(page, provider, request) {
581
+ const requested = [
582
+ ['duration', request.durationSec === undefined ? undefined : String(request.durationSec)],
583
+ ['aspect', request.aspect],
584
+ ['model', request.model],
585
+ ];
586
+ for (const [name, value] of requested) {
587
+ if (value === undefined)
588
+ continue;
589
+ const option = provider.options?.find((item) => item.name === name && item.value === value);
590
+ if (!option)
591
+ throw new ToolError('OPTION_NOT_CONFIGURED');
592
+ if (option.action === 'select') {
593
+ await page.evaluate(`(a) => {
594
+ const el = document.querySelector(a.selector);
595
+ if (!el) throw new Error('SELECTOR_CHANGED');
596
+ el.value = a.value;
597
+ el.dispatchEvent(new Event('change', { bubbles: true }));
598
+ }`, { selector: option.selector, value: option.value });
599
+ }
600
+ else if (!(await page.click(option.selector))) {
601
+ throw new ToolError('SELECTOR_CHANGED');
602
+ }
603
+ await sleep(300);
604
+ }
605
+ }
606
+ async readBalance(page, provider) {
607
+ if (!provider.creditBalance)
608
+ return null;
609
+ const text = await page.text(provider.creditBalance).catch(() => '');
610
+ return parseBalance(text);
611
+ }
612
+ async waitEnabled(page, selector, timeoutMs, signal) {
613
+ const deadline = Date.now() + timeoutMs;
614
+ while (Date.now() < deadline) {
615
+ if (signal?.aborted)
616
+ throw new ToolError('CANCELLED');
617
+ if (await page.enabled(selector).catch(() => false))
618
+ return true;
619
+ await sleep(250, signal);
620
+ }
621
+ return false;
622
+ }
623
+ async waitForRemoteFile(remoteDir) {
624
+ const deadline = Date.now() + this.config.downloadTimeoutMs;
625
+ let last;
626
+ let stable = 0;
627
+ while (Date.now() < deadline) {
628
+ const files = (await this.ssh.list(remoteDir).catch(() => []))
629
+ .filter((file) => !file.name.endsWith('.crdownload'));
630
+ if (files.length) {
631
+ const candidate = files[0];
632
+ if (last?.name === candidate.name && last.size === candidate.size)
633
+ stable += 1;
634
+ else
635
+ stable = 0;
636
+ last = { name: candidate.name, size: candidate.size };
637
+ if (stable >= 2)
638
+ return candidate.name;
639
+ }
640
+ await sleep(3000);
641
+ }
642
+ throw new ToolError('DOWNLOAD_FAILED');
643
+ }
644
+ async download(job, provider, page) {
645
+ await this.jobs.update(job.id, { state: 'downloading' });
646
+ const remoteDir = `${this.config.cosmosDownloadsDir}/${job.id}`;
647
+ try {
648
+ await this.ssh.mkdir(remoteDir);
649
+ await page.setDownloadBehavior(remoteDir);
650
+ if (!(await page.click(provider.downloadButton)))
651
+ throw new ToolError('DOWNLOAD_FAILED');
652
+ if (provider.downloadConfirm) {
653
+ await sleep(700);
654
+ await page.click(provider.downloadConfirm);
655
+ }
656
+ const remoteName = await this.waitForRemoteFile(remoteDir);
657
+ const tempDir = path.join(this.config.stateDir, 'tmp');
658
+ await mkdir(tempDir, { recursive: true, mode: 0o700 });
659
+ const ext = path.extname(remoteName) || '.mp4';
660
+ const local = path.join(tempDir, `${job.id}${ext}`);
661
+ await this.ssh.copyFrom(`${remoteDir}/${remoteName}`, local);
662
+ const artifact = await this.media.store(local, job);
663
+ const balance = await this.readBalance(page, provider);
664
+ await this.ledgers.record(job.site, job.creditsBefore ?? null, balance, provider.creditCost);
665
+ const done = await this.jobs.update(job.id, {
666
+ state: 'succeeded',
667
+ artifact,
668
+ creditsAfter: balance,
669
+ completedAt: nowIso(),
670
+ });
671
+ this.active.delete(provider.id);
672
+ await page.close().catch(() => undefined);
673
+ if (this.config.keepContainerCopyHours === 0) {
674
+ await this.ssh.remove(remoteDir).catch(() => undefined);
675
+ }
676
+ this.log({ event: 'succeeded', jobId: job.id, provider: provider.id, bytes: artifact.bytes });
677
+ return done;
678
+ }
679
+ catch (error) {
680
+ const failed = await this.jobs.update(job.id, {
681
+ state: 'needs_attention',
682
+ errorCode: errorCode(error),
683
+ errorMessage: errorMessage(error),
684
+ });
685
+ this.log({ event: 'download_failed', jobId: job.id, provider: provider.id, errorCode: failed.errorCode });
686
+ return failed;
687
+ }
688
+ }
689
+ }