@reshotdev/screenshot 0.0.1-beta.0
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/LICENSE +190 -0
- package/README.md +388 -0
- package/package.json +64 -0
- package/src/commands/auth.js +259 -0
- package/src/commands/chrome.js +140 -0
- package/src/commands/ci-run.js +123 -0
- package/src/commands/ci-setup.js +288 -0
- package/src/commands/drifts.js +423 -0
- package/src/commands/import-tests.js +309 -0
- package/src/commands/ingest.js +458 -0
- package/src/commands/init.js +633 -0
- package/src/commands/publish.js +1721 -0
- package/src/commands/pull.js +303 -0
- package/src/commands/record.js +94 -0
- package/src/commands/run.js +476 -0
- package/src/commands/setup-wizard.js +740 -0
- package/src/commands/setup.js +137 -0
- package/src/commands/status.js +275 -0
- package/src/commands/sync.js +621 -0
- package/src/commands/ui.js +248 -0
- package/src/commands/validate-docs.js +529 -0
- package/src/index.js +462 -0
- package/src/lib/api-client.js +815 -0
- package/src/lib/capture-engine.js +1623 -0
- package/src/lib/capture-script-runner.js +3120 -0
- package/src/lib/ci-detect.js +137 -0
- package/src/lib/config.js +1240 -0
- package/src/lib/diff-engine.js +642 -0
- package/src/lib/hash.js +74 -0
- package/src/lib/image-crop.js +396 -0
- package/src/lib/matrix.js +89 -0
- package/src/lib/output-path-template.js +318 -0
- package/src/lib/playwright-runner.js +252 -0
- package/src/lib/polished-clip.js +553 -0
- package/src/lib/privacy-engine.js +408 -0
- package/src/lib/progress-tracker.js +142 -0
- package/src/lib/record-browser-injection.js +654 -0
- package/src/lib/record-cdp.js +612 -0
- package/src/lib/record-clip.js +343 -0
- package/src/lib/record-config.js +623 -0
- package/src/lib/record-screenshot.js +360 -0
- package/src/lib/record-terminal.js +123 -0
- package/src/lib/recorder-service.js +781 -0
- package/src/lib/secrets.js +51 -0
- package/src/lib/selector-strategies.js +859 -0
- package/src/lib/standalone-mode.js +400 -0
- package/src/lib/storage-providers.js +569 -0
- package/src/lib/style-engine.js +684 -0
- package/src/lib/ui-api.js +4677 -0
- package/src/lib/ui-assets.js +373 -0
- package/src/lib/ui-executor.js +587 -0
- package/src/lib/variant-injector.js +591 -0
- package/src/lib/viewport-presets.js +454 -0
- package/src/lib/worker-pool.js +118 -0
- package/web/cropper/index.html +436 -0
- package/web/manager/dist/assets/index--ZgioErz.js +507 -0
- package/web/manager/dist/assets/index-n468W0Wr.css +1 -0
- package/web/manager/dist/index.html +27 -0
- package/web/subtitle-editor/index.html +295 -0
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
// ui-executor.js - Job execution and management for UI
|
|
2
|
+
const { spawn } = require("child_process");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const fs = require("fs-extra");
|
|
5
|
+
const { v4: uuidv4 } = require("uuid");
|
|
6
|
+
|
|
7
|
+
const JOBS_FILE = path.join(process.cwd(), ".reshot", "ui-jobs.json");
|
|
8
|
+
const MAX_JOBS_HISTORY = 100;
|
|
9
|
+
|
|
10
|
+
// Track running processes for cancellation
|
|
11
|
+
const runningProcesses = new Map(); // jobId -> { child, timeout }
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Job model
|
|
15
|
+
* @typedef {Object} Job
|
|
16
|
+
* @property {string} id
|
|
17
|
+
* @property {string} type - 'run' | 'publish' | 'record' | 'crop-helper'
|
|
18
|
+
* @property {string} status - 'pending' | 'running' | 'success' | 'failed'
|
|
19
|
+
* @property {string} createdAt
|
|
20
|
+
* @property {string} updatedAt
|
|
21
|
+
* @property {string|null} scenarioKey
|
|
22
|
+
* @property {string[]} logs
|
|
23
|
+
* @property {Object} metadata
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Load jobs from disk
|
|
28
|
+
*/
|
|
29
|
+
function loadJobs() {
|
|
30
|
+
try {
|
|
31
|
+
if (fs.existsSync(JOBS_FILE)) {
|
|
32
|
+
return fs.readJSONSync(JOBS_FILE);
|
|
33
|
+
}
|
|
34
|
+
} catch (error) {
|
|
35
|
+
console.warn("Failed to load jobs file:", error.message);
|
|
36
|
+
}
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Save jobs to disk
|
|
42
|
+
*/
|
|
43
|
+
function saveJobs(jobs) {
|
|
44
|
+
try {
|
|
45
|
+
fs.ensureDirSync(path.dirname(JOBS_FILE));
|
|
46
|
+
// Keep only last MAX_JOBS_HISTORY jobs
|
|
47
|
+
const jobsToSave = jobs.slice(-MAX_JOBS_HISTORY);
|
|
48
|
+
fs.writeJSONSync(JOBS_FILE, jobsToSave, { spaces: 2 });
|
|
49
|
+
} catch (error) {
|
|
50
|
+
console.error("Failed to save jobs file:", error.message);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Create a new job
|
|
56
|
+
*/
|
|
57
|
+
function createJob(type, metadata = {}) {
|
|
58
|
+
const job = {
|
|
59
|
+
id: uuidv4(),
|
|
60
|
+
type,
|
|
61
|
+
status: "pending",
|
|
62
|
+
createdAt: new Date().toISOString(),
|
|
63
|
+
updatedAt: new Date().toISOString(),
|
|
64
|
+
scenarioKey: metadata.scenarioKey || (metadata.scenarioKeys?.length === 1 ? metadata.scenarioKeys[0] : null),
|
|
65
|
+
logs: [],
|
|
66
|
+
metadata,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const jobs = loadJobs();
|
|
70
|
+
jobs.push(job);
|
|
71
|
+
saveJobs(jobs);
|
|
72
|
+
|
|
73
|
+
return job;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Update job status
|
|
78
|
+
*/
|
|
79
|
+
function updateJobStatus(jobId, status, additionalData = {}) {
|
|
80
|
+
const jobs = loadJobs();
|
|
81
|
+
const jobIndex = jobs.findIndex((j) => j.id === jobId);
|
|
82
|
+
|
|
83
|
+
if (jobIndex === -1) {
|
|
84
|
+
throw new Error(`Job ${jobId} not found`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
jobs[jobIndex] = {
|
|
88
|
+
...jobs[jobIndex],
|
|
89
|
+
...additionalData,
|
|
90
|
+
status,
|
|
91
|
+
updatedAt: new Date().toISOString(),
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
saveJobs(jobs);
|
|
95
|
+
return jobs[jobIndex];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Append log to job
|
|
100
|
+
*/
|
|
101
|
+
function appendJobLog(jobId, logLine) {
|
|
102
|
+
const jobs = loadJobs();
|
|
103
|
+
const jobIndex = jobs.findIndex((j) => j.id === jobId);
|
|
104
|
+
|
|
105
|
+
if (jobIndex === -1) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
jobs[jobIndex].logs.push({
|
|
110
|
+
timestamp: new Date().toISOString(),
|
|
111
|
+
message: logLine,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Keep only last 1000 log lines
|
|
115
|
+
if (jobs[jobIndex].logs.length > 1000) {
|
|
116
|
+
jobs[jobIndex].logs = jobs[jobIndex].logs.slice(-1000);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
jobs[jobIndex].updatedAt = new Date().toISOString();
|
|
120
|
+
saveJobs(jobs);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Get job by ID
|
|
125
|
+
*/
|
|
126
|
+
function getJob(jobId) {
|
|
127
|
+
const jobs = loadJobs();
|
|
128
|
+
return jobs.find((j) => j.id === jobId) || null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Get all jobs
|
|
133
|
+
*/
|
|
134
|
+
function getAllJobs(limit = 50) {
|
|
135
|
+
const jobs = loadJobs();
|
|
136
|
+
return jobs.slice(-limit).reverse(); // Most recent first
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Clean up stuck jobs (jobs that have been running for more than 5 minutes without updates)
|
|
141
|
+
*/
|
|
142
|
+
function cleanupStuckJobs() {
|
|
143
|
+
const jobs = loadJobs();
|
|
144
|
+
const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;
|
|
145
|
+
let cleaned = false;
|
|
146
|
+
|
|
147
|
+
for (const job of jobs) {
|
|
148
|
+
if (job.status === "running") {
|
|
149
|
+
const updatedAtDate = new Date(job.updatedAt);
|
|
150
|
+
// Skip if date is invalid
|
|
151
|
+
if (isNaN(updatedAtDate.getTime())) continue;
|
|
152
|
+
const updatedAt = updatedAtDate.getTime();
|
|
153
|
+
if (updatedAt < fiveMinutesAgo) {
|
|
154
|
+
// Job has been running for more than 5 minutes without updates, mark as failed
|
|
155
|
+
const jobIndex = jobs.findIndex((j) => j.id === job.id);
|
|
156
|
+
if (jobIndex !== -1) {
|
|
157
|
+
jobs[jobIndex].status = "failed";
|
|
158
|
+
jobs[jobIndex].updatedAt = new Date().toISOString();
|
|
159
|
+
if (!jobs[jobIndex].metadata) {
|
|
160
|
+
jobs[jobIndex].metadata = {};
|
|
161
|
+
}
|
|
162
|
+
jobs[jobIndex].metadata.error =
|
|
163
|
+
"Job timed out (no activity for 5 minutes) - platform may be unavailable";
|
|
164
|
+
jobs[jobIndex].logs.push({
|
|
165
|
+
timestamp: new Date().toISOString(),
|
|
166
|
+
message:
|
|
167
|
+
"[error] Job was stuck in running state and has been marked as failed",
|
|
168
|
+
});
|
|
169
|
+
cleaned = true;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (cleaned) {
|
|
176
|
+
saveJobs(jobs);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return cleaned;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Execute a CLI command as a job
|
|
184
|
+
*/
|
|
185
|
+
function executeJob(jobId, command, args = [], options = {}) {
|
|
186
|
+
return new Promise((resolve, reject) => {
|
|
187
|
+
// Always use the local CLI script — global installs may have stale paths
|
|
188
|
+
// __dirname is in src/lib, so go up one level to get to src/index.js
|
|
189
|
+
let cliPath = path.resolve(__dirname, "..", "index.js");
|
|
190
|
+
let useNode = true;
|
|
191
|
+
|
|
192
|
+
// Ensure the file exists
|
|
193
|
+
if (!fs.existsSync(cliPath)) {
|
|
194
|
+
const error = `CLI script not found at ${cliPath}`;
|
|
195
|
+
updateJobStatus(jobId, "failed", { error });
|
|
196
|
+
const jobs = loadJobs();
|
|
197
|
+
const jobIndex = jobs.findIndex((j) => j.id === jobId);
|
|
198
|
+
if (jobIndex !== -1) {
|
|
199
|
+
jobs[jobIndex].logs.push({
|
|
200
|
+
timestamp: new Date().toISOString(),
|
|
201
|
+
message: `[error] ${error}`,
|
|
202
|
+
});
|
|
203
|
+
jobs[jobIndex].logs.push({
|
|
204
|
+
timestamp: new Date().toISOString(),
|
|
205
|
+
message: `[error] Current working directory: ${process.cwd()}`,
|
|
206
|
+
});
|
|
207
|
+
jobs[jobIndex].logs.push({
|
|
208
|
+
timestamp: new Date().toISOString(),
|
|
209
|
+
message: `[error] __dirname: ${__dirname}`,
|
|
210
|
+
});
|
|
211
|
+
saveJobs(jobs);
|
|
212
|
+
}
|
|
213
|
+
return reject(new Error(error));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const isInteractive = command === "record"; // Record is interactive
|
|
217
|
+
|
|
218
|
+
// Build command and args
|
|
219
|
+
const cmd = useNode ? "node" : cliPath;
|
|
220
|
+
const cmdArgs = useNode ? [cliPath, command, ...args] : [command, ...args];
|
|
221
|
+
|
|
222
|
+
// Update status and add initial logs
|
|
223
|
+
updateJobStatus(jobId, "running");
|
|
224
|
+
appendJobLog(
|
|
225
|
+
jobId,
|
|
226
|
+
`[info] Executing: ${useNode ? "node" : "reshot"} ${command} ${args.join(
|
|
227
|
+
" "
|
|
228
|
+
)}`
|
|
229
|
+
);
|
|
230
|
+
appendJobLog(jobId, `[info] Working directory: ${process.cwd()}`);
|
|
231
|
+
appendJobLog(
|
|
232
|
+
jobId,
|
|
233
|
+
`[info] Using ${useNode ? "local" : "global"} CLI: ${
|
|
234
|
+
useNode ? cliPath : "reshot"
|
|
235
|
+
}`
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
// Use node to run the CLI script
|
|
239
|
+
const child = spawn(cmd, cmdArgs, {
|
|
240
|
+
cwd: process.cwd(),
|
|
241
|
+
env: {
|
|
242
|
+
...process.env,
|
|
243
|
+
...options.env,
|
|
244
|
+
// Preserve color output for better logs
|
|
245
|
+
FORCE_COLOR: "1",
|
|
246
|
+
NODE_ENV: process.env.NODE_ENV || "production",
|
|
247
|
+
},
|
|
248
|
+
stdio: isInteractive
|
|
249
|
+
? ["pipe", "pipe", "pipe"]
|
|
250
|
+
: ["ignore", "pipe", "pipe"],
|
|
251
|
+
shell: false,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
let stdoutBuffer = "";
|
|
255
|
+
let stderrBuffer = "";
|
|
256
|
+
let hasOutput = false;
|
|
257
|
+
|
|
258
|
+
// Set a timeout for non-interactive commands (10 minutes max)
|
|
259
|
+
const timeout = isInteractive
|
|
260
|
+
? null
|
|
261
|
+
: setTimeout(() => {
|
|
262
|
+
if (child && !child.killed) {
|
|
263
|
+
appendJobLog(jobId, "[error] Job timed out after 10 minutes");
|
|
264
|
+
child.kill("SIGTERM");
|
|
265
|
+
runningProcesses.delete(jobId);
|
|
266
|
+
updateJobStatus(jobId, "failed", {
|
|
267
|
+
error: "Job timed out after 10 minutes",
|
|
268
|
+
});
|
|
269
|
+
resolve({
|
|
270
|
+
code: -1,
|
|
271
|
+
stdout: stdoutBuffer,
|
|
272
|
+
stderr: stderrBuffer,
|
|
273
|
+
error: "Timeout",
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}, 10 * 60 * 1000);
|
|
277
|
+
|
|
278
|
+
// Track the process for cancellation
|
|
279
|
+
runningProcesses.set(jobId, { child, timeout });
|
|
280
|
+
|
|
281
|
+
child.stdout.on("data", (data) => {
|
|
282
|
+
hasOutput = true;
|
|
283
|
+
const text = data.toString();
|
|
284
|
+
stdoutBuffer += text;
|
|
285
|
+
// Split by newlines and log each line
|
|
286
|
+
const lines = text
|
|
287
|
+
.split(/\r?\n/)
|
|
288
|
+
.filter((line) => line.trim().length > 0);
|
|
289
|
+
lines.forEach((line) => {
|
|
290
|
+
appendJobLog(jobId, line);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
child.stderr.on("data", (data) => {
|
|
295
|
+
hasOutput = true;
|
|
296
|
+
const text = data.toString();
|
|
297
|
+
stderrBuffer += text;
|
|
298
|
+
// Split by newlines and log each line
|
|
299
|
+
const lines = text
|
|
300
|
+
.split(/\r?\n/)
|
|
301
|
+
.filter((line) => line.trim().length > 0);
|
|
302
|
+
lines.forEach((line) => {
|
|
303
|
+
appendJobLog(jobId, `[stderr] ${line}`);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// For interactive commands like record, we can't fully automate
|
|
308
|
+
// But we'll let it start and log what happens
|
|
309
|
+
if (isInteractive) {
|
|
310
|
+
appendJobLog(
|
|
311
|
+
jobId,
|
|
312
|
+
"[info] Interactive command - may require manual input"
|
|
313
|
+
);
|
|
314
|
+
appendJobLog(
|
|
315
|
+
jobId,
|
|
316
|
+
"[info] Ensure Chrome is running with remote debugging enabled"
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
child.on("close", (code, signal) => {
|
|
321
|
+
if (timeout) clearTimeout(timeout);
|
|
322
|
+
runningProcesses.delete(jobId);
|
|
323
|
+
|
|
324
|
+
if (signal) {
|
|
325
|
+
appendJobLog(jobId, `[info] Process terminated by signal: ${signal}`);
|
|
326
|
+
updateJobStatus(jobId, "failed", {
|
|
327
|
+
exitCode: -1,
|
|
328
|
+
error: `Process terminated by signal: ${signal}`,
|
|
329
|
+
});
|
|
330
|
+
return resolve({
|
|
331
|
+
code: -1,
|
|
332
|
+
stdout: stdoutBuffer,
|
|
333
|
+
stderr: stderrBuffer,
|
|
334
|
+
error: `Terminated: ${signal}`,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
appendJobLog(jobId, `[info] Process exited with code ${code}`);
|
|
339
|
+
|
|
340
|
+
// If no output was received and process exited quickly, it might have failed to start
|
|
341
|
+
if (!hasOutput && code !== 0) {
|
|
342
|
+
appendJobLog(
|
|
343
|
+
jobId,
|
|
344
|
+
"[error] Process exited with no output - command may have failed to start"
|
|
345
|
+
);
|
|
346
|
+
const errorMsg =
|
|
347
|
+
stderrBuffer || `Process exited with code ${code} and no output`;
|
|
348
|
+
updateJobStatus(jobId, "failed", {
|
|
349
|
+
exitCode: code,
|
|
350
|
+
error: errorMsg,
|
|
351
|
+
});
|
|
352
|
+
return resolve({
|
|
353
|
+
code,
|
|
354
|
+
stdout: stdoutBuffer,
|
|
355
|
+
stderr: stderrBuffer,
|
|
356
|
+
error: errorMsg,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Check if job was cancelled
|
|
361
|
+
const job = getJob(jobId);
|
|
362
|
+
if (job && job.status === "cancelled") {
|
|
363
|
+
// Already marked as cancelled, don't override
|
|
364
|
+
resolve({
|
|
365
|
+
code,
|
|
366
|
+
stdout: stdoutBuffer,
|
|
367
|
+
stderr: stderrBuffer,
|
|
368
|
+
cancelled: true,
|
|
369
|
+
});
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Check for common error patterns in output even if exit code is 0
|
|
374
|
+
// Be more specific to avoid false positives - look for actual error patterns, not just keywords
|
|
375
|
+
const authErrorPatterns = [
|
|
376
|
+
/(?:invalid|missing|expired|bad|unauthorized).*api.?key/i,
|
|
377
|
+
/api.?key.*(?:invalid|missing|expired|bad|required)/i,
|
|
378
|
+
/401.*unauthorized/i,
|
|
379
|
+
/authentication.*(?:failed|error|required)/i,
|
|
380
|
+
/(?:failed|error).*authentication/i,
|
|
381
|
+
];
|
|
382
|
+
const hasAuthError = authErrorPatterns.some(
|
|
383
|
+
(pattern) =>
|
|
384
|
+
pattern.test(stderrBuffer) ||
|
|
385
|
+
(code !== 0 && pattern.test(stdoutBuffer))
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
const hasConnectionError =
|
|
389
|
+
stderrBuffer.includes("ECONNREFUSED") ||
|
|
390
|
+
stderrBuffer.includes("ENOTFOUND") ||
|
|
391
|
+
stderrBuffer.includes("connect ETIMEDOUT") ||
|
|
392
|
+
(code !== 0 &&
|
|
393
|
+
(stdoutBuffer.includes("ECONNREFUSED") ||
|
|
394
|
+
stdoutBuffer.includes("ENOTFOUND")));
|
|
395
|
+
|
|
396
|
+
if (hasAuthError) {
|
|
397
|
+
const errorMsg =
|
|
398
|
+
"Authentication failed - check API key and run 'reshot auth'";
|
|
399
|
+
appendJobLog(jobId, `[error] ${errorMsg}`);
|
|
400
|
+
updateJobStatus(jobId, "failed", {
|
|
401
|
+
exitCode: code,
|
|
402
|
+
error: errorMsg,
|
|
403
|
+
});
|
|
404
|
+
return resolve({
|
|
405
|
+
code,
|
|
406
|
+
stdout: stdoutBuffer,
|
|
407
|
+
stderr: stderrBuffer,
|
|
408
|
+
error: errorMsg,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (hasConnectionError) {
|
|
413
|
+
const errorMsg =
|
|
414
|
+
"Cannot connect to platform - ensure the server is running";
|
|
415
|
+
appendJobLog(jobId, `[error] ${errorMsg}`);
|
|
416
|
+
updateJobStatus(jobId, "failed", {
|
|
417
|
+
exitCode: code,
|
|
418
|
+
error: errorMsg,
|
|
419
|
+
});
|
|
420
|
+
return resolve({
|
|
421
|
+
code,
|
|
422
|
+
stdout: stdoutBuffer,
|
|
423
|
+
stderr: stderrBuffer,
|
|
424
|
+
error: errorMsg,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (code === 0) {
|
|
429
|
+
updateJobStatus(jobId, "success", {
|
|
430
|
+
exitCode: code,
|
|
431
|
+
});
|
|
432
|
+
resolve({ code, stdout: stdoutBuffer, stderr: stderrBuffer });
|
|
433
|
+
} else {
|
|
434
|
+
const errorMsg = stderrBuffer || `Process exited with code ${code}`;
|
|
435
|
+
updateJobStatus(jobId, "failed", {
|
|
436
|
+
exitCode: code,
|
|
437
|
+
error: errorMsg,
|
|
438
|
+
});
|
|
439
|
+
// Don't reject for non-zero exit codes - some commands may exit with codes for expected reasons
|
|
440
|
+
// Just mark as failed and resolve
|
|
441
|
+
resolve({
|
|
442
|
+
code,
|
|
443
|
+
stdout: stdoutBuffer,
|
|
444
|
+
stderr: stderrBuffer,
|
|
445
|
+
error: errorMsg,
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
child.on("error", (error) => {
|
|
451
|
+
if (timeout) clearTimeout(timeout);
|
|
452
|
+
runningProcesses.delete(jobId);
|
|
453
|
+
const errorMsg = error.message;
|
|
454
|
+
updateJobStatus(jobId, "failed", {
|
|
455
|
+
error: errorMsg,
|
|
456
|
+
});
|
|
457
|
+
appendJobLog(jobId, `[error] Failed to start process: ${errorMsg}`);
|
|
458
|
+
appendJobLog(jobId, `[error] Command: ${cmd} ${cmdArgs.join(" ")}`);
|
|
459
|
+
reject(error);
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Cancel a running job
|
|
466
|
+
*/
|
|
467
|
+
function cancelJob(jobId) {
|
|
468
|
+
const processInfo = runningProcesses.get(jobId);
|
|
469
|
+
|
|
470
|
+
if (processInfo) {
|
|
471
|
+
const { child, timeout } = processInfo;
|
|
472
|
+
|
|
473
|
+
// Clear the timeout
|
|
474
|
+
if (timeout) clearTimeout(timeout);
|
|
475
|
+
|
|
476
|
+
// Kill the process
|
|
477
|
+
if (child && !child.killed) {
|
|
478
|
+
child.kill("SIGTERM");
|
|
479
|
+
// Force kill after 5 seconds if still running
|
|
480
|
+
setTimeout(() => {
|
|
481
|
+
if (!child.killed) {
|
|
482
|
+
child.kill("SIGKILL");
|
|
483
|
+
}
|
|
484
|
+
}, 5000);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
runningProcesses.delete(jobId);
|
|
488
|
+
appendJobLog(jobId, "[info] Job cancelled by user");
|
|
489
|
+
updateJobStatus(jobId, "cancelled");
|
|
490
|
+
return true;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Job not running, just update status if it's in running state
|
|
494
|
+
const job = getJob(jobId);
|
|
495
|
+
if (job && job.status === "running") {
|
|
496
|
+
appendJobLog(jobId, "[info] Job marked as cancelled (process not found)");
|
|
497
|
+
updateJobStatus(jobId, "cancelled");
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Execute run job
|
|
506
|
+
* @param {string} jobId - Job ID
|
|
507
|
+
* @param {string[]|null} scenarioKeys - Optional scenario keys to run
|
|
508
|
+
* @param {Object|null} variant - Optional variant configuration (e.g., { locale: 'ko', role: 'admin' })
|
|
509
|
+
* @param {string|null} format - Optional output format ('step-by-step-images' or 'summary-video')
|
|
510
|
+
* @param {boolean|null} diff - Whether to enable baseline diffing (null = use config default)
|
|
511
|
+
*/
|
|
512
|
+
async function executeRunJob(
|
|
513
|
+
jobId,
|
|
514
|
+
scenarioKeys = null,
|
|
515
|
+
variant = null,
|
|
516
|
+
format = null,
|
|
517
|
+
diff = null,
|
|
518
|
+
noPrivacy = false,
|
|
519
|
+
noStyle = false
|
|
520
|
+
) {
|
|
521
|
+
const args = [];
|
|
522
|
+
// Pass scenario keys if provided (comma-separated)
|
|
523
|
+
if (scenarioKeys && Array.isArray(scenarioKeys) && scenarioKeys.length > 0) {
|
|
524
|
+
args.push("--scenarios", scenarioKeys.join(","));
|
|
525
|
+
}
|
|
526
|
+
// Pass variant as JSON string if provided
|
|
527
|
+
if (
|
|
528
|
+
variant &&
|
|
529
|
+
typeof variant === "object" &&
|
|
530
|
+
Object.keys(variant).length > 0
|
|
531
|
+
) {
|
|
532
|
+
args.push("--variant", JSON.stringify(variant));
|
|
533
|
+
}
|
|
534
|
+
// Pass format if provided
|
|
535
|
+
if (format) {
|
|
536
|
+
args.push("--format", format);
|
|
537
|
+
}
|
|
538
|
+
// Pass diff flag if explicitly set
|
|
539
|
+
if (diff === true) {
|
|
540
|
+
args.push("--diff");
|
|
541
|
+
} else if (diff === false) {
|
|
542
|
+
args.push("--no-diff");
|
|
543
|
+
}
|
|
544
|
+
// Pass privacy/style flags
|
|
545
|
+
if (noPrivacy) {
|
|
546
|
+
args.push("--no-privacy");
|
|
547
|
+
}
|
|
548
|
+
if (noStyle) {
|
|
549
|
+
args.push("--no-style");
|
|
550
|
+
}
|
|
551
|
+
return executeJob(jobId, "run", args);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Execute publish job
|
|
556
|
+
*/
|
|
557
|
+
async function executePublishJob(jobId, scenarioKeys = null) {
|
|
558
|
+
const args = [];
|
|
559
|
+
// For now, publish all. Future: filter by scenarioKeys
|
|
560
|
+
return executeJob(jobId, "publish", args);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Execute record job
|
|
565
|
+
*/
|
|
566
|
+
async function executeRecordJob(jobId, title, scenarioKey = null) {
|
|
567
|
+
const args = title ? [title] : [];
|
|
568
|
+
// If scenarioKey is provided, we'd need to pass it somehow
|
|
569
|
+
// For now, just use the title
|
|
570
|
+
return executeJob(jobId, "record", args);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
module.exports = {
|
|
574
|
+
createJob,
|
|
575
|
+
updateJobStatus,
|
|
576
|
+
appendJobLog,
|
|
577
|
+
getJob,
|
|
578
|
+
getAllJobs,
|
|
579
|
+
executeJob,
|
|
580
|
+
executeRunJob,
|
|
581
|
+
executePublishJob,
|
|
582
|
+
executeRecordJob,
|
|
583
|
+
cancelJob,
|
|
584
|
+
cleanupStuckJobs,
|
|
585
|
+
loadJobs,
|
|
586
|
+
saveJobs,
|
|
587
|
+
};
|