mustflow 1.30.0 → 2.11.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/README.md +35 -11
- package/dist/cli/commands/classify.js +61 -6
- package/dist/cli/commands/contract-lint.js +13 -4
- package/dist/cli/commands/dashboard.js +6 -0
- package/dist/cli/commands/index.js +5 -0
- package/dist/cli/commands/run.js +224 -48
- package/dist/cli/commands/upgrade.js +65 -0
- package/dist/cli/commands/verify.js +550 -33
- package/dist/cli/i18n/en.js +73 -10
- package/dist/cli/i18n/es.js +73 -10
- package/dist/cli/i18n/fr.js +73 -10
- package/dist/cli/i18n/hi.js +73 -10
- package/dist/cli/i18n/ko.js +73 -10
- package/dist/cli/i18n/zh.js +73 -10
- package/dist/cli/index.js +27 -46
- package/dist/cli/lib/command-registry.js +5 -0
- package/dist/cli/lib/dashboard-export.js +62 -12
- package/dist/cli/lib/dashboard-html/client-script.js +1936 -0
- package/dist/cli/lib/dashboard-html/locale-bootstrap.js +8 -0
- package/dist/cli/lib/dashboard-html/styles.js +572 -0
- package/dist/cli/lib/dashboard-html/template.js +134 -0
- package/dist/cli/lib/dashboard-html/types.js +1 -0
- package/dist/cli/lib/dashboard-html.js +1 -1907
- package/dist/cli/lib/dashboard-locale.js +37 -0
- package/dist/cli/lib/local-index/constants.js +48 -0
- package/dist/cli/lib/local-index/index.js +2256 -0
- package/dist/cli/lib/local-index/sql.js +15 -0
- package/dist/cli/lib/local-index/types.js +1 -0
- package/dist/cli/lib/local-index.js +1 -1908
- package/dist/cli/lib/reporter.js +6 -0
- package/dist/cli/lib/run-plan.js +96 -4
- package/dist/cli/lib/templates.js +18 -1
- package/dist/cli/lib/validation/command-intents.js +11 -0
- package/dist/cli/lib/validation/constants.js +238 -0
- package/dist/cli/lib/validation/index.js +1384 -0
- package/dist/cli/lib/validation/primitives.js +198 -0
- package/dist/cli/lib/validation/test-selection.js +95 -0
- package/dist/cli/lib/validation/types.js +1 -0
- package/dist/cli/lib/validation.js +1 -1661
- package/dist/core/bounded-output.js +38 -0
- package/dist/core/change-classification.js +6 -2
- package/dist/core/change-verification.js +240 -6
- package/dist/core/check-issues.js +12 -0
- package/dist/core/command-contract-validation.js +20 -0
- package/dist/core/command-effects.js +13 -0
- package/dist/core/completion-verdict.js +209 -0
- package/dist/core/contract-lint.js +316 -7
- package/dist/core/dashboard-verification.js +8 -0
- package/dist/core/external-evidence.js +9 -0
- package/dist/core/public-json-contracts.js +28 -0
- package/dist/core/repeated-failure.js +17 -0
- package/dist/core/repro-evidence.js +53 -0
- package/dist/core/run-performance-history.js +307 -0
- package/dist/core/run-profile.js +87 -0
- package/dist/core/run-receipt.js +171 -4
- package/dist/core/run-write-drift.js +18 -2
- package/dist/core/scope-risk.js +64 -0
- package/dist/core/skill-route-alignment.js +110 -0
- package/dist/core/source-anchor-status.js +4 -1
- package/dist/core/test-selection.js +227 -0
- package/dist/core/validation-ratchet.js +52 -0
- package/dist/core/verification-decision-graph.js +67 -0
- package/dist/core/verification-evidence.js +249 -0
- package/dist/core/verification-scheduler.js +96 -2
- package/examples/README.md +12 -4
- package/package.json +1 -1
- package/schemas/README.md +18 -4
- package/schemas/change-verification-report.schema.json +169 -5
- package/schemas/commands.schema.json +51 -1
- package/schemas/contract-lint-report.schema.json +80 -0
- package/schemas/dashboard-export.schema.json +500 -0
- package/schemas/explain-report.schema.json +2 -0
- package/schemas/latest-run-pointer.schema.json +384 -0
- package/schemas/run-receipt.schema.json +113 -0
- package/schemas/test-selection.schema.json +81 -0
- package/schemas/verify-report.schema.json +361 -1
- package/schemas/verify-run-manifest.schema.json +410 -0
- package/templates/default/common/.mustflow/config/commands.toml +1 -1
- package/templates/default/i18n.toml +1 -1
- package/templates/default/locales/en/.mustflow/skills/INDEX.md +124 -29
- package/templates/default/locales/en/.mustflow/skills/routes.toml +289 -0
- package/templates/default/manifest.toml +29 -2
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const PERFORMANCE_HISTORY_SCHEMA_VERSION = '1';
|
|
4
|
+
const PERFORMANCE_HISTORY_DIR = path.join('.mustflow', 'state', 'perf');
|
|
5
|
+
const PERFORMANCE_SAMPLES_FILE = 'samples.json';
|
|
6
|
+
const PERFORMANCE_SUMMARY_FILE = 'summary.json';
|
|
7
|
+
const MAX_AGE_DAYS = 30;
|
|
8
|
+
const MAX_TOTAL_KB = 256;
|
|
9
|
+
const MAX_TOTAL_BYTES = MAX_TOTAL_KB * 1024;
|
|
10
|
+
const MAX_SAMPLES_TOTAL = 500;
|
|
11
|
+
const MAX_SAMPLES_PER_INTENT = 40;
|
|
12
|
+
const MAX_SAMPLES_PER_INTENT_FINGERPRINT = 20;
|
|
13
|
+
const MAX_FAILED_SAMPLES_PER_INTENT = 5;
|
|
14
|
+
const MAX_FINGERPRINTS_PER_INTENT = 3;
|
|
15
|
+
const EWMA_ALPHA = 0.3;
|
|
16
|
+
function getRetention() {
|
|
17
|
+
return {
|
|
18
|
+
max_age_days: MAX_AGE_DAYS,
|
|
19
|
+
max_total_kb: MAX_TOTAL_KB,
|
|
20
|
+
max_samples_total: MAX_SAMPLES_TOTAL,
|
|
21
|
+
max_samples_per_intent: MAX_SAMPLES_PER_INTENT,
|
|
22
|
+
max_samples_per_intent_fingerprint: MAX_SAMPLES_PER_INTENT_FINGERPRINT,
|
|
23
|
+
max_failed_samples_per_intent: MAX_FAILED_SAMPLES_PER_INTENT,
|
|
24
|
+
max_fingerprints_per_intent: MAX_FINGERPRINTS_PER_INTENT,
|
|
25
|
+
timestamp_granularity: 'day',
|
|
26
|
+
stores_output_tails: false,
|
|
27
|
+
stores_command_line: false,
|
|
28
|
+
stores_environment_values: false,
|
|
29
|
+
stores_absolute_paths: false,
|
|
30
|
+
stores_test_names: false,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function toObservedDay(value) {
|
|
34
|
+
return value.slice(0, 10);
|
|
35
|
+
}
|
|
36
|
+
function toDayIndex(day) {
|
|
37
|
+
const time = Date.parse(`${day}T00:00:00.000Z`);
|
|
38
|
+
return Math.floor(time / 86_400_000);
|
|
39
|
+
}
|
|
40
|
+
function getRunnerBucket(runner) {
|
|
41
|
+
return `${runner.kind}/${runner.platform_family}/${runner.arch_family}/${runner.runtime}@${runner.runtime_major}`;
|
|
42
|
+
}
|
|
43
|
+
function createSample(receipt) {
|
|
44
|
+
if (!receipt.performance.quality.usable_for_history) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
if (receipt.performance.result_summary.status !== 'passed' && receipt.performance.result_summary.status !== 'failed') {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
observed_day: toObservedDay(receipt.finished_at),
|
|
52
|
+
intent: receipt.intent,
|
|
53
|
+
intent_fingerprint: receipt.performance.intent_fingerprint,
|
|
54
|
+
command_fingerprint: receipt.performance.command_fingerprint,
|
|
55
|
+
contract_fingerprint: receipt.performance.contract_fingerprint,
|
|
56
|
+
runner_bucket: getRunnerBucket(receipt.performance.runner),
|
|
57
|
+
duration_ms: receipt.performance.duration_ms,
|
|
58
|
+
...(typeof receipt.performance.executor_overhead_ms === 'number'
|
|
59
|
+
? { executor_overhead_ms: receipt.performance.executor_overhead_ms }
|
|
60
|
+
: {}),
|
|
61
|
+
timeout_ratio: receipt.performance.timeout_ratio,
|
|
62
|
+
status: receipt.performance.result_summary.status,
|
|
63
|
+
exit_code_class: receipt.performance.result_summary.exit_code_class,
|
|
64
|
+
timed_out: false,
|
|
65
|
+
error_kind: receipt.performance.result_summary.error_kind,
|
|
66
|
+
stdout_bytes: receipt.performance.output_summary.stdout_bytes,
|
|
67
|
+
stderr_bytes: receipt.performance.output_summary.stderr_bytes,
|
|
68
|
+
...(receipt.performance.phases && receipt.performance.phases.length > 0
|
|
69
|
+
? { phase_durations_ms: toPhaseDurations(receipt.performance.phases) }
|
|
70
|
+
: {}),
|
|
71
|
+
...(receipt.performance.selection
|
|
72
|
+
? {
|
|
73
|
+
selection_strategy: receipt.performance.selection.strategy,
|
|
74
|
+
changed_file_count: receipt.performance.selection.changed_file_count,
|
|
75
|
+
changed_surface_counts: receipt.performance.selection.changed_surface_counts,
|
|
76
|
+
selected_target_count: receipt.performance.selection.selected_target_count,
|
|
77
|
+
fallback_used: receipt.performance.selection.fallback_used,
|
|
78
|
+
}
|
|
79
|
+
: {}),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function toPhaseDurations(phases) {
|
|
83
|
+
const durations = {};
|
|
84
|
+
for (const phase of phases) {
|
|
85
|
+
durations[phase.name] = phase.duration_ms;
|
|
86
|
+
}
|
|
87
|
+
return durations;
|
|
88
|
+
}
|
|
89
|
+
function readSamples(samplesPath) {
|
|
90
|
+
if (!existsSync(samplesPath)) {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const parsed = JSON.parse(readFileSync(samplesPath, 'utf8'));
|
|
95
|
+
return Array.isArray(parsed.samples) ? parsed.samples.filter(isRunPerformanceSample) : [];
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function isRunPerformanceSample(value) {
|
|
102
|
+
if (!value || typeof value !== 'object') {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
const sample = value;
|
|
106
|
+
return (typeof sample.observed_day === 'string' &&
|
|
107
|
+
typeof sample.intent === 'string' &&
|
|
108
|
+
typeof sample.intent_fingerprint === 'string' &&
|
|
109
|
+
typeof sample.command_fingerprint === 'string' &&
|
|
110
|
+
typeof sample.contract_fingerprint === 'string' &&
|
|
111
|
+
typeof sample.runner_bucket === 'string' &&
|
|
112
|
+
typeof sample.duration_ms === 'number' &&
|
|
113
|
+
typeof sample.timeout_ratio === 'number' &&
|
|
114
|
+
(sample.status === 'passed' || sample.status === 'failed') &&
|
|
115
|
+
typeof sample.stdout_bytes === 'number' &&
|
|
116
|
+
typeof sample.stderr_bytes === 'number' &&
|
|
117
|
+
(sample.selection_strategy === undefined || typeof sample.selection_strategy === 'string') &&
|
|
118
|
+
(sample.changed_file_count === undefined || isNonNegativeNumber(sample.changed_file_count)) &&
|
|
119
|
+
(sample.changed_surface_counts === undefined || isChangedSurfaceCounts(sample.changed_surface_counts)) &&
|
|
120
|
+
(sample.selected_target_count === undefined || isNonNegativeNumber(sample.selected_target_count)) &&
|
|
121
|
+
(sample.fallback_used === undefined || typeof sample.fallback_used === 'boolean'));
|
|
122
|
+
}
|
|
123
|
+
function isNonNegativeNumber(value) {
|
|
124
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
|
125
|
+
}
|
|
126
|
+
function isChangedSurfaceCounts(value) {
|
|
127
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
return Object.entries(value).every(([surface, count]) => /^[a-z][a-z0-9_]*$/.test(surface) && isNonNegativeNumber(count));
|
|
131
|
+
}
|
|
132
|
+
function keepMostRecentByLimit(items, limit, key) {
|
|
133
|
+
const counts = new Map();
|
|
134
|
+
const kept = [];
|
|
135
|
+
for (let index = items.length - 1; index >= 0; index -= 1) {
|
|
136
|
+
const item = items[index];
|
|
137
|
+
const itemKey = key(item);
|
|
138
|
+
const count = counts.get(itemKey) ?? 0;
|
|
139
|
+
if (count < limit) {
|
|
140
|
+
kept.push(item);
|
|
141
|
+
counts.set(itemKey, count + 1);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return kept.reverse();
|
|
145
|
+
}
|
|
146
|
+
function keepMostRecentFailuresByIntent(samples) {
|
|
147
|
+
const failures = new Map();
|
|
148
|
+
const keepIndexes = new Set();
|
|
149
|
+
for (let index = samples.length - 1; index >= 0; index -= 1) {
|
|
150
|
+
const sample = samples[index];
|
|
151
|
+
if (sample.status === 'passed') {
|
|
152
|
+
keepIndexes.add(index);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const count = failures.get(sample.intent) ?? 0;
|
|
156
|
+
if (count < MAX_FAILED_SAMPLES_PER_INTENT) {
|
|
157
|
+
keepIndexes.add(index);
|
|
158
|
+
failures.set(sample.intent, count + 1);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return samples.filter((_, index) => keepIndexes.has(index));
|
|
162
|
+
}
|
|
163
|
+
function keepRecentFingerprintsByIntent(samples) {
|
|
164
|
+
const latestByIntent = new Map();
|
|
165
|
+
for (const sample of samples) {
|
|
166
|
+
const byFingerprint = latestByIntent.get(sample.intent) ?? new Map();
|
|
167
|
+
byFingerprint.set(sample.intent_fingerprint, Math.max(byFingerprint.get(sample.intent_fingerprint) ?? 0, toDayIndex(sample.observed_day)));
|
|
168
|
+
latestByIntent.set(sample.intent, byFingerprint);
|
|
169
|
+
}
|
|
170
|
+
const allowed = new Map();
|
|
171
|
+
for (const [intent, fingerprints] of latestByIntent.entries()) {
|
|
172
|
+
const allowedFingerprints = [...fingerprints.entries()]
|
|
173
|
+
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
|
|
174
|
+
.slice(0, MAX_FINGERPRINTS_PER_INTENT)
|
|
175
|
+
.map(([fingerprint]) => fingerprint);
|
|
176
|
+
allowed.set(intent, new Set(allowedFingerprints));
|
|
177
|
+
}
|
|
178
|
+
return samples.filter((sample) => allowed.get(sample.intent)?.has(sample.intent_fingerprint) ?? false);
|
|
179
|
+
}
|
|
180
|
+
function pruneSamples(samples, today) {
|
|
181
|
+
const todayIndex = toDayIndex(today);
|
|
182
|
+
let pruned = samples.filter((sample) => todayIndex - toDayIndex(sample.observed_day) < MAX_AGE_DAYS);
|
|
183
|
+
pruned = keepRecentFingerprintsByIntent(pruned);
|
|
184
|
+
pruned = keepMostRecentByLimit(pruned, MAX_SAMPLES_PER_INTENT_FINGERPRINT, (sample) => `${sample.intent}\0${sample.intent_fingerprint}`);
|
|
185
|
+
pruned = keepMostRecentByLimit(pruned, MAX_SAMPLES_PER_INTENT, (sample) => sample.intent);
|
|
186
|
+
pruned = keepMostRecentFailuresByIntent(pruned);
|
|
187
|
+
return pruned.slice(Math.max(0, pruned.length - MAX_SAMPLES_TOTAL));
|
|
188
|
+
}
|
|
189
|
+
function percentile(values, percentileValue) {
|
|
190
|
+
if (values.length === 0) {
|
|
191
|
+
return 0;
|
|
192
|
+
}
|
|
193
|
+
const sorted = [...values].sort((left, right) => left - right);
|
|
194
|
+
const index = Math.ceil((percentileValue / 100) * sorted.length) - 1;
|
|
195
|
+
return sorted[Math.max(0, Math.min(sorted.length - 1, index))] ?? 0;
|
|
196
|
+
}
|
|
197
|
+
function calculateEwma(values) {
|
|
198
|
+
if (values.length === 0) {
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
let current = values[0] ?? 0;
|
|
202
|
+
for (const value of values.slice(1)) {
|
|
203
|
+
current = EWMA_ALPHA * value + (1 - EWMA_ALPHA) * current;
|
|
204
|
+
}
|
|
205
|
+
return Math.round(current);
|
|
206
|
+
}
|
|
207
|
+
function summarizeRunnerBuckets(samples) {
|
|
208
|
+
const buckets = {};
|
|
209
|
+
const groups = groupBy(samples, (sample) => sample.runner_bucket);
|
|
210
|
+
for (const [bucket, bucketSamples] of groups.entries()) {
|
|
211
|
+
const durations = bucketSamples.map((sample) => sample.duration_ms);
|
|
212
|
+
buckets[bucket] = {
|
|
213
|
+
sample_count: bucketSamples.length,
|
|
214
|
+
p50_duration_ms: percentile(durations, 50),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return buckets;
|
|
218
|
+
}
|
|
219
|
+
function summarizeFingerprint(samples) {
|
|
220
|
+
const durations = samples.map((sample) => sample.duration_ms);
|
|
221
|
+
const successes = samples.filter((sample) => sample.status === 'passed');
|
|
222
|
+
const failures = samples.filter((sample) => sample.status !== 'passed');
|
|
223
|
+
const lastSuccess = successes.at(-1);
|
|
224
|
+
return {
|
|
225
|
+
sample_count: samples.length,
|
|
226
|
+
success_count: successes.length,
|
|
227
|
+
timeout_count: samples.filter((sample) => sample.timed_out).length,
|
|
228
|
+
failure_count: failures.length,
|
|
229
|
+
p50_duration_ms: percentile(durations, 50),
|
|
230
|
+
p75_duration_ms: percentile(durations, 75),
|
|
231
|
+
p95_duration_ms: percentile(durations, 95),
|
|
232
|
+
min_duration_ms: Math.min(...durations),
|
|
233
|
+
max_duration_ms: Math.max(...durations),
|
|
234
|
+
ewma_duration_ms: calculateEwma(durations),
|
|
235
|
+
last_success_duration_ms: lastSuccess?.duration_ms ?? null,
|
|
236
|
+
last_observed_day: samples.at(-1)?.observed_day ?? '',
|
|
237
|
+
runner_buckets: summarizeRunnerBuckets(samples),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
function createSummary(samples, generatedDay) {
|
|
241
|
+
const intents = {};
|
|
242
|
+
const byIntent = groupBy(samples, (sample) => sample.intent);
|
|
243
|
+
for (const [intent, intentSamples] of byIntent.entries()) {
|
|
244
|
+
const fingerprints = {};
|
|
245
|
+
const byFingerprint = groupBy(intentSamples, (sample) => sample.intent_fingerprint);
|
|
246
|
+
for (const [fingerprint, fingerprintSamples] of byFingerprint.entries()) {
|
|
247
|
+
fingerprints[fingerprint] = summarizeFingerprint(fingerprintSamples);
|
|
248
|
+
}
|
|
249
|
+
intents[intent] = { fingerprints };
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
schema_version: PERFORMANCE_HISTORY_SCHEMA_VERSION,
|
|
253
|
+
generated_day: generatedDay,
|
|
254
|
+
retention: getRetention(),
|
|
255
|
+
intents,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
function groupBy(items, keyFor) {
|
|
259
|
+
const groups = new Map();
|
|
260
|
+
for (const item of items) {
|
|
261
|
+
const key = keyFor(item);
|
|
262
|
+
const group = groups.get(key) ?? [];
|
|
263
|
+
group.push(item);
|
|
264
|
+
groups.set(key, group);
|
|
265
|
+
}
|
|
266
|
+
return groups;
|
|
267
|
+
}
|
|
268
|
+
function createSamplesFile(samples) {
|
|
269
|
+
return {
|
|
270
|
+
schema_version: PERFORMANCE_HISTORY_SCHEMA_VERSION,
|
|
271
|
+
retention: getRetention(),
|
|
272
|
+
samples,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
function serialize(value) {
|
|
276
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
277
|
+
}
|
|
278
|
+
function enforceSizeLimit(samples, today) {
|
|
279
|
+
let pruned = [...samples];
|
|
280
|
+
let summary = createSummary(pruned, today);
|
|
281
|
+
while (pruned.length > 0 &&
|
|
282
|
+
Buffer.byteLength(serialize(createSamplesFile(pruned)), 'utf8') + Buffer.byteLength(serialize(summary), 'utf8') > MAX_TOTAL_BYTES) {
|
|
283
|
+
pruned = pruned.slice(1);
|
|
284
|
+
summary = createSummary(pruned, today);
|
|
285
|
+
}
|
|
286
|
+
return pruned;
|
|
287
|
+
}
|
|
288
|
+
export function recordRunPerformanceHistory(projectRoot, receipt) {
|
|
289
|
+
const sample = createSample(receipt);
|
|
290
|
+
if (!sample) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
const historyDir = path.join(projectRoot, PERFORMANCE_HISTORY_DIR);
|
|
295
|
+
const samplesPath = path.join(historyDir, PERFORMANCE_SAMPLES_FILE);
|
|
296
|
+
const summaryPath = path.join(historyDir, PERFORMANCE_SUMMARY_FILE);
|
|
297
|
+
const samples = enforceSizeLimit(pruneSamples([...readSamples(samplesPath), sample], sample.observed_day), sample.observed_day);
|
|
298
|
+
const samplesFile = createSamplesFile(samples);
|
|
299
|
+
const summaryFile = createSummary(samples, sample.observed_day);
|
|
300
|
+
mkdirSync(historyDir, { recursive: true });
|
|
301
|
+
writeFileSync(samplesPath, serialize(samplesFile));
|
|
302
|
+
writeFileSync(summaryPath, serialize(summaryFile));
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
// Performance history is a local optimization hint. A write failure must not affect command execution.
|
|
306
|
+
}
|
|
307
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { performance } from 'node:perf_hooks';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const RUN_PROFILE_SCHEMA_VERSION = '1';
|
|
5
|
+
const RUN_PROFILE_ENV = 'MUSTFLOW_RUN_PROFILE';
|
|
6
|
+
const RUN_PROFILE_DIR = path.join('.mustflow', 'state', 'runs');
|
|
7
|
+
const LATEST_RUN_PROFILE = 'latest.profile.json';
|
|
8
|
+
function isRunProfileEnabled() {
|
|
9
|
+
const value = process.env[RUN_PROFILE_ENV];
|
|
10
|
+
return value === '1' || value?.toLowerCase() === 'true';
|
|
11
|
+
}
|
|
12
|
+
function roundDurationMs(durationMs) {
|
|
13
|
+
return Math.max(0, Math.round(durationMs * 1000) / 1000);
|
|
14
|
+
}
|
|
15
|
+
function getProfileRelativePath() {
|
|
16
|
+
return path.join(RUN_PROFILE_DIR, LATEST_RUN_PROFILE).split(path.sep).join('/');
|
|
17
|
+
}
|
|
18
|
+
export class RunProfiler {
|
|
19
|
+
enabled;
|
|
20
|
+
startedAt;
|
|
21
|
+
startedAtMs;
|
|
22
|
+
phases = [];
|
|
23
|
+
constructor(enabled = isRunProfileEnabled()) {
|
|
24
|
+
this.enabled = enabled;
|
|
25
|
+
this.startedAt = new Date();
|
|
26
|
+
this.startedAtMs = performance.now();
|
|
27
|
+
}
|
|
28
|
+
measure(name, callback) {
|
|
29
|
+
if (!this.enabled) {
|
|
30
|
+
return callback();
|
|
31
|
+
}
|
|
32
|
+
const startedAtMs = performance.now();
|
|
33
|
+
try {
|
|
34
|
+
return callback();
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
this.recordPhase(name, startedAtMs);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async measureAsync(name, callback) {
|
|
41
|
+
if (!this.enabled) {
|
|
42
|
+
return callback();
|
|
43
|
+
}
|
|
44
|
+
const startedAtMs = performance.now();
|
|
45
|
+
try {
|
|
46
|
+
return await callback();
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
this.recordPhase(name, startedAtMs);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
getReceiptPhases() {
|
|
53
|
+
if (!this.enabled) {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
return [...this.phases];
|
|
57
|
+
}
|
|
58
|
+
writeLatest(input) {
|
|
59
|
+
if (!this.enabled) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const finishedAt = new Date();
|
|
63
|
+
const profile = {
|
|
64
|
+
schema_version: RUN_PROFILE_SCHEMA_VERSION,
|
|
65
|
+
command: 'run',
|
|
66
|
+
profile: true,
|
|
67
|
+
profile_window: 'run_command_handler',
|
|
68
|
+
intent: input.intent,
|
|
69
|
+
status: input.status,
|
|
70
|
+
preview_mode: input.previewMode,
|
|
71
|
+
started_at: this.startedAt.toISOString(),
|
|
72
|
+
finished_at: finishedAt.toISOString(),
|
|
73
|
+
duration_ms: roundDurationMs(performance.now() - this.startedAtMs),
|
|
74
|
+
phases: [...this.phases],
|
|
75
|
+
profile_path: getProfileRelativePath(),
|
|
76
|
+
};
|
|
77
|
+
const profilePath = path.join(input.projectRoot, RUN_PROFILE_DIR, LATEST_RUN_PROFILE);
|
|
78
|
+
mkdirSync(path.dirname(profilePath), { recursive: true });
|
|
79
|
+
writeFileSync(profilePath, `${JSON.stringify(profile, null, 2)}\n`);
|
|
80
|
+
}
|
|
81
|
+
recordPhase(name, startedAtMs) {
|
|
82
|
+
this.phases.push({
|
|
83
|
+
name,
|
|
84
|
+
duration_ms: roundDurationMs(performance.now() - startedAtMs),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
package/dist/core/run-receipt.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { DEFAULT_RUN_RECEIPT_TAIL_BYTES } from './retention-policy.js';
|
|
4
5
|
import { redactSecretLikeText } from './secret-redaction.js';
|
|
@@ -44,15 +45,17 @@ function summarizeOutput(output, maxOutputBytes, tailBytes, field, state) {
|
|
|
44
45
|
redaction_kinds: [],
|
|
45
46
|
};
|
|
46
47
|
}
|
|
47
|
-
const text = output.toString();
|
|
48
|
-
const bytes = Buffer.byteLength(text, 'utf8');
|
|
49
48
|
const tailLimit = Math.min(tailBytes, maxOutputBytes);
|
|
49
|
+
const text = typeof output === 'object' && 'tail' in output && 'bytes' in output ? output.tail : output.toString();
|
|
50
|
+
const bytes = typeof output === 'object' && 'tail' in output && 'bytes' in output
|
|
51
|
+
? output.bytes
|
|
52
|
+
: Buffer.byteLength(text, 'utf8');
|
|
50
53
|
const tail = truncateTextByBytes(text, tailLimit);
|
|
51
54
|
const redaction = redactSecretLikeText(tail.text);
|
|
52
55
|
recordRedaction(state, `${field}.tail`, redaction);
|
|
53
56
|
return {
|
|
54
57
|
bytes,
|
|
55
|
-
truncated: tail.truncated,
|
|
58
|
+
truncated: tail.truncated || bytes > Buffer.byteLength(tail.text, 'utf8'),
|
|
56
59
|
tail: redaction.text,
|
|
57
60
|
redacted: redaction.redacted,
|
|
58
61
|
redaction_count: redaction.redactionCount,
|
|
@@ -62,6 +65,146 @@ function summarizeOutput(output, maxOutputBytes, tailBytes, field, state) {
|
|
|
62
65
|
function getReceiptRelativePath() {
|
|
63
66
|
return toPosixPath(path.join(RUN_RECEIPT_DIR, LATEST_RUN_RECEIPT));
|
|
64
67
|
}
|
|
68
|
+
function stableJson(value) {
|
|
69
|
+
if (Array.isArray(value)) {
|
|
70
|
+
return `[${value.map((entry) => stableJson(entry)).join(',')}]`;
|
|
71
|
+
}
|
|
72
|
+
if (value && typeof value === 'object') {
|
|
73
|
+
const entries = Object.entries(value).sort(([left], [right]) => left.localeCompare(right));
|
|
74
|
+
return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`).join(',')}}`;
|
|
75
|
+
}
|
|
76
|
+
return JSON.stringify(value);
|
|
77
|
+
}
|
|
78
|
+
function fingerprint(value) {
|
|
79
|
+
return `sha256:${createHash('sha256').update(stableJson(value)).digest('hex')}`;
|
|
80
|
+
}
|
|
81
|
+
function getRuntimeMajor() {
|
|
82
|
+
const version = getBunRuntimeVersion() ?? process.versions.node;
|
|
83
|
+
const major = Number.parseInt(version.split('.')[0] ?? '', 10);
|
|
84
|
+
return Number.isFinite(major) ? major : 0;
|
|
85
|
+
}
|
|
86
|
+
function getBunRuntimeVersion() {
|
|
87
|
+
const versions = process.versions;
|
|
88
|
+
return versions.bun;
|
|
89
|
+
}
|
|
90
|
+
function getExitCodeClass(status, exitCode) {
|
|
91
|
+
if (exitCode === null) {
|
|
92
|
+
return 'no_exit_code';
|
|
93
|
+
}
|
|
94
|
+
return status === 'passed' ? 'success' : 'failure';
|
|
95
|
+
}
|
|
96
|
+
function getErrorKind(status, exitCode) {
|
|
97
|
+
if (status === 'timed_out') {
|
|
98
|
+
return 'timeout';
|
|
99
|
+
}
|
|
100
|
+
if (status === 'start_failed') {
|
|
101
|
+
return 'start_failed';
|
|
102
|
+
}
|
|
103
|
+
if (status === 'failed' && exitCode !== null) {
|
|
104
|
+
return 'exit_code';
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
function createPerformanceSummary(input) {
|
|
109
|
+
const contractIdentity = {
|
|
110
|
+
cwd: input.cwd,
|
|
111
|
+
env_allowlist: input.envAllowlist,
|
|
112
|
+
env_policy: input.envPolicy,
|
|
113
|
+
lifecycle: input.lifecycle,
|
|
114
|
+
max_output_bytes: input.maxOutputBytes,
|
|
115
|
+
mode: input.mode,
|
|
116
|
+
run_policy: input.runPolicy,
|
|
117
|
+
success_exit_codes: input.successExitCodes,
|
|
118
|
+
timeout_seconds: input.timeoutSeconds,
|
|
119
|
+
};
|
|
120
|
+
const commandIdentity = {
|
|
121
|
+
argv: input.argv ?? null,
|
|
122
|
+
cmd: input.cmd ?? null,
|
|
123
|
+
mode: input.mode,
|
|
124
|
+
};
|
|
125
|
+
const timeoutBudgetMs = input.timeoutSeconds * 1000;
|
|
126
|
+
const timeoutRatio = timeoutBudgetMs > 0 ? input.durationMs / timeoutBudgetMs : 0;
|
|
127
|
+
const phaseTimings = sanitizePhaseTimings(input.phaseTimings ?? []);
|
|
128
|
+
const selection = sanitizeSelectionSummary(input.selectionSummary);
|
|
129
|
+
return {
|
|
130
|
+
schema_version: '1',
|
|
131
|
+
measurement: 'wall_clock',
|
|
132
|
+
duration_ms: input.durationMs,
|
|
133
|
+
...(typeof input.executorOverheadMs === 'number' ? { executor_overhead_ms: input.executorOverheadMs } : {}),
|
|
134
|
+
...(phaseTimings.length > 0 ? { phases: phaseTimings } : {}),
|
|
135
|
+
...(selection ? { selection } : {}),
|
|
136
|
+
timeout_ratio: Number(timeoutRatio.toFixed(6)),
|
|
137
|
+
command_fingerprint: fingerprint(commandIdentity),
|
|
138
|
+
intent_fingerprint: fingerprint({ intent: input.intent, ...contractIdentity }),
|
|
139
|
+
contract_fingerprint: fingerprint(contractIdentity),
|
|
140
|
+
runner: {
|
|
141
|
+
kind: 'local',
|
|
142
|
+
platform_family: process.platform,
|
|
143
|
+
arch_family: process.arch,
|
|
144
|
+
runtime: getBunRuntimeVersion() ? 'bun' : 'node',
|
|
145
|
+
runtime_major: getRuntimeMajor(),
|
|
146
|
+
},
|
|
147
|
+
output_summary: {
|
|
148
|
+
stdout_bytes: input.stdout.bytes,
|
|
149
|
+
stderr_bytes: input.stderr.bytes,
|
|
150
|
+
stdout_truncated: input.stdout.truncated,
|
|
151
|
+
stderr_truncated: input.stderr.truncated,
|
|
152
|
+
},
|
|
153
|
+
result_summary: {
|
|
154
|
+
status: input.status,
|
|
155
|
+
exit_code_class: getExitCodeClass(input.status, input.exitCode),
|
|
156
|
+
timed_out: input.timedOut,
|
|
157
|
+
error_kind: getErrorKind(input.status, input.exitCode),
|
|
158
|
+
},
|
|
159
|
+
quality: {
|
|
160
|
+
phase_timings_source: phaseTimings.length > 0 ? 'structured_report' : 'none',
|
|
161
|
+
target_timings_source: 'none',
|
|
162
|
+
usable_for_history: input.status === 'passed' || input.status === 'failed',
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
function isSafePerformanceKey(value) {
|
|
167
|
+
return /^[a-z][a-z0-9_]*$/.test(value);
|
|
168
|
+
}
|
|
169
|
+
function toNonNegativeInteger(value) {
|
|
170
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
return Math.trunc(value);
|
|
174
|
+
}
|
|
175
|
+
function sanitizeSelectionSummary(selection) {
|
|
176
|
+
if (!selection || !isSafePerformanceKey(selection.strategy) || typeof selection.fallback_used !== 'boolean') {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
const changedFileCount = toNonNegativeInteger(selection.changed_file_count);
|
|
180
|
+
const selectedTargetCount = toNonNegativeInteger(selection.selected_target_count);
|
|
181
|
+
if (changedFileCount === null || selectedTargetCount === null) {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
const changedSurfaceCounts = {};
|
|
185
|
+
for (const [surface, count] of Object.entries(selection.changed_surface_counts).sort(([left], [right]) => left.localeCompare(right))) {
|
|
186
|
+
const sanitizedCount = toNonNegativeInteger(count);
|
|
187
|
+
if (!isSafePerformanceKey(surface) || sanitizedCount === null) {
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
changedSurfaceCounts[surface] = sanitizedCount;
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
strategy: selection.strategy,
|
|
194
|
+
changed_file_count: changedFileCount,
|
|
195
|
+
changed_surface_counts: changedSurfaceCounts,
|
|
196
|
+
selected_target_count: selectedTargetCount,
|
|
197
|
+
fallback_used: selection.fallback_used,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function sanitizePhaseTimings(phases) {
|
|
201
|
+
return phases
|
|
202
|
+
.filter((phase) => isSafePerformanceKey(phase.name) && Number.isFinite(phase.duration_ms) && phase.duration_ms >= 0)
|
|
203
|
+
.map((phase) => ({
|
|
204
|
+
name: phase.name,
|
|
205
|
+
duration_ms: Math.round(phase.duration_ms * 1000) / 1000,
|
|
206
|
+
}));
|
|
207
|
+
}
|
|
65
208
|
export function createRunReceipt(input) {
|
|
66
209
|
const relativeCwd = path.relative(input.projectRoot, input.cwd);
|
|
67
210
|
const stdoutTailBytes = input.stdoutTailBytes ?? DEFAULT_RUN_RECEIPT_TAIL_BYTES;
|
|
@@ -72,6 +215,7 @@ export function createRunReceipt(input) {
|
|
|
72
215
|
const error = input.error ? redactReceiptString(input.error, 'error', redactionState) : null;
|
|
73
216
|
const stdout = summarizeOutput(input.stdout, input.maxOutputBytes, stdoutTailBytes, 'stdout', redactionState);
|
|
74
217
|
const stderr = summarizeOutput(input.stderr, input.maxOutputBytes, stderrTailBytes, 'stderr', redactionState);
|
|
218
|
+
const durationMs = input.finishedAt.getTime() - input.startedAt.getTime();
|
|
75
219
|
return {
|
|
76
220
|
schema_version: RUN_RECEIPT_SCHEMA_VERSION,
|
|
77
221
|
command: 'run',
|
|
@@ -80,7 +224,7 @@ export function createRunReceipt(input) {
|
|
|
80
224
|
timed_out: input.timedOut,
|
|
81
225
|
started_at: input.startedAt.toISOString(),
|
|
82
226
|
finished_at: input.finishedAt.toISOString(),
|
|
83
|
-
duration_ms:
|
|
227
|
+
duration_ms: durationMs,
|
|
84
228
|
cwd: relativeCwd.length > 0 ? toPosixPath(relativeCwd) : '.',
|
|
85
229
|
lifecycle: input.lifecycle,
|
|
86
230
|
run_policy: input.runPolicy,
|
|
@@ -99,6 +243,29 @@ export function createRunReceipt(input) {
|
|
|
99
243
|
stdout,
|
|
100
244
|
stderr,
|
|
101
245
|
write_drift: input.writeDrift,
|
|
246
|
+
performance: createPerformanceSummary({
|
|
247
|
+
intent: input.intent,
|
|
248
|
+
status: input.status,
|
|
249
|
+
timedOut: input.timedOut,
|
|
250
|
+
durationMs,
|
|
251
|
+
executorOverheadMs: input.executorOverheadMs,
|
|
252
|
+
mode: input.mode,
|
|
253
|
+
argv,
|
|
254
|
+
cmd,
|
|
255
|
+
cwd: relativeCwd.length > 0 ? toPosixPath(relativeCwd) : '.',
|
|
256
|
+
lifecycle: input.lifecycle,
|
|
257
|
+
runPolicy: input.runPolicy,
|
|
258
|
+
envPolicy: input.envPolicy,
|
|
259
|
+
envAllowlist: input.envAllowlist,
|
|
260
|
+
timeoutSeconds: input.timeoutSeconds,
|
|
261
|
+
maxOutputBytes: input.maxOutputBytes,
|
|
262
|
+
successExitCodes: input.successExitCodes,
|
|
263
|
+
exitCode: input.exitCode,
|
|
264
|
+
stdout,
|
|
265
|
+
stderr,
|
|
266
|
+
phaseTimings: input.phaseTimings,
|
|
267
|
+
selectionSummary: input.selectionSummary,
|
|
268
|
+
}),
|
|
102
269
|
redaction: {
|
|
103
270
|
redacted: redactionState.count > 0,
|
|
104
271
|
redaction_count: redactionState.count,
|
|
@@ -4,8 +4,13 @@ import path from 'node:path';
|
|
|
4
4
|
import { normalizeCommandEffects } from './command-effects.js';
|
|
5
5
|
const MAX_SNAPSHOT_FILES = 20_000;
|
|
6
6
|
const MAX_REPORTED_PATHS = 200;
|
|
7
|
+
const RECURSIVE_SNAPSHOT_ENV = 'MUSTFLOW_WRITE_DRIFT_SNAPSHOT';
|
|
7
8
|
const EXCLUDED_DIRECTORY_NAMES = new Set(['.git', 'node_modules']);
|
|
8
9
|
const EXCLUDED_RELATIVE_DIRECTORY_PATHS = new Set(['.mustflow/state/runs']);
|
|
10
|
+
function isRecursiveSnapshotEnabled() {
|
|
11
|
+
const value = process.env[RECURSIVE_SNAPSHOT_ENV];
|
|
12
|
+
return value === '1' || value?.toLowerCase() === 'true';
|
|
13
|
+
}
|
|
9
14
|
function toPosixPath(value) {
|
|
10
15
|
return value.split(path.sep).join('/');
|
|
11
16
|
}
|
|
@@ -52,16 +57,25 @@ function captureSnapshot(projectRoot) {
|
|
|
52
57
|
if (gitSnapshot) {
|
|
53
58
|
return gitSnapshot;
|
|
54
59
|
}
|
|
60
|
+
if (!isRecursiveSnapshotEnabled()) {
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
entries: new Map(),
|
|
64
|
+
reason: 'git_status_unavailable_recursive_snapshot_disabled',
|
|
65
|
+
source: 'unavailable',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
55
68
|
try {
|
|
56
69
|
const entries = new Map();
|
|
57
70
|
collectSnapshotEntries(projectRoot, projectRoot, entries);
|
|
58
|
-
return { ok: true, entries, reason: null };
|
|
71
|
+
return { ok: true, entries, reason: null, source: 'recursive_snapshot' };
|
|
59
72
|
}
|
|
60
73
|
catch (error) {
|
|
61
74
|
return {
|
|
62
75
|
ok: false,
|
|
63
76
|
entries: new Map(),
|
|
64
77
|
reason: error instanceof Error && error.message.length > 0 ? error.message : 'snapshot_unavailable',
|
|
78
|
+
source: 'unavailable',
|
|
65
79
|
};
|
|
66
80
|
}
|
|
67
81
|
}
|
|
@@ -93,12 +107,14 @@ function captureGitStatusSnapshot(projectRoot) {
|
|
|
93
107
|
ok: true,
|
|
94
108
|
entries,
|
|
95
109
|
reason: null,
|
|
110
|
+
source: 'git_status',
|
|
96
111
|
};
|
|
97
112
|
}
|
|
98
113
|
function listDeclaredWritePaths(projectRoot, contract, intentName) {
|
|
99
114
|
const paths = normalizeCommandEffects(projectRoot, contract, intentName)
|
|
100
115
|
.filter((effect) => effect.access === 'write')
|
|
101
|
-
.map((effect) => effect.path)
|
|
116
|
+
.map((effect) => effect.path)
|
|
117
|
+
.filter((effectPath) => typeof effectPath === 'string');
|
|
102
118
|
return [...new Set(paths.map(normalizeRelativePath))].sort((left, right) => left.localeCompare(right));
|
|
103
119
|
}
|
|
104
120
|
function listObservedChangedPaths(before, after) {
|