@odavl/guardian 0.1.0-rc1 → 1.0.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/CHANGELOG.md +146 -0
- package/README.md +155 -97
- package/bin/guardian.js +1544 -55
- package/config/README.md +59 -0
- package/config/profiles/landing-demo.yaml +16 -0
- package/package.json +26 -11
- package/policies/landing-demo.json +22 -0
- package/src/enterprise/audit-logger.js +166 -0
- package/src/enterprise/pdf-exporter.js +267 -0
- package/src/enterprise/rbac-gate.js +142 -0
- package/src/enterprise/rbac.js +239 -0
- package/src/enterprise/site-manager.js +180 -0
- package/src/founder/feedback-system.js +156 -0
- package/src/founder/founder-tracker.js +213 -0
- package/src/founder/usage-signals.js +141 -0
- package/src/guardian/alert-ledger.js +121 -0
- package/src/guardian/attempt-engine.js +587 -12
- package/src/guardian/attempt-registry.js +42 -1
- package/src/guardian/attempt-relevance.js +106 -0
- package/src/guardian/attempt.js +85 -39
- package/src/guardian/attempts-filter.js +63 -0
- package/src/guardian/baseline.js +50 -8
- package/src/guardian/breakage-intelligence.js +1 -0
- package/src/guardian/browser-pool.js +131 -0
- package/src/guardian/browser.js +28 -1
- package/src/guardian/ci-cli.js +121 -0
- package/src/guardian/ci-mode.js +15 -0
- package/src/guardian/ci-output.js +38 -0
- package/src/guardian/cli-summary.js +167 -67
- package/src/guardian/config-loader.js +162 -0
- package/src/guardian/data-guardian-detector.js +189 -0
- package/src/guardian/detection-layers.js +271 -0
- package/src/guardian/drift-detector.js +100 -0
- package/src/guardian/enhanced-html-reporter.js +221 -4
- package/src/guardian/env-guard.js +127 -0
- package/src/guardian/failure-intelligence.js +173 -0
- package/src/guardian/first-run-profile.js +89 -0
- package/src/guardian/first-run.js +54 -0
- package/src/guardian/flag-validator.js +111 -0
- package/src/guardian/flow-executor.js +309 -44
- package/src/guardian/html-reporter.js +2 -0
- package/src/guardian/human-reporter.js +431 -0
- package/src/guardian/index.js +22 -19
- package/src/guardian/init-command.js +9 -5
- package/src/guardian/intent-detector.js +146 -0
- package/src/guardian/journey-definitions.js +132 -0
- package/src/guardian/journey-scan-cli.js +145 -0
- package/src/guardian/journey-scanner.js +583 -0
- package/src/guardian/junit-reporter.js +18 -1
- package/src/guardian/language-detection.js +99 -0
- package/src/guardian/live-cli.js +95 -0
- package/src/guardian/live-scheduler-runner.js +137 -0
- package/src/guardian/live-scheduler.js +146 -0
- package/src/guardian/market-reporter.js +357 -82
- package/src/guardian/parallel-executor.js +116 -0
- package/src/guardian/pattern-analyzer.js +348 -0
- package/src/guardian/policy.js +80 -3
- package/src/guardian/prerequisite-checker.js +101 -0
- package/src/guardian/preset-loader.js +27 -18
- package/src/guardian/profile-loader.js +96 -0
- package/src/guardian/reality.js +1612 -115
- package/src/guardian/reporter.js +27 -41
- package/src/guardian/run-artifacts.js +212 -0
- package/src/guardian/run-cleanup.js +207 -0
- package/src/guardian/run-latest.js +90 -0
- package/src/guardian/run-list.js +211 -0
- package/src/guardian/run-summary.js +20 -0
- package/src/guardian/scan-presets.js +100 -11
- package/src/guardian/selector-fallbacks.js +394 -0
- package/src/guardian/semantic-contact-detection.js +255 -0
- package/src/guardian/semantic-contact-finder.js +201 -0
- package/src/guardian/semantic-targets.js +234 -0
- package/src/guardian/site-introspection.js +257 -0
- package/src/guardian/smoke.js +258 -0
- package/src/guardian/snapshot-schema.js +25 -1
- package/src/guardian/snapshot.js +69 -3
- package/src/guardian/stability-scorer.js +169 -0
- package/src/guardian/success-evaluator.js +214 -0
- package/src/guardian/template-command.js +184 -0
- package/src/guardian/text-formatters.js +426 -0
- package/src/guardian/timeout-profiles.js +57 -0
- package/src/guardian/verdict.js +320 -0
- package/src/guardian/verdicts.js +74 -0
- package/src/guardian/wait-for-outcome.js +120 -0
- package/src/guardian/watch-runner.js +181 -0
- package/src/payments/stripe-checkout.js +169 -0
- package/src/plans/plan-definitions.js +148 -0
- package/src/plans/plan-manager.js +211 -0
- package/src/plans/usage-tracker.js +210 -0
- package/src/recipes/recipe-engine.js +188 -0
- package/src/recipes/recipe-failure-analysis.js +159 -0
- package/src/recipes/recipe-registry.js +134 -0
- package/src/recipes/recipe-runtime.js +507 -0
- package/src/recipes/recipe-store.js +410 -0
- package/guardian-contract-v1.md +0 -149
- /package/{guardian.config.json → config/guardian.config.json} +0 -0
- /package/{guardian.policy.json → config/guardian.policy.json} +0 -0
- /package/{guardian.profile.docs.yaml → config/profiles/docs.yaml} +0 -0
- /package/{guardian.profile.ecommerce.yaml → config/profiles/ecommerce.yaml} +0 -0
- /package/{guardian.profile.marketing.yaml → config/profiles/marketing.yaml} +0 -0
- /package/{guardian.profile.saas.yaml → config/profiles/saas.yaml} +0 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Language Detection
|
|
3
|
+
*
|
|
4
|
+
* Deterministic language detection from HTML attributes.
|
|
5
|
+
* No guessing, no AI. Only reads explicit language declarations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Detect page language from HTML
|
|
10
|
+
*
|
|
11
|
+
* Detection order:
|
|
12
|
+
* 1. <html lang="..."> attribute
|
|
13
|
+
* 2. <meta http-equiv="content-language" ...> attribute
|
|
14
|
+
* 3. fallback: "unknown"
|
|
15
|
+
*
|
|
16
|
+
* @param {Page} page - Playwright page object
|
|
17
|
+
* @returns {Promise<string>} BCP-47 language code or "unknown"
|
|
18
|
+
*/
|
|
19
|
+
async function detectLanguage(page) {
|
|
20
|
+
try {
|
|
21
|
+
// Try <html lang="...">
|
|
22
|
+
const htmlLang = await page.evaluate(() => {
|
|
23
|
+
const htmlElement = document.documentElement;
|
|
24
|
+
return htmlElement.getAttribute('lang');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
if (htmlLang && htmlLang.trim()) {
|
|
28
|
+
// Return the language code (e.g., "de", "de-DE", "en", "en-US")
|
|
29
|
+
return htmlLang.trim().toLowerCase();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Try <meta http-equiv="content-language" ...>
|
|
33
|
+
const metaLang = await page.evaluate(() => {
|
|
34
|
+
const meta = document.querySelector('meta[http-equiv="content-language"]');
|
|
35
|
+
return meta ? meta.getAttribute('content') : null;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (metaLang && metaLang.trim()) {
|
|
39
|
+
return metaLang.trim().toLowerCase();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Fallback
|
|
43
|
+
return 'unknown';
|
|
44
|
+
} catch (error) {
|
|
45
|
+
// If evaluation fails, return unknown
|
|
46
|
+
return 'unknown';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parse BCP-47 language code to get primary language
|
|
52
|
+
* e.g., "de-DE" -> "de", "en-US" -> "en"
|
|
53
|
+
*/
|
|
54
|
+
function getPrimaryLanguage(languageCode) {
|
|
55
|
+
if (!languageCode || languageCode === 'unknown') {
|
|
56
|
+
return 'unknown';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Extract primary language (before first hyphen)
|
|
60
|
+
const primary = languageCode.split('-')[0].toLowerCase();
|
|
61
|
+
return primary || 'unknown';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get human-readable language name from code
|
|
66
|
+
*/
|
|
67
|
+
const LANGUAGE_NAMES = {
|
|
68
|
+
'de': 'German',
|
|
69
|
+
'en': 'English',
|
|
70
|
+
'es': 'Spanish',
|
|
71
|
+
'fr': 'French',
|
|
72
|
+
'pt': 'Portuguese',
|
|
73
|
+
'it': 'Italian',
|
|
74
|
+
'nl': 'Dutch',
|
|
75
|
+
'sv': 'Swedish',
|
|
76
|
+
'ar': 'Arabic',
|
|
77
|
+
'zh': 'Chinese',
|
|
78
|
+
'ja': 'Japanese',
|
|
79
|
+
'ko': 'Korean',
|
|
80
|
+
'ru': 'Russian',
|
|
81
|
+
'pl': 'Polish',
|
|
82
|
+
'unknown': 'Unknown'
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
function getLanguageName(languageCode) {
|
|
86
|
+
if (!languageCode || languageCode === 'unknown') {
|
|
87
|
+
return 'Unknown';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const primary = getPrimaryLanguage(languageCode);
|
|
91
|
+
return LANGUAGE_NAMES[primary] || `Unknown (${primary})`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = {
|
|
95
|
+
detectLanguage,
|
|
96
|
+
getPrimaryLanguage,
|
|
97
|
+
getLanguageName,
|
|
98
|
+
LANGUAGE_NAMES
|
|
99
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live Guardian CLI
|
|
3
|
+
* Periodically run journey scans and compare against baseline.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { runJourneyScanCLI } = require('./journey-scan-cli');
|
|
9
|
+
const { getJourneyDefinition } = require('./journey-definitions');
|
|
10
|
+
const { detectIntent } = require('./intent-detector');
|
|
11
|
+
const { JourneyScanner } = require('./journey-scanner');
|
|
12
|
+
const { buildBaselineFromJourneyResult, compareAgainstBaseline, classifySeverity } = require('./drift-detector');
|
|
13
|
+
const { shouldEmitAlert, recordAlert } = require('./alert-ledger');
|
|
14
|
+
|
|
15
|
+
async function runLiveCLI(config) {
|
|
16
|
+
const { baseUrl, artifactsDir = './.odavlguardian', intervalMinutes = null, headless = true, timeout = 20000, preset, presetProvided = false, cooldownMinutes = 60 } = config;
|
|
17
|
+
|
|
18
|
+
// Ensure output directories
|
|
19
|
+
if (!fs.existsSync(artifactsDir)) fs.mkdirSync(artifactsDir, { recursive: true });
|
|
20
|
+
const baselinePath = path.join(artifactsDir, 'baseline.json');
|
|
21
|
+
|
|
22
|
+
async function singleRunEvaluate() {
|
|
23
|
+
// Auto intent + journey selection if preset not provided
|
|
24
|
+
let finalPreset = preset || 'saas';
|
|
25
|
+
let intentDetection = null;
|
|
26
|
+
if (!presetProvided) {
|
|
27
|
+
intentDetection = await detectIntent(baseUrl, { timeout, headless });
|
|
28
|
+
if (intentDetection.intent === 'saas') finalPreset = 'saas';
|
|
29
|
+
else if (intentDetection.intent === 'shop') finalPreset = 'shop';
|
|
30
|
+
else if (intentDetection.intent === 'landing') finalPreset = 'landing';
|
|
31
|
+
else finalPreset = 'landing';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const journey = getJourneyDefinition(finalPreset);
|
|
35
|
+
const scanner = new JourneyScanner({ timeout, headless, screenshotDir: path.join(artifactsDir, 'screenshots') });
|
|
36
|
+
const result = await scanner.scan(baseUrl, journey);
|
|
37
|
+
if (intentDetection) result.intentDetection = intentDetection;
|
|
38
|
+
|
|
39
|
+
// Baseline capture on first run
|
|
40
|
+
if (!fs.existsSync(baselinePath)) {
|
|
41
|
+
const baseline = buildBaselineFromJourneyResult(result);
|
|
42
|
+
fs.writeFileSync(baselinePath, JSON.stringify(baseline, null, 2), 'utf8');
|
|
43
|
+
console.log('📌 Baseline captured.');
|
|
44
|
+
return { result, baseline, drift: { hasDrift: false, reasons: [] }, exitCode: 0 };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Load baseline and compare
|
|
48
|
+
const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
|
|
49
|
+
const drift = compareAgainstBaseline(baseline, result);
|
|
50
|
+
const severity = classifySeverity(drift, result);
|
|
51
|
+
|
|
52
|
+
// Write report and baseline comparison summary
|
|
53
|
+
const { HumanReporter } = require('./human-reporter');
|
|
54
|
+
result.baseline = baseline;
|
|
55
|
+
result.drift = drift;
|
|
56
|
+
result.severity = severity;
|
|
57
|
+
const reporter = new HumanReporter();
|
|
58
|
+
reporter.generateSummary(result, artifactsDir);
|
|
59
|
+
reporter.generateJSON(result, artifactsDir);
|
|
60
|
+
|
|
61
|
+
if (drift.hasDrift) {
|
|
62
|
+
const alertDecision = shouldEmitAlert(drift.reasons, severity, artifactsDir, cooldownMinutes);
|
|
63
|
+
|
|
64
|
+
if (alertDecision.emit) {
|
|
65
|
+
console.log(`🚨 Guardian detected a behavioral regression (${severity}):`);
|
|
66
|
+
for (const r of drift.reasons) console.log(`– ${r}`);
|
|
67
|
+
recordAlert(alertDecision.signature, severity, artifactsDir);
|
|
68
|
+
return { result, baseline, drift, severity, exitCode: 3 };
|
|
69
|
+
} else {
|
|
70
|
+
console.log(`✅ Drift detected but alert suppressed (${alertDecision.reason})`);
|
|
71
|
+
return { result, baseline, drift, severity, exitCode: 0 };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.log('✅ No regression detected.');
|
|
76
|
+
return { result, baseline, drift, severity, exitCode: 0 };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (intervalMinutes && intervalMinutes > 0) {
|
|
80
|
+
let running = true;
|
|
81
|
+
const handleSigint = () => { running = false; console.log('\n🛑 Live Guardian stopped.'); process.exit(0); };
|
|
82
|
+
process.on('SIGINT', handleSigint);
|
|
83
|
+
console.log(`⏱️ Live mode: every ${intervalMinutes} minute(s)`);
|
|
84
|
+
while (running) {
|
|
85
|
+
const { exitCode } = await singleRunEvaluate();
|
|
86
|
+
if (exitCode === 3) process.exit(3);
|
|
87
|
+
await new Promise(r => setTimeout(r, intervalMinutes * 60 * 1000));
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
const { exitCode } = await singleRunEvaluate();
|
|
91
|
+
process.exit(exitCode);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { runLiveCLI };
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live Scheduler Runner
|
|
3
|
+
* Detached background process that executes schedules on time.
|
|
4
|
+
*
|
|
5
|
+
* Each tick invokes the existing CLI path: "guardian live <url> [--preset ...]"
|
|
6
|
+
* to reuse baseline/drift/alerts and plan/RBAC enforcement without refactoring.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const { spawn } = require('child_process');
|
|
13
|
+
|
|
14
|
+
const SCHED_DIR = path.join(os.homedir(), '.odavl-guardian', 'scheduler');
|
|
15
|
+
const STATE_FILE = path.join(SCHED_DIR, 'schedules.json');
|
|
16
|
+
|
|
17
|
+
// Active timers per schedule id
|
|
18
|
+
const timers = new Map();
|
|
19
|
+
|
|
20
|
+
function loadState() {
|
|
21
|
+
try {
|
|
22
|
+
const data = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
|
|
23
|
+
if (!data.schedules) data.schedules = [];
|
|
24
|
+
return data;
|
|
25
|
+
} catch {
|
|
26
|
+
return { schedules: [] };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function saveState(state) {
|
|
31
|
+
const data = {
|
|
32
|
+
schedules: state.schedules || [],
|
|
33
|
+
runner: state.runner || null,
|
|
34
|
+
updatedAt: new Date().toISOString(),
|
|
35
|
+
};
|
|
36
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(data, null, 2), 'utf-8');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function scheduleRun(entry) {
|
|
40
|
+
// Clear existing timer
|
|
41
|
+
const existing = timers.get(entry.id);
|
|
42
|
+
if (existing) {
|
|
43
|
+
clearTimeout(existing);
|
|
44
|
+
timers.delete(entry.id);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (entry.status !== 'running') {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
const nextTs = entry.nextRunAt ? Date.parse(entry.nextRunAt) : (now + entry.intervalMinutes * 60 * 1000);
|
|
53
|
+
const delay = Math.max(0, nextTs - now);
|
|
54
|
+
|
|
55
|
+
const t = setTimeout(() => {
|
|
56
|
+
executeLive(entry).then(() => {
|
|
57
|
+
// Update times and reschedule
|
|
58
|
+
const state = loadState();
|
|
59
|
+
const s = state.schedules.find(x => x.id === entry.id);
|
|
60
|
+
if (s) {
|
|
61
|
+
const finished = Date.now();
|
|
62
|
+
s.lastRunAt = new Date(finished).toISOString();
|
|
63
|
+
s.nextRunAt = new Date(finished + s.intervalMinutes * 60 * 1000).toISOString();
|
|
64
|
+
saveState(state);
|
|
65
|
+
scheduleRun(s);
|
|
66
|
+
}
|
|
67
|
+
}).catch(() => {
|
|
68
|
+
// On failure, still advance nextRunAt
|
|
69
|
+
const state = loadState();
|
|
70
|
+
const s = state.schedules.find(x => x.id === entry.id);
|
|
71
|
+
if (s) {
|
|
72
|
+
const finished = Date.now();
|
|
73
|
+
s.lastRunAt = new Date(finished).toISOString();
|
|
74
|
+
s.nextRunAt = new Date(finished + s.intervalMinutes * 60 * 1000).toISOString();
|
|
75
|
+
saveState(state);
|
|
76
|
+
scheduleRun(s);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}, delay);
|
|
80
|
+
|
|
81
|
+
timers.set(entry.id, t);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function executeLive(entry) {
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
// Spawn the existing CLI path in a child process.
|
|
87
|
+
const nodeExec = process.execPath;
|
|
88
|
+
const binPath = path.join(__dirname, '..', '..', 'bin', 'guardian.js');
|
|
89
|
+
const args = ['live', '--url', entry.url];
|
|
90
|
+
if (entry.preset) {
|
|
91
|
+
args.push('--preset', entry.preset);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const child = spawn(nodeExec, [binPath, ...args], {
|
|
95
|
+
stdio: 'ignore',
|
|
96
|
+
windowsHide: true,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
child.on('exit', () => resolve());
|
|
100
|
+
child.on('error', () => resolve());
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function reconcile() {
|
|
105
|
+
const state = loadState();
|
|
106
|
+
// For every running schedule, ensure a timer exists
|
|
107
|
+
for (const s of state.schedules) {
|
|
108
|
+
if (s.status === 'running') {
|
|
109
|
+
scheduleRun(s);
|
|
110
|
+
} else {
|
|
111
|
+
const t = timers.get(s.id);
|
|
112
|
+
if (t) {
|
|
113
|
+
clearTimeout(t);
|
|
114
|
+
timers.delete(s.id);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function startWatch() {
|
|
121
|
+
try {
|
|
122
|
+
fs.watch(STATE_FILE, { persistent: true }, () => {
|
|
123
|
+
reconcile();
|
|
124
|
+
});
|
|
125
|
+
} catch {
|
|
126
|
+
// Fallback: periodic reconcile
|
|
127
|
+
setInterval(reconcile, 5000);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function main() {
|
|
132
|
+
// Initial reconcile and watch for changes
|
|
133
|
+
reconcile();
|
|
134
|
+
startWatch();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
main();
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live Scheduler (Phase A)
|
|
3
|
+
* Local-first, time-based scheduler with persisted state.
|
|
4
|
+
*
|
|
5
|
+
* Schedules are stored under ~/.odavl-guardian/scheduler/schedules.json
|
|
6
|
+
* Background runner: live-scheduler-runner.js (detached child process).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const crypto = require('crypto');
|
|
13
|
+
const { spawn } = require('child_process');
|
|
14
|
+
|
|
15
|
+
const SCHED_DIR = path.join(os.homedir(), '.odavl-guardian', 'scheduler');
|
|
16
|
+
const STATE_FILE = path.join(SCHED_DIR, 'schedules.json');
|
|
17
|
+
const RUNNER_FILE = path.join(__dirname, 'live-scheduler-runner.js');
|
|
18
|
+
|
|
19
|
+
function ensureSchedDir() {
|
|
20
|
+
if (!fs.existsSync(SCHED_DIR)) {
|
|
21
|
+
fs.mkdirSync(SCHED_DIR, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function loadState() {
|
|
26
|
+
ensureSchedDir();
|
|
27
|
+
if (!fs.existsSync(STATE_FILE)) {
|
|
28
|
+
return { schedules: [], runner: null };
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const data = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
|
|
32
|
+
if (!data.schedules) data.schedules = [];
|
|
33
|
+
return data;
|
|
34
|
+
} catch (err) {
|
|
35
|
+
return { schedules: [], runner: null };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function saveState(state) {
|
|
40
|
+
ensureSchedDir();
|
|
41
|
+
const data = {
|
|
42
|
+
schedules: state.schedules || [],
|
|
43
|
+
runner: state.runner || null,
|
|
44
|
+
updatedAt: new Date().toISOString(),
|
|
45
|
+
};
|
|
46
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(data, null, 2), 'utf-8');
|
|
47
|
+
return data;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function genId() {
|
|
51
|
+
return crypto.randomBytes(8).toString('hex');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Create and register a new schedule
|
|
56
|
+
*/
|
|
57
|
+
function createSchedule({ url, preset = 'saas', intervalMinutes }) {
|
|
58
|
+
if (!url || typeof url !== 'string') throw new Error('url is required');
|
|
59
|
+
if (!intervalMinutes || intervalMinutes <= 0) throw new Error('intervalMinutes must be > 0');
|
|
60
|
+
|
|
61
|
+
const state = loadState();
|
|
62
|
+
const id = genId();
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
const nextRunAt = new Date(now + intervalMinutes * 60 * 1000).toISOString();
|
|
65
|
+
|
|
66
|
+
const entry = {
|
|
67
|
+
id,
|
|
68
|
+
url,
|
|
69
|
+
preset,
|
|
70
|
+
intervalMinutes,
|
|
71
|
+
status: 'running',
|
|
72
|
+
lastRunAt: null,
|
|
73
|
+
nextRunAt,
|
|
74
|
+
createdAt: new Date().toISOString(),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
state.schedules.push(entry);
|
|
78
|
+
saveState(state);
|
|
79
|
+
return entry;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Stop a schedule by id
|
|
84
|
+
*/
|
|
85
|
+
function stopSchedule(id) {
|
|
86
|
+
const state = loadState();
|
|
87
|
+
const s = state.schedules.find(x => x.id === id);
|
|
88
|
+
if (!s) throw new Error(`Schedule not found: ${id}`);
|
|
89
|
+
s.status = 'stopped';
|
|
90
|
+
saveState(state);
|
|
91
|
+
return s;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* List schedules
|
|
96
|
+
*/
|
|
97
|
+
function listSchedules() {
|
|
98
|
+
const state = loadState();
|
|
99
|
+
return state.schedules;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Check if runner pid is active
|
|
104
|
+
*/
|
|
105
|
+
function isRunnerActive(pid) {
|
|
106
|
+
if (!pid) return false;
|
|
107
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Start background runner (detached)
|
|
112
|
+
*/
|
|
113
|
+
function startBackgroundRunner() {
|
|
114
|
+
ensureSchedDir();
|
|
115
|
+
const state = loadState();
|
|
116
|
+
|
|
117
|
+
// Avoid multiple runners
|
|
118
|
+
if (state.runner && isRunnerActive(state.runner.pid)) {
|
|
119
|
+
return state.runner;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const nodeExec = process.execPath;
|
|
123
|
+
const child = spawn(nodeExec, [RUNNER_FILE], {
|
|
124
|
+
detached: true,
|
|
125
|
+
stdio: 'ignore',
|
|
126
|
+
windowsHide: true,
|
|
127
|
+
});
|
|
128
|
+
const runnerInfo = {
|
|
129
|
+
pid: child.pid,
|
|
130
|
+
startedAt: new Date().toISOString(),
|
|
131
|
+
};
|
|
132
|
+
child.unref();
|
|
133
|
+
|
|
134
|
+
state.runner = runnerInfo;
|
|
135
|
+
saveState(state);
|
|
136
|
+
return runnerInfo;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = {
|
|
140
|
+
createSchedule,
|
|
141
|
+
stopSchedule,
|
|
142
|
+
listSchedules,
|
|
143
|
+
startBackgroundRunner,
|
|
144
|
+
loadState,
|
|
145
|
+
saveState,
|
|
146
|
+
};
|