@cloud-cli/on 1.2.2 → 1.2.3

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 (50) hide show
  1. package/README.md +24 -20
  2. package/dist/db-client.d.ts +14 -0
  3. package/dist/drivers/index.d.ts +2 -0
  4. package/dist/drivers/standard-process.driver.d.ts +10 -0
  5. package/dist/drivers/systemd.driver.d.ts +6 -0
  6. package/dist/index.d.ts +4 -0
  7. package/dist/log-redactor.d.ts +6 -0
  8. package/dist/on.js +9240 -0
  9. package/dist/parser/include-resolver.d.ts +9 -0
  10. package/dist/parser/matrix-expander.d.ts +5 -0
  11. package/dist/parser/yaml-loader.d.ts +6 -0
  12. package/dist/plugins/github-status.plugin.d.ts +7 -0
  13. package/dist/plugins/manager.d.ts +7 -0
  14. package/dist/queue.d.ts +41 -0
  15. package/dist/reporters/html.reporter.d.ts +14 -0
  16. package/dist/reporters/json-file.reporter.d.ts +9 -0
  17. package/dist/reporters/slack.reporter.d.ts +15 -0
  18. package/dist/safe-eval.d.ts +28 -0
  19. package/dist/secrets.d.ts +15 -0
  20. package/dist/server/preprocessors/github.d.ts +5 -0
  21. package/dist/server/server.d.ts +32 -0
  22. package/dist/types.d.ts +181 -0
  23. package/dist/worker.d.ts +8 -0
  24. package/package.json +24 -20
  25. package/dist/config.js +0 -15
  26. package/dist/db-client.js +0 -38
  27. package/dist/drivers/index.js +0 -11
  28. package/dist/drivers/standard-process.driver.js +0 -156
  29. package/dist/drivers/systemd.driver.js +0 -151
  30. package/dist/evaluator/safe-eval.js +0 -213
  31. package/dist/index.js +0 -145
  32. package/dist/ingress/preprocessors/github.js +0 -30
  33. package/dist/ingress/server.js +0 -241
  34. package/dist/logging/redactor.js +0 -20
  35. package/dist/parser/include-resolver.js +0 -47
  36. package/dist/parser/matrix-expander.js +0 -43
  37. package/dist/parser/yaml-loader.js +0 -45
  38. package/dist/plugins/github-status.plugin.js +0 -19
  39. package/dist/plugins/manager.js +0 -21
  40. package/dist/plugins/types.js +0 -1
  41. package/dist/queue/dispatcher.js +0 -112
  42. package/dist/reporters/html.reporter.js +0 -108
  43. package/dist/reporters/json-file.reporter.js +0 -16
  44. package/dist/reporters/slack.reporter.js +0 -25
  45. package/dist/reporters/types.js +0 -1
  46. package/dist/runner/step-runner.js +0 -42
  47. package/dist/secrets/store.js +0 -40
  48. package/dist/types.js +0 -1
  49. package/dist/worker.js +0 -249
  50. /package/dist/{ingress/types.js → runner/step-runner.d.ts} +0 -0
@@ -1,241 +0,0 @@
1
- import http from 'node:http';
2
- import { URL } from 'node:url';
3
- import { SafeExpressionEvaluator } from '../evaluator/safe-eval.js';
4
- import { GitHubPreprocessor } from './preprocessors/github.js';
5
- import { HtmlReporter } from '../reporters/html.reporter.js';
6
- export class WebhookServer {
7
- server;
8
- preprocessors = new Map();
9
- workflows = [];
10
- queue;
11
- secrets;
12
- adminToken;
13
- static withPort(options) {
14
- const { port, ...o } = options;
15
- return new WebhookServer(o).listen(port);
16
- }
17
- constructor(options) {
18
- this.queue = options.queue;
19
- this.secrets = options.secrets;
20
- this.adminToken = options.adminToken;
21
- this.workflows = options.workflows;
22
- // Register built-in preprocessors
23
- this.registerPreprocessor(new GitHubPreprocessor());
24
- this.server = http.createServer((req, res) => this.handleRequest(req, res));
25
- }
26
- registerPreprocessor(preprocessor) {
27
- this.preprocessors.set(preprocessor.name, preprocessor);
28
- }
29
- async handleRequest(req, res) {
30
- const url = new URL(req.url || '/', `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers['x-forwarded-host'] || req.headers.host}`);
31
- if (req.method === 'GET' && (url.pathname === '/runs' || url.pathname === '/')) {
32
- return this.renderDashboard(res);
33
- }
34
- if (req.method === 'GET' && url.pathname.startsWith('/runs/')) {
35
- const jobId = url.pathname.replace('/runs/', '');
36
- return this.renderRunDetails(jobId, res);
37
- }
38
- if (req.method === 'POST' && url.pathname === '/admin/reload-secrets') {
39
- return this.handleSecretReload(req, res);
40
- }
41
- if (req.method === 'POST' && url.pathname.startsWith('/webhooks/')) {
42
- const provider = url.pathname.replace('/webhooks/', '');
43
- return this.handleWebhook(provider, req, res);
44
- }
45
- // Fallback 404
46
- res.writeHead(404, { 'Content-Type': 'application/json' });
47
- res.end(JSON.stringify({ error: 'Endpoint not found' }));
48
- }
49
- /**
50
- * Processes incoming HTTP webhooks
51
- */
52
- async handleWebhook(provider, req, res) {
53
- try {
54
- // 5MB
55
- const MAX_PAYLOAD_SIZE = 5 * 1024 * 1024;
56
- const chunks = [];
57
- let receivedBytes = 0;
58
- for await (const chunk of req) {
59
- receivedBytes += chunk.length;
60
- if (receivedBytes > MAX_PAYLOAD_SIZE) {
61
- res.writeHead(413, { 'Content-Type': 'application/json' });
62
- return res.end(JSON.stringify({ error: 'Payload size exceeds limit' }));
63
- }
64
- chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
65
- }
66
- const rawBuffer = Buffer.concat(chunks);
67
- let body = {};
68
- try {
69
- body = JSON.parse(rawBuffer.toString('utf-8'));
70
- }
71
- catch { }
72
- const headers = Object.fromEntries(Object.entries(req.headers).map(([k, v]) => [k.toLowerCase(), Array.isArray(v) ? v[0] : v || '']));
73
- const preprocessor = this.preprocessors.get(provider);
74
- const secret = this.secrets.get(`${provider.toUpperCase()}_WEBHOOK_SECRET`);
75
- let isValid = true;
76
- let inputs = { ...body };
77
- if (preprocessor) {
78
- const result = preprocessor.parse(headers, body, rawBuffer, secret);
79
- isValid = result.isValid;
80
- inputs = result.inputs;
81
- }
82
- // Reject unauthorized requests immediately
83
- if (!isValid) {
84
- res.writeHead(401, { 'Content-Type': 'application/json' });
85
- return res.end(JSON.stringify({ error: 'Invalid HMAC signature or authentication failed' }));
86
- }
87
- // 3. Match Incoming Webhook to Registered Workflows
88
- const triggeredJobs = [];
89
- for (const workflow of this.workflows) {
90
- // Match provider (e.g. 'github')
91
- if (workflow.on.provider !== provider)
92
- continue;
93
- // Evaluate workflow trigger condition if defined (e.g. `if: inputs.event == 'push'`)
94
- if (workflow.on.if) {
95
- try {
96
- const shouldRun = SafeExpressionEvaluator.evaluateCondition(workflow.on.if, { inputs });
97
- if (!shouldRun)
98
- continue;
99
- }
100
- catch (evalErr) {
101
- console.error(`⚠️ Condition evaluation error in workflow [${workflow.id}]:`, evalErr.message);
102
- continue; // Skip this workflow without crashing server
103
- }
104
- }
105
- // 4. Resolve Concurrency Key (if specified)
106
- let concurrencyKey;
107
- if (workflow.concurrency?.group) {
108
- concurrencyKey = await SafeExpressionEvaluator.evaluateValue(workflow.concurrency.group, { inputs });
109
- }
110
- // 5. Enqueue Job to SQLite
111
- const jobPayload = {
112
- workflowId: workflow.id,
113
- steps: workflow.steps,
114
- inputs,
115
- };
116
- await this.queue.enqueue(workflow.id, jobPayload, concurrencyKey);
117
- triggeredJobs.push(workflow.id);
118
- }
119
- // 6. Respond Fast (202 Accepted)
120
- res.writeHead(202, { 'Content-Type': 'application/json' });
121
- res.end(JSON.stringify({
122
- message: 'Webhook processed',
123
- triggeredWorkflows: triggeredJobs,
124
- }));
125
- }
126
- catch (err) {
127
- console.error('❌ Webhook Ingress Error:', err);
128
- res.writeHead(500, { 'Content-Type': 'application/json' });
129
- res.end(JSON.stringify({ error: 'Internal Ingress Error', details: err.message }));
130
- }
131
- }
132
- /**
133
- * Handles Zero-Downtime Secret Reload
134
- */
135
- async handleSecretReload(req, res) {
136
- const authHeader = req.headers['authorization'];
137
- if (authHeader !== `Bearer ${this.adminToken}`) {
138
- res.writeHead(403, { 'Content-Type': 'application/json' });
139
- return res.end(JSON.stringify({ error: 'Unauthorized' }));
140
- }
141
- // Trigger in-memory secret reload
142
- this.secrets.reload();
143
- console.log('🔄 SecretStore reloaded successfully without downtime!');
144
- res.writeHead(200, { 'Content-Type': 'application/json' });
145
- res.end(JSON.stringify({ message: 'Secrets reloaded successfully' }));
146
- }
147
- /**
148
- * Serves the Server Health & Jobs Dashboard
149
- */
150
- async renderDashboard(res) {
151
- const jobs = await this.queue.listJobs(50);
152
- const rows = jobs
153
- .map((j) => {
154
- const badge = j.status === 'success'
155
- ? 'bg-emerald-500/10 text-emerald-400'
156
- : j.status === 'failed'
157
- ? 'bg-rose-500/10 text-rose-400'
158
- : j.status === 'running'
159
- ? 'bg-indigo-500/10 text-indigo-400 animate-pulse'
160
- : 'bg-gray-500/10 text-gray-400';
161
- return `
162
- <tr class="border-b border-gray-800 hover:bg-gray-900/50 transition">
163
- <td class="py-3 px-4 font-mono text-indigo-400"><a href="/runs/${j.id}" class="hover:underline">#${j.id}</a></td>
164
- <td class="py-3 px-4 font-medium text-white">${j.workflow_id}</td>
165
- <td class="py-3 px-4">
166
- <span class="px-2.5 py-0.5 rounded-full text-xs font-semibold ${badge}">${j.status.toUpperCase()}</span>
167
- </td>
168
- <td class="py-3 px-4 text-xs font-mono text-gray-400">${j.worker_id || '-'}</td>
169
- <td class="py-3 px-4 text-xs text-gray-400">${j.created_at}</td>
170
- <td class="py-3 px-4 text-right">
171
- <a href="/runs/${j.id}" class="text-xs bg-gray-800 hover:bg-gray-700 text-gray-200 px-3 py-1 rounded border border-gray-700">View Trace →</a>
172
- </td>
173
- </tr>
174
- `;
175
- })
176
- .join('');
177
- const html = `<!DOCTYPE html>
178
- <html lang="en" class="dark">
179
- <head>
180
- <meta charset="UTF-8">
181
- <meta http-equiv="refresh" content="10"> <!-- Auto-refreshes every 10s -->
182
- <title>Workflow Engine Dashboard</title>
183
- <script src="https://cdn.tailwindcss.com"></script>
184
- </head>
185
- <body class="bg-gray-950 text-gray-100 min-h-screen p-6 font-sans">
186
- <div class="max-w-6xl mx-auto space-y-6">
187
- <div class="flex items-center justify-between border-b border-gray-800 pb-4">
188
- <div>
189
- <h1 class="text-2xl font-bold text-white">⚙️ Runner Engine Status</h1>
190
- <p class="text-xs text-gray-400">Live SQLite Job Queue & Execution Traces</p>
191
- </div>
192
- <span class="text-xs font-mono bg-emerald-500/10 text-emerald-400 px-3 py-1 rounded-full border border-emerald-500/20">
193
- ● System Operational
194
- </span>
195
- </div>
196
-
197
- <div class="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
198
- <table class="w-full text-left text-sm">
199
- <thead class="bg-gray-800/50 text-gray-400 text-xs uppercase font-mono border-b border-gray-800">
200
- <tr>
201
- <th class="py-3 px-4">Job ID</th>
202
- <th class="py-3 px-4">Workflow</th>
203
- <th class="py-3 px-4">Status</th>
204
- <th class="py-3 px-4">Worker</th>
205
- <th class="py-3 px-4">Created At</th>
206
- <th class="py-3 px-4 text-right">Action</th>
207
- </tr>
208
- </thead>
209
- <tbody>${rows.length ? rows : '<tr><td colspan="6" class="p-6 text-center text-gray-500">No jobs recorded yet.</td></tr>'}</tbody>
210
- </table>
211
- </div>
212
- </div>
213
- </body>
214
- </html>`;
215
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
216
- res.end(html);
217
- }
218
- /**
219
- * Serves single job HTML report
220
- */
221
- async renderRunDetails(jobId, res) {
222
- const job = await this.queue.getJob(jobId);
223
- if (!job || !job.report) {
224
- res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
225
- return res.end('<h1>404 - Report Not Found</h1><p>Job is still running or does not exist.</p><a href="/runs">← Back to Dashboard</a>');
226
- }
227
- const reportData = JSON.parse(job.report);
228
- const htmlReporter = new HtmlReporter({ outputDir: '' });
229
- const htmlContent = htmlReporter.generateHtml(reportData);
230
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
231
- res.end(htmlContent);
232
- }
233
- listen(port) {
234
- return new Promise((resolve) => {
235
- this.server.listen(port, () => {
236
- console.log(`🌐 Webhook Ingress Server running on port ${port}`);
237
- resolve();
238
- });
239
- });
240
- }
241
- }
@@ -1,20 +0,0 @@
1
- import { Transform } from 'node:stream';
2
- export class SecretRedactorStream extends Transform {
3
- secretValues;
4
- constructor(secretValues) {
5
- super();
6
- // Sort longest secrets first to prevent partial replacements
7
- this.secretValues = secretValues.sort((a, b) => b.length - a.length);
8
- }
9
- _transform(chunk, encoding, callback) {
10
- let logString = chunk.toString('utf-8');
11
- // Replace all known secret values with ***
12
- for (const secret of this.secretValues) {
13
- if (secret) {
14
- logString = logString.replaceAll(secret, '***');
15
- }
16
- }
17
- this.push(Buffer.from(logString));
18
- callback();
19
- }
20
- }
@@ -1,47 +0,0 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import YAML from 'yaml';
4
- export class WorkflowIncludeResolver {
5
- baseDir;
6
- maxDepth;
7
- constructor(baseDir, maxDepth = 5) {
8
- this.baseDir = path.resolve(baseDir);
9
- this.maxDepth = maxDepth;
10
- }
11
- /**
12
- * Recursively loads and merges YAML workflows while guarding against cycles.
13
- */
14
- resolve(filePath, visited = new Set(), depth = 0) {
15
- if (depth > this.maxDepth) {
16
- throw new Error(`Max include depth of ${this.maxDepth} exceeded at ${filePath}`);
17
- }
18
- const absolutePath = path.resolve(this.baseDir, filePath);
19
- // 1. Security Check: Prevent Directory Traversal outside workspace
20
- if (!absolutePath.startsWith(this.baseDir)) {
21
- throw new Error(`Security Violation: Include path '${filePath}' is outside workspace directory.`);
22
- }
23
- // 2. Cycle Detection
24
- if (visited.has(absolutePath)) {
25
- const cycleTrail = Array.from(visited).concat(absolutePath).join(' -> ');
26
- throw new Error(`Circular 'includes' dependency detected: ${cycleTrail}`);
27
- }
28
- visited.add(absolutePath);
29
- if (!fs.existsSync(absolutePath)) {
30
- throw new Error(`Included workflow partial not found: ${filePath}`);
31
- }
32
- // 3. Parse File
33
- const content = fs.readFileSync(absolutePath, 'utf-8');
34
- const parsed = YAML.parse(content);
35
- // 4. Handle Nested Includes (Depth First)
36
- if (Array.isArray(parsed.includes)) {
37
- for (const subInclude of parsed.includes) {
38
- const partialData = this.resolve(subInclude, new Set(visited), depth + 1);
39
- // Merge strategy: Append steps, deep-merge env and trigger properties
40
- parsed.steps = [...(partialData.steps || []), ...(parsed.steps || [])];
41
- parsed.env = { ...partialData.env, ...parsed.env };
42
- }
43
- delete parsed.includes; // Clean up top-level key after merging
44
- }
45
- return parsed;
46
- }
47
- }
@@ -1,43 +0,0 @@
1
- /**
2
- * Expands a workflow definition containing a `strategy.matrix` into dynamic single-instance workflow jobs.
3
- */
4
- export function expandMatrix(workflow) {
5
- // If no matrix strategy is defined, return workflow as a single-element array
6
- if (!workflow.strategy?.matrix || Object.keys(workflow.strategy.matrix).length === 0) {
7
- return [workflow];
8
- }
9
- const matrix = workflow.strategy.matrix;
10
- const keys = Object.keys(matrix);
11
- // Calculate Cartesian Product across all matrix keys
12
- const combinations = keys.reduce((acc, key) => {
13
- const values = Array.isArray(matrix[key]) ? matrix[key] : [matrix[key]];
14
- return acc.flatMap((combination) => values.map((val) => ({
15
- ...combination,
16
- [key]: val,
17
- })));
18
- }, [{}]);
19
- // Clone workflow instance for each Cartesian matrix combination
20
- return combinations.map((combination, index) => {
21
- const clone = JSON.parse(JSON.stringify(workflow));
22
- // Remove top-level strategy key after expansion
23
- delete clone.strategy;
24
- // Create matrix identity label: "node=20, os=ubuntu"
25
- const matrixLabel = Object.entries(combination)
26
- .map(([k, v]) => `${k}=${v}`)
27
- .join(', ');
28
- // Append matrix label to workflow name & generate unique workflow ID
29
- clone.name = `${workflow.name} (${matrixLabel})`;
30
- clone.matrixContext = combination;
31
- // Convert matrix values to environment variables (e.g., MATRIX_NODE_VERSION="20")
32
- const matrixEnv = {};
33
- for (const [k, v] of Object.entries(combination)) {
34
- const envKey = `MATRIX_${k.toUpperCase().replace(/[^A-Z0-9_]/g, '_')}`;
35
- matrixEnv[envKey] = String(v);
36
- }
37
- clone.env = {
38
- ...(clone.env || {}),
39
- ...matrixEnv,
40
- };
41
- return clone;
42
- });
43
- }
@@ -1,45 +0,0 @@
1
- import { readdir } from 'node:fs/promises';
2
- import { isAbsolute, join, resolve } from 'node:path';
3
- import { WorkflowIncludeResolver } from './include-resolver.js';
4
- import { expandMatrix } from './matrix-expander.js';
5
- export class YamlLoader {
6
- static async from(path) {
7
- const absolutePath = isAbsolute(path) ? resolve('/', path) : join(process.cwd(), resolve('/', path));
8
- const list = await readdir(absolutePath, { withFileTypes: true });
9
- const files = list
10
- .filter((f) => f.isFile() && (f.name.endsWith('.yml') || f.name.endsWith('.yaml')))
11
- .map((f) => join(absolutePath, f.name));
12
- const workflows = [];
13
- const resolver = new WorkflowIncludeResolver(path);
14
- for (const file of files) {
15
- workflows.push(...(await YamlLoader.loadFile(file, resolver)));
16
- }
17
- return workflows;
18
- }
19
- static loadFile(path, resolver) {
20
- const workflows = [];
21
- try {
22
- // Resolve includes & partials
23
- const resolved = resolver.resolve(path);
24
- // Expand matrix strategy into concrete job specs
25
- const expandedWorkflows = expandMatrix(resolved);
26
- for (const wf of expandedWorkflows) {
27
- workflows.push({
28
- id: wf.id || wf.name.toLowerCase().replace(/[^a-z0-9]/g, '-'),
29
- name: wf.name,
30
- on: {
31
- provider: Object.keys(wf.on || {})[0] || 'generic',
32
- if: wf.on?.[Object.keys(wf.on || {})[0]]?.if,
33
- },
34
- concurrency: wf.concurrency,
35
- steps: wf.steps,
36
- });
37
- }
38
- }
39
- catch (err) {
40
- console.error(`❌ Error parsing workflow '${path}':`, err.message);
41
- return [];
42
- }
43
- return workflows;
44
- }
45
- }
@@ -1,19 +0,0 @@
1
- export class GitHubStatusPlugin {
2
- name = 'github-commit-status';
3
- async onWorkflowStart(wf) {
4
- if (!wf.inputs.commit_sha || !wf.inputs.clone_url)
5
- return;
6
- await this.updateStatus(wf, 'pending', 'Workflow build has started.');
7
- }
8
- async onWorkflowFinish(wf, status) {
9
- if (!wf.inputs.commit_sha || !wf.inputs.clone_url)
10
- return;
11
- const state = status === 'success' ? 'success' : 'failure';
12
- const description = status === 'success' ? 'All workflow steps passed!' : 'Workflow failed.';
13
- await this.updateStatus(wf, state, description);
14
- }
15
- async updateStatus(wf, state, description) {
16
- // Calls GitHub REST API using inputs.commit_sha
17
- console.log(`[GitHub Plugin] Setting commit status for ${wf.inputs.commit_sha} -> ${state}: ${description}`);
18
- }
19
- }
@@ -1,21 +0,0 @@
1
- export class PluginManager {
2
- plugins = [];
3
- register(plugin) {
4
- console.log(`🔌 Registered Plugin: ${plugin.name}`);
5
- this.plugins.push(plugin);
6
- }
7
- async triggerWorkflowStart(wf) {
8
- for (const plugin of this.plugins) {
9
- if (plugin.onWorkflowStart) {
10
- await plugin.onWorkflowStart(wf).catch(err => console.error(`[Plugin Error] ${plugin.name}.onWorkflowStart:`, err));
11
- }
12
- }
13
- }
14
- async triggerWorkflowFinish(wf, status) {
15
- for (const plugin of this.plugins) {
16
- if (plugin.onWorkflowFinish) {
17
- await plugin.onWorkflowFinish(wf, status).catch(err => console.error(`[Plugin Error] ${plugin.name}.onWorkflowFinish:`, err));
18
- }
19
- }
20
- }
21
- }
@@ -1 +0,0 @@
1
- export {};
@@ -1,112 +0,0 @@
1
- import db from '../db-client.js';
2
- export class QueueManager {
3
- workerId;
4
- constructor(workerId) {
5
- this.workerId = workerId;
6
- }
7
- /**
8
- * Initializes the database schema.
9
- */
10
- async init() {
11
- await db.run(`
12
- CREATE TABLE IF NOT EXISTS jobs (
13
- id INTEGER PRIMARY KEY AUTOINCREMENT,
14
- workflow_id TEXT NOT NULL,
15
- payload TEXT NOT NULL,
16
- status TEXT NOT NULL DEFAULT 'pending',
17
- concurrency_key TEXT,
18
- worker_id TEXT,
19
- report TEXT, -- Stores WorkflowExecutionReport JSON
20
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
21
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
22
- )
23
- `);
24
- await this.clearStaleJobs();
25
- }
26
- /**
27
- * Enqueues a new job into the database.
28
- * Includes simple GitHub-style concurrency cancellation.
29
- */
30
- async enqueue(workflowId, payload, concurrencyKey) {
31
- // If a concurrency key is provided, cancel existing pending/running jobs in that group
32
- if (concurrencyKey) {
33
- await db.run(`
34
- UPDATE jobs
35
- SET status = 'cancelling'
36
- WHERE concurrency_key = ? AND status IN ('pending', 'running');
37
- `, [concurrencyKey]);
38
- }
39
- const res = await db.run(`
40
- INSERT INTO jobs (workflow_id, concurrency_key, payload)
41
- VALUES (?, ?, ?);
42
- `, [workflowId, concurrencyKey || '', JSON.stringify(payload)]);
43
- return res;
44
- }
45
- /**
46
- * ATOMICALY claims the oldest pending job.
47
- * Requires SQLite >= 3.35 for the RETURNING clause.
48
- */
49
- async claimNextJob() {
50
- // This query is completely immune to HTTP/Network race conditions.
51
- // It locks the row, updates it, and returns the data in one transaction.
52
- const result = await db.get(`
53
- UPDATE jobs
54
- SET
55
- status = 'running',
56
- worker_id = ?,
57
- started_at = CURRENT_TIMESTAMP
58
- WHERE id = (
59
- SELECT id FROM jobs
60
- WHERE status = 'pending'
61
- ORDER BY created_at ASC
62
- LIMIT 1
63
- )
64
- RETURNING *;
65
- `, [this.workerId]);
66
- return result ? result : null;
67
- }
68
- /**
69
- * Marks a job as completed or failed
70
- */
71
- async finishJob(jobId, status) {
72
- await db.run(`
73
- UPDATE jobs
74
- SET status = ?, finished_at = CURRENT_TIMESTAMP
75
- WHERE id = ?;
76
- `, [status, jobId]);
77
- }
78
- /**
79
- * Checks if the current job has been marked for cancellation by another event
80
- */
81
- async isCancelled(jobId) {
82
- const job = await db.get(`SELECT status FROM jobs WHERE id = ?;`, [jobId]);
83
- return job?.status === 'cancelling';
84
- }
85
- async clearStaleJobs() {
86
- return await db.run(`UPDATE jobs SET status = 'pending', worker_id = NULL WHERE status = 'running' AND started_at < datetime('now', '-1 hour');`);
87
- }
88
- /**
89
- * Save complete execution report JSON to DB
90
- */
91
- async saveReport(jobId, report) {
92
- await db.run(`UPDATE jobs SET report = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [
93
- JSON.stringify(report),
94
- jobId,
95
- ]);
96
- }
97
- /**
98
- * Fetch job details + report by ID
99
- */
100
- async getJob(jobId) {
101
- return db.get(`SELECT * FROM jobs WHERE id = ?`, [jobId]);
102
- }
103
- /**
104
- * List recent jobs for dashboard status monitoring
105
- */
106
- async listJobs(limit = 50) {
107
- return db.all(`SELECT id, workflow_id, status, concurrency_key, worker_id, created_at, updated_at, report
108
- FROM jobs
109
- ORDER BY id DESC
110
- LIMIT ?`, [limit]);
111
- }
112
- }
@@ -1,108 +0,0 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { AnsiUp } from 'ansi_up';
4
- export class HtmlReporter {
5
- name = 'html-reporter';
6
- outputDir;
7
- ansiUp;
8
- constructor(options) {
9
- this.outputDir = options.outputDir;
10
- this.ansiUp = new AnsiUp();
11
- this.ansiUp.use_classes = false; // Inline CSS styling for portability
12
- }
13
- async report(execReport) {
14
- fs.mkdirSync(this.outputDir, { recursive: true });
15
- const htmlContent = this.generateHtml(execReport);
16
- const filePath = path.join(this.outputDir, `run-${execReport.jobId}.html`);
17
- fs.writeFileSync(filePath, htmlContent, 'utf-8');
18
- console.log(`📊 HTML Execution Report generated: ${filePath}`);
19
- }
20
- /**
21
- * Render standalone HTML template using Tailwind CSS via CDN
22
- */
23
- generateHtml(execReport) {
24
- const statusColor = execReport.status === 'success'
25
- ? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20'
26
- : execReport.status === 'failed'
27
- ? 'bg-rose-500/10 text-rose-400 border-rose-500/20'
28
- : 'bg-amber-500/10 text-amber-400 border-amber-500/20';
29
- // Render Steps & Logs
30
- const stepRows = execReport.steps
31
- .map((step, idx) => {
32
- let rawLog = '';
33
- if (step.logFilePath && fs.existsSync(step.logFilePath)) {
34
- try {
35
- rawLog = fs.readFileSync(step.logFilePath, 'utf-8');
36
- }
37
- catch { }
38
- }
39
- const htmlLog = rawLog
40
- ? this.ansiUp.ansi_to_html(rawLog)
41
- : '<span class="text-gray-500">(No terminal log output recorded for this step)</span>';
42
- const stepBadge = step.status === 'success'
43
- ? 'text-emerald-400 bg-emerald-500/10'
44
- : step.status === 'failed'
45
- ? 'text-rose-400 bg-rose-500/10'
46
- : 'text-gray-400 bg-gray-500/10';
47
- return `
48
- <div class="border border-gray-800 rounded-xl overflow-hidden bg-gray-900/50 mb-4">
49
- <div class="flex items-center justify-between p-4 bg-gray-800/40 border-b border-gray-800">
50
- <div class="flex items-center gap-3">
51
- <span class="text-xs font-mono text-gray-500">#${idx + 1}</span>
52
- <h3 class="font-semibold text-gray-200">${step.name}</h3>
53
- <span class="px-2.5 py-0.5 rounded-full text-xs font-medium ${stepBadge}">
54
- ${step.status.toUpperCase()}
55
- </span>
56
- </div>
57
- <div class="text-sm font-mono text-gray-400">${step.durationMs}ms</div>
58
- </div>
59
- <div class="p-4 bg-gray-950 font-mono text-xs overflow-x-auto text-gray-300 leading-relaxed max-h-96">
60
- <pre class="whitespace-pre-wrap">${htmlLog}</pre>
61
- </div>
62
- </div>
63
- `;
64
- })
65
- .join('');
66
- return `<!DOCTYPE html>
67
- <html lang="en" class="dark">
68
- <head>
69
- <meta charset="UTF-8">
70
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
71
- <title>Run #${execReport.jobId} - ${execReport.workflowName}</title>
72
- <script src="https://cdn.tailwindcss.com"></script>
73
- </head>
74
- <body class="bg-gray-950 text-gray-100 min-h-screen p-6 font-sans">
75
- <div class="max-w-5xl mx-auto space-y-6">
76
-
77
- <!-- Top Header -->
78
- <div class="flex items-center justify-between border-b border-gray-800 pb-6">
79
- <div>
80
- <a href="/runs" class="text-xs text-indigo-400 hover:underline mb-1 inline-block">← Back to Dashboard</a>
81
- <h1 class="text-2xl font-bold text-white flex items-center gap-3">
82
- ${execReport.workflowName}
83
- <span class="text-sm font-mono text-gray-500">#${execReport.jobId}</span>
84
- </h1>
85
- <p class="text-xs text-gray-400 mt-1">Started ${execReport.startedAt} • Finished in ${execReport.durationMs}ms</p>
86
- </div>
87
- <span class="px-4 py-1.5 rounded-full text-sm font-semibold border ${statusColor}">
88
- ${execReport.status.toUpperCase()}
89
- </span>
90
- </div>
91
-
92
- <!-- Inputs Overview -->
93
- <div class="bg-gray-900 border border-gray-800 rounded-xl p-4">
94
- <h2 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Trigger Inputs</h2>
95
- <pre class="font-mono text-xs text-indigo-300 bg-gray-950 p-3 rounded-lg overflow-x-auto">${JSON.stringify(execReport.inputs, null, 2)}</pre>
96
- </div>
97
-
98
- <!-- Steps Timeline -->
99
- <div>
100
- <h2 class="text-lg font-semibold text-white mb-4">Execution Steps</h2>
101
- ${stepRows}
102
- </div>
103
-
104
- </div>
105
- </body>
106
- </html>`;
107
- }
108
- }