@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
package/src/guardian/reporter.js
CHANGED
|
@@ -25,39 +25,26 @@ class GuardianReporter {
|
|
|
25
25
|
|
|
26
26
|
createReport(crawlResult, baseUrl) {
|
|
27
27
|
const { visited, totalDiscovered, totalVisited } = crawlResult;
|
|
28
|
-
|
|
29
|
-
const coverage = totalDiscovered > 0
|
|
28
|
+
|
|
29
|
+
const coverage = totalDiscovered > 0
|
|
30
30
|
? parseFloat(((totalVisited / totalDiscovered) * 100).toFixed(2))
|
|
31
31
|
: 0;
|
|
32
|
-
|
|
33
|
-
// Calculate confidence
|
|
34
|
-
let confidenceLevel = 'LOW';
|
|
35
|
-
if (coverage >= 85) {
|
|
36
|
-
confidenceLevel = 'HIGH';
|
|
37
|
-
} else if (coverage >= 60) {
|
|
38
|
-
confidenceLevel = 'MEDIUM';
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Calculate decision
|
|
42
|
-
let decision = 'READY';
|
|
43
|
-
if (coverage < 30) {
|
|
44
|
-
decision = 'DO_NOT_LAUNCH';
|
|
45
|
-
} else if (coverage < 60) {
|
|
46
|
-
decision = 'INSUFFICIENT_CONFIDENCE';
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Check for critical errors (server errors only, not 404s)
|
|
32
|
+
|
|
50
33
|
const failedPages = visited.filter(p => p.status && p.status >= 500);
|
|
34
|
+
const observedPages = visited.filter(p => p.status && p.status < 500);
|
|
35
|
+
|
|
36
|
+
const reasons = [];
|
|
37
|
+
reasons.push(`Observed page reachability only: visited ${totalVisited} page(s), discovered ${totalDiscovered}, coverage ${coverage}%.`);
|
|
51
38
|
if (failedPages.length > 0) {
|
|
52
|
-
|
|
39
|
+
reasons.push(`${failedPages.length} page(s) returned server errors or navigation failures.`);
|
|
53
40
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
41
|
+
if (observedPages.length > 0) {
|
|
42
|
+
reasons.push(`HTTP responses observed for ${observedPages.length} page(s); no user flows or form submissions were executed.`);
|
|
43
|
+
}
|
|
44
|
+
reasons.push('No end-to-end user flows were validated; results are limited to link discovery and HTTP status observations.');
|
|
45
|
+
|
|
46
|
+
const decision = 'INSUFFICIENT_DATA';
|
|
47
|
+
|
|
61
48
|
return {
|
|
62
49
|
version: 'mvp-0.1',
|
|
63
50
|
timestamp: new Date().toISOString(),
|
|
@@ -69,12 +56,12 @@ class GuardianReporter {
|
|
|
69
56
|
failedPages: failedPages.length
|
|
70
57
|
},
|
|
71
58
|
confidence: {
|
|
72
|
-
level:
|
|
73
|
-
reasoning:
|
|
59
|
+
level: 'LOW',
|
|
60
|
+
reasoning: 'Only page reachability was observed; no user flows were confirmed.'
|
|
74
61
|
},
|
|
75
62
|
finalJudgment: {
|
|
76
63
|
decision: decision,
|
|
77
|
-
reasons: reasons
|
|
64
|
+
reasons: reasons
|
|
78
65
|
},
|
|
79
66
|
pages: visited.map((p, i) => ({
|
|
80
67
|
index: i + 1,
|
|
@@ -100,17 +87,16 @@ class GuardianReporter {
|
|
|
100
87
|
? parseFloat(((stepsExecuted / stepsTotal) * 100).toFixed(2))
|
|
101
88
|
: 0;
|
|
102
89
|
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
90
|
+
const decision = success ? 'OBSERVED' : 'PARTIAL';
|
|
91
|
+
const confidenceLevel = success ? 'MEDIUM' : 'LOW';
|
|
92
|
+
|
|
107
93
|
const reasons = [];
|
|
108
94
|
if (success) {
|
|
109
|
-
reasons.push(`
|
|
110
|
-
reasons.push(
|
|
95
|
+
reasons.push(`Observed flow "${flowName}" end-to-end; ${stepsExecuted}/${stepsTotal} steps completed.`);
|
|
96
|
+
reasons.push('No critical failures detected in this flow.');
|
|
111
97
|
} else {
|
|
112
|
-
reasons.push(`Flow "${flowName}"
|
|
113
|
-
reasons.push(
|
|
98
|
+
reasons.push(`Flow "${flowName}" did not complete; stopped at step ${failedStep || 'unknown'} with error: ${error || 'unspecified failure'}.`);
|
|
99
|
+
reasons.push('This run observed only the partial flow execution above; other flows were not validated.');
|
|
114
100
|
}
|
|
115
101
|
|
|
116
102
|
return {
|
|
@@ -135,8 +121,8 @@ class GuardianReporter {
|
|
|
135
121
|
confidence: {
|
|
136
122
|
level: confidenceLevel,
|
|
137
123
|
reasoning: success
|
|
138
|
-
? `
|
|
139
|
-
: `Flow failed at step ${failedStep}: ${error}
|
|
124
|
+
? `Observed single flow execution; steps completed ${stepsExecuted}/${stepsTotal}.`
|
|
125
|
+
: `Flow incomplete; failed at step ${failedStep || 'unknown'} with error: ${error || 'unspecified failure'}.`
|
|
140
126
|
},
|
|
141
127
|
finalJudgment: {
|
|
142
128
|
decision: decision,
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run Artifacts Naming & Metadata
|
|
3
|
+
* Deterministic naming and META.json generation for Guardian runs
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generate site slug from URL
|
|
11
|
+
*
|
|
12
|
+
* Rules:
|
|
13
|
+
* - Lowercase
|
|
14
|
+
* - Remove protocol (http/https)
|
|
15
|
+
* - Remove trailing slash
|
|
16
|
+
* - Replace non-alphanumeric with "-"
|
|
17
|
+
* - Collapse multiple "-" to one
|
|
18
|
+
* - Trim leading/trailing "-"
|
|
19
|
+
*
|
|
20
|
+
* @param {string} url - Original URL
|
|
21
|
+
* @returns {string} slug
|
|
22
|
+
*/
|
|
23
|
+
function makeSiteSlug(url) {
|
|
24
|
+
if (!url) return 'unknown';
|
|
25
|
+
|
|
26
|
+
// Parse URL to get host + path
|
|
27
|
+
let normalized = url.toLowerCase();
|
|
28
|
+
|
|
29
|
+
// Remove protocol
|
|
30
|
+
normalized = normalized.replace(/^https?:\/\//i, '');
|
|
31
|
+
|
|
32
|
+
// Remove trailing slash
|
|
33
|
+
normalized = normalized.replace(/\/$/, '');
|
|
34
|
+
|
|
35
|
+
// Replace any non-alphanumeric character (including colon, slash, dot) with hyphen
|
|
36
|
+
normalized = normalized.replace(/[^a-z0-9\-]/g, '-');
|
|
37
|
+
|
|
38
|
+
// Collapse multiple hyphens
|
|
39
|
+
normalized = normalized.replace(/-+/g, '-');
|
|
40
|
+
|
|
41
|
+
// Trim leading/trailing hyphens
|
|
42
|
+
normalized = normalized.replace(/^-+|-+$/g, '');
|
|
43
|
+
|
|
44
|
+
return normalized || 'unknown';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Generate human-readable run directory name
|
|
49
|
+
*
|
|
50
|
+
* Format: YYYY-MM-DD_HH-MM-SS_<siteSlug>_<policy>_<RESULT>
|
|
51
|
+
* Example: 2025-12-24_01-31-11_localhost-8001_startup_FAILED
|
|
52
|
+
*
|
|
53
|
+
* @param {Object} opts
|
|
54
|
+
* @param {Date|string} opts.timestamp - Execution time
|
|
55
|
+
* @param {string} opts.url - Site URL
|
|
56
|
+
* @param {string} opts.policy - Policy/profile name (or 'default')
|
|
57
|
+
* @param {string} opts.result - Result: PASSED, FAILED, or WARN
|
|
58
|
+
* @returns {string} directory name
|
|
59
|
+
*/
|
|
60
|
+
function makeRunDirName(opts) {
|
|
61
|
+
const { timestamp, url, policy, result } = opts;
|
|
62
|
+
|
|
63
|
+
// Parse timestamp
|
|
64
|
+
let time = timestamp instanceof Date ? timestamp : new Date(timestamp);
|
|
65
|
+
|
|
66
|
+
// Format: YYYY-MM-DD_HH-MM-SS
|
|
67
|
+
const year = time.getFullYear();
|
|
68
|
+
const month = String(time.getMonth() + 1).padStart(2, '0');
|
|
69
|
+
const day = String(time.getDate()).padStart(2, '0');
|
|
70
|
+
const hours = String(time.getHours()).padStart(2, '0');
|
|
71
|
+
const minutes = String(time.getMinutes()).padStart(2, '0');
|
|
72
|
+
const seconds = String(time.getSeconds()).padStart(2, '0');
|
|
73
|
+
|
|
74
|
+
const timeStr = `${year}-${month}-${day}_${hours}-${minutes}-${seconds}`;
|
|
75
|
+
|
|
76
|
+
// Generate slug
|
|
77
|
+
const slug = makeSiteSlug(url);
|
|
78
|
+
|
|
79
|
+
// Normalize policy (extract preset name if needed)
|
|
80
|
+
let policyName = policy || 'default';
|
|
81
|
+
if (policyName.startsWith('preset:')) {
|
|
82
|
+
policyName = policyName.replace('preset:', '');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Normalize result
|
|
86
|
+
const normalizedResult = (result || 'UNKNOWN').toUpperCase();
|
|
87
|
+
|
|
88
|
+
return `${timeStr}_${slug}_${policyName}_${normalizedResult}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Write META.json for a completed run
|
|
93
|
+
*
|
|
94
|
+
* @param {Object} opts
|
|
95
|
+
* @param {string} opts.runDir - Run directory path
|
|
96
|
+
* @param {string} opts.url - Original site URL
|
|
97
|
+
* @param {string} opts.siteSlug - Generated slug
|
|
98
|
+
* @param {string} opts.policy - Policy used
|
|
99
|
+
* @param {string} opts.result - PASSED|FAILED|WARN
|
|
100
|
+
* @param {number} opts.durationMs - Wall-clock duration
|
|
101
|
+
* @param {string} opts.profile - Detected site profile (ecommerce|saas|content|unknown)
|
|
102
|
+
* @param {Object} opts.attempts - Attempt statistics
|
|
103
|
+
* @param {number} opts.attempts.total
|
|
104
|
+
* @param {number} opts.attempts.executed
|
|
105
|
+
* @param {number} opts.attempts.successful
|
|
106
|
+
* @param {number} opts.attempts.failed
|
|
107
|
+
* @param {number} opts.attempts.skipped
|
|
108
|
+
* @param {Array} opts.attempts.skippedDetails - Array of {attempt, reason}
|
|
109
|
+
* @param {number} [opts.attempts.nearSuccess] - Count of near-success signals
|
|
110
|
+
* @param {Array} [opts.attempts.nearSuccessDetails] - Array of { attempt, reason }
|
|
111
|
+
* @throws {Error} if write fails
|
|
112
|
+
*/
|
|
113
|
+
function writeMetaJson(opts) {
|
|
114
|
+
const {
|
|
115
|
+
runDir,
|
|
116
|
+
url,
|
|
117
|
+
siteSlug,
|
|
118
|
+
policy,
|
|
119
|
+
result,
|
|
120
|
+
durationMs,
|
|
121
|
+
profile,
|
|
122
|
+
attempts,
|
|
123
|
+
verdict
|
|
124
|
+
} = opts;
|
|
125
|
+
|
|
126
|
+
const meta = {
|
|
127
|
+
version: 1,
|
|
128
|
+
timestamp: new Date().toISOString(),
|
|
129
|
+
url,
|
|
130
|
+
siteSlug,
|
|
131
|
+
policy: policy || 'default',
|
|
132
|
+
result: (result || 'UNKNOWN').toUpperCase(),
|
|
133
|
+
durationMs: Math.round(durationMs || 0),
|
|
134
|
+
profile: profile || 'unknown',
|
|
135
|
+
attempts: {
|
|
136
|
+
total: attempts?.total || 0,
|
|
137
|
+
executed: attempts?.executed || 0,
|
|
138
|
+
successful: attempts?.successful || 0,
|
|
139
|
+
failed: attempts?.failed || 0,
|
|
140
|
+
skipped: attempts?.skipped || 0,
|
|
141
|
+
skippedDetails: attempts?.skippedDetails || [],
|
|
142
|
+
nearSuccess: attempts?.nearSuccess || 0,
|
|
143
|
+
nearSuccessDetails: attempts?.nearSuccessDetails || []
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
if (verdict) {
|
|
147
|
+
meta.verdict = {
|
|
148
|
+
verdict: verdict.verdict,
|
|
149
|
+
confidence: verdict.confidence,
|
|
150
|
+
why: verdict.why || ''
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const metaPath = path.join(runDir, 'META.json');
|
|
155
|
+
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2));
|
|
156
|
+
|
|
157
|
+
return metaPath;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Read META.json from a run directory
|
|
162
|
+
*
|
|
163
|
+
* @param {string} runDir - Run directory path
|
|
164
|
+
* @returns {Object|null} parsed META.json or null if missing/invalid
|
|
165
|
+
*/
|
|
166
|
+
function readMetaJson(runDir) {
|
|
167
|
+
const metaPath = path.join(runDir, 'META.json');
|
|
168
|
+
|
|
169
|
+
if (!fs.existsSync(metaPath)) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
const content = fs.readFileSync(metaPath, 'utf8');
|
|
175
|
+
return JSON.parse(content);
|
|
176
|
+
} catch (e) {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Format duration for human-readable output
|
|
183
|
+
*
|
|
184
|
+
* @param {number} ms - Milliseconds
|
|
185
|
+
* @returns {string} formatted duration
|
|
186
|
+
*/
|
|
187
|
+
function formatDuration(ms) {
|
|
188
|
+
if (ms < 1000) {
|
|
189
|
+
return `${ms}ms`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const totalSecs = Math.floor(ms / 1000);
|
|
193
|
+
const hours = Math.floor(totalSecs / 3600);
|
|
194
|
+
const minutes = Math.floor((totalSecs % 3600) / 60);
|
|
195
|
+
const seconds = totalSecs % 60;
|
|
196
|
+
|
|
197
|
+
if (hours > 0) {
|
|
198
|
+
return `${hours}h ${minutes}m ${seconds}s`;
|
|
199
|
+
} else if (minutes > 0) {
|
|
200
|
+
return `${minutes}m ${seconds}s`;
|
|
201
|
+
} else {
|
|
202
|
+
return `${seconds}s`;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
module.exports = {
|
|
207
|
+
makeSiteSlug,
|
|
208
|
+
makeRunDirName,
|
|
209
|
+
writeMetaJson,
|
|
210
|
+
readMetaJson,
|
|
211
|
+
formatDuration
|
|
212
|
+
};
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run Cleanup & Management
|
|
3
|
+
* Delete old, excessive, or failed runs based on specified criteria
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { readMetaJson } = require('./run-artifacts');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse duration string (e.g. "7d", "24h", "30m")
|
|
12
|
+
*
|
|
13
|
+
* @param {string} durationStr - Duration like "7d", "24h", "30m"
|
|
14
|
+
* @returns {number} milliseconds
|
|
15
|
+
*/
|
|
16
|
+
function parseDuration(durationStr) {
|
|
17
|
+
const match = durationStr.match(/^(\d+)([dhm])$/);
|
|
18
|
+
if (!match) {
|
|
19
|
+
throw new Error(`Invalid duration format: ${durationStr}. Use Nd, Nh, or Nm (e.g., "7d", "24h", "30m")`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const value = parseInt(match[1], 10);
|
|
23
|
+
const unit = match[2];
|
|
24
|
+
|
|
25
|
+
switch (unit) {
|
|
26
|
+
case 'd':
|
|
27
|
+
return value * 24 * 60 * 60 * 1000;
|
|
28
|
+
case 'h':
|
|
29
|
+
return value * 60 * 60 * 1000;
|
|
30
|
+
case 'm':
|
|
31
|
+
return value * 60 * 1000;
|
|
32
|
+
default:
|
|
33
|
+
throw new Error(`Unknown time unit: ${unit}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Find all run directories
|
|
39
|
+
*
|
|
40
|
+
* @param {string} artifactsDir - Path to artifacts directory
|
|
41
|
+
* @returns {Array} array of { dirPath, dirName, meta }
|
|
42
|
+
*/
|
|
43
|
+
function loadAllRuns(artifactsDir) {
|
|
44
|
+
if (!fs.existsSync(artifactsDir)) {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const entries = fs.readdirSync(artifactsDir, { withFileTypes: true });
|
|
50
|
+
const runs = [];
|
|
51
|
+
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
if (!entry.isDirectory()) continue;
|
|
54
|
+
|
|
55
|
+
const dirPath = path.join(artifactsDir, entry.name);
|
|
56
|
+
const meta = readMetaJson(dirPath);
|
|
57
|
+
|
|
58
|
+
// Skip runs without META.json
|
|
59
|
+
if (!meta) continue;
|
|
60
|
+
|
|
61
|
+
runs.push({
|
|
62
|
+
dirPath,
|
|
63
|
+
dirName: entry.name,
|
|
64
|
+
meta
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return runs;
|
|
69
|
+
} catch (e) {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Group runs by siteSlug
|
|
76
|
+
*
|
|
77
|
+
* @param {Array} runs - Array of run objects
|
|
78
|
+
* @returns {Object} map of siteSlug → array of runs
|
|
79
|
+
*/
|
|
80
|
+
function groupBySite(runs) {
|
|
81
|
+
const grouped = {};
|
|
82
|
+
for (const run of runs) {
|
|
83
|
+
const slug = run.meta.siteSlug || 'unknown';
|
|
84
|
+
if (!grouped[slug]) {
|
|
85
|
+
grouped[slug] = [];
|
|
86
|
+
}
|
|
87
|
+
grouped[slug].push(run);
|
|
88
|
+
}
|
|
89
|
+
return grouped;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Sort runs by timestamp (newest first)
|
|
94
|
+
*
|
|
95
|
+
* @param {Array} runs - Array of run objects
|
|
96
|
+
* @returns {Array} sorted runs
|
|
97
|
+
*/
|
|
98
|
+
function sortByTimestamp(runs) {
|
|
99
|
+
return runs.sort((a, b) => {
|
|
100
|
+
const timeA = new Date(a.meta.timestamp).getTime();
|
|
101
|
+
const timeB = new Date(b.meta.timestamp).getTime();
|
|
102
|
+
return timeB - timeA;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Apply cleanup filters
|
|
108
|
+
*
|
|
109
|
+
* @param {Array} runs - Array of run objects
|
|
110
|
+
* @param {Object} opts - Options
|
|
111
|
+
* @param {string} opts.olderThan - Duration string (e.g., "7d")
|
|
112
|
+
* @param {number} opts.keepLatest - Number of latest runs to keep per site
|
|
113
|
+
* @param {boolean} opts.failedOnly - Only delete FAILED runs
|
|
114
|
+
* @returns {Array} runs to delete
|
|
115
|
+
*/
|
|
116
|
+
function selectRunsForDeletion(runs, opts = {}) {
|
|
117
|
+
let toDelete = [...runs];
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
|
|
120
|
+
// Filter by age
|
|
121
|
+
if (opts.olderThan) {
|
|
122
|
+
const ageMs = parseDuration(opts.olderThan);
|
|
123
|
+
const cutoffTime = now - ageMs;
|
|
124
|
+
toDelete = toDelete.filter(run => {
|
|
125
|
+
const runTime = new Date(run.meta.timestamp).getTime();
|
|
126
|
+
return runTime < cutoffTime;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Filter by status
|
|
131
|
+
if (opts.failedOnly) {
|
|
132
|
+
toDelete = toDelete.filter(run => run.meta.result === 'FAILED');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Apply keep-latest per site (only if we're not already filtering by age/status heavily)
|
|
136
|
+
if (opts.keepLatest && opts.keepLatest > 0) {
|
|
137
|
+
const grouped = groupBySite(toDelete);
|
|
138
|
+
const toKeepPaths = new Set();
|
|
139
|
+
|
|
140
|
+
for (const siteSlug in grouped) {
|
|
141
|
+
const siteRuns = sortByTimestamp(grouped[siteSlug]);
|
|
142
|
+
// Keep the latest N
|
|
143
|
+
for (let i = 0; i < Math.min(opts.keepLatest, siteRuns.length); i++) {
|
|
144
|
+
toKeepPaths.add(siteRuns[i].dirPath);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
toDelete = toDelete.filter(run => !toKeepPaths.has(run.dirPath));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return toDelete;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Execute cleanup
|
|
156
|
+
*
|
|
157
|
+
* @param {string} artifactsDir - Path to artifacts directory
|
|
158
|
+
* @param {Object} opts - Options
|
|
159
|
+
* @returns {Object} cleanup result
|
|
160
|
+
*/
|
|
161
|
+
function cleanup(artifactsDir = './artifacts', opts = {}) {
|
|
162
|
+
const allRuns = loadAllRuns(artifactsDir);
|
|
163
|
+
|
|
164
|
+
if (allRuns.length === 0) {
|
|
165
|
+
return {
|
|
166
|
+
deleted: 0,
|
|
167
|
+
kept: 0,
|
|
168
|
+
errors: []
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Determine which runs to delete
|
|
173
|
+
const toDelete = selectRunsForDeletion(allRuns, opts);
|
|
174
|
+
const deletePaths = new Set(toDelete.map(r => r.dirPath));
|
|
175
|
+
|
|
176
|
+
const errors = [];
|
|
177
|
+
|
|
178
|
+
// Delete run directories
|
|
179
|
+
for (const run of toDelete) {
|
|
180
|
+
try {
|
|
181
|
+
fs.rmSync(run.dirPath, { recursive: true, force: true });
|
|
182
|
+
} catch (e) {
|
|
183
|
+
errors.push(`Failed to delete ${run.dirName}: ${e.message}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
deleted: toDelete.length,
|
|
189
|
+
kept: allRuns.length - toDelete.length,
|
|
190
|
+
errors,
|
|
191
|
+
deletedRuns: toDelete.map(r => ({
|
|
192
|
+
dirName: r.dirName,
|
|
193
|
+
site: r.meta.siteSlug,
|
|
194
|
+
result: r.meta.result,
|
|
195
|
+
timestamp: r.meta.timestamp
|
|
196
|
+
}))
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = {
|
|
201
|
+
cleanup,
|
|
202
|
+
parseDuration,
|
|
203
|
+
loadAllRuns,
|
|
204
|
+
groupBySite,
|
|
205
|
+
sortByTimestamp,
|
|
206
|
+
selectRunsForDeletion
|
|
207
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Latest Run Pointers
|
|
3
|
+
* Maintains LATEST.json and site-specific latest pointers for quick access
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create a pointer file pointing to the latest run
|
|
11
|
+
*
|
|
12
|
+
* @param {string} pointerPath - Path where pointer file should be saved
|
|
13
|
+
* @param {Object} meta - META.json content from the run
|
|
14
|
+
* @param {string} runDirName - Name of the run directory
|
|
15
|
+
*/
|
|
16
|
+
function writePointer(pointerPath, meta, runDirName) {
|
|
17
|
+
const pointer = {
|
|
18
|
+
version: 1,
|
|
19
|
+
timestamp: new Date().toISOString(),
|
|
20
|
+
pointedRun: runDirName,
|
|
21
|
+
pointedRunMeta: {
|
|
22
|
+
timestamp: meta.timestamp,
|
|
23
|
+
url: meta.url,
|
|
24
|
+
siteSlug: meta.siteSlug,
|
|
25
|
+
policy: meta.policy,
|
|
26
|
+
result: meta.result,
|
|
27
|
+
durationMs: meta.durationMs
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Create directory if needed
|
|
32
|
+
const dir = path.dirname(pointerPath);
|
|
33
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
34
|
+
|
|
35
|
+
// Atomic write
|
|
36
|
+
fs.writeFileSync(pointerPath, JSON.stringify(pointer, null, 2));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Update LATEST.json (global latest run)
|
|
41
|
+
*
|
|
42
|
+
* @param {string} runDir - Full path to the run directory
|
|
43
|
+
* @param {string} runDirName - Name of the run directory
|
|
44
|
+
* @param {Object} meta - META.json content
|
|
45
|
+
* @param {string} artifactsDir - Artifacts directory path (for pointer location)
|
|
46
|
+
*/
|
|
47
|
+
function updateLatestGlobal(runDir, runDirName, meta, artifactsDir = './artifacts') {
|
|
48
|
+
const pointerPath = path.join(artifactsDir, 'LATEST.json');
|
|
49
|
+
writePointer(pointerPath, meta, runDirName);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Update site-specific latest pointer
|
|
54
|
+
*
|
|
55
|
+
* @param {string} runDir - Full path to the run directory
|
|
56
|
+
* @param {string} runDirName - Name of the run directory
|
|
57
|
+
* @param {Object} meta - META.json content
|
|
58
|
+
* @param {string} artifactsDir - Artifacts directory path (for pointer location)
|
|
59
|
+
*/
|
|
60
|
+
function updateLatestBySite(runDir, runDirName, meta, artifactsDir = './artifacts') {
|
|
61
|
+
const siteSlug = meta.siteSlug || 'unknown';
|
|
62
|
+
const pointerPath = path.join(artifactsDir, 'latest', `${siteSlug}.json`);
|
|
63
|
+
writePointer(pointerPath, meta, runDirName);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Read a pointer file
|
|
68
|
+
*
|
|
69
|
+
* @param {string} pointerPath - Path to pointer file
|
|
70
|
+
* @returns {Object|null} pointer content or null if missing
|
|
71
|
+
*/
|
|
72
|
+
function readPointer(pointerPath) {
|
|
73
|
+
if (!fs.existsSync(pointerPath)) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const content = fs.readFileSync(pointerPath, 'utf8');
|
|
79
|
+
return JSON.parse(content);
|
|
80
|
+
} catch (e) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = {
|
|
86
|
+
updateLatestGlobal,
|
|
87
|
+
updateLatestBySite,
|
|
88
|
+
readPointer,
|
|
89
|
+
writePointer
|
|
90
|
+
};
|