@contractspec/lib.evolution 3.7.6 → 3.7.7
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 +44 -32
- package/dist/analyzer/spec-analyzer.d.ts +1 -1
- package/dist/generator/ai-spec-generator.d.ts +1 -1
- package/dist/generator/spec-generator.d.ts +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +827 -827
- package/dist/node/index.js +827 -827
- package/dist/types.d.ts +1 -1
- package/package.json +9 -9
package/dist/index.js
CHANGED
|
@@ -1,765 +1,762 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
// src/analyzer/
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
class SpecAnalyzer {
|
|
12
|
-
logger;
|
|
13
|
-
minSampleSize;
|
|
14
|
-
errorRateThreshold;
|
|
15
|
-
latencyP99ThresholdMs;
|
|
16
|
-
throughputDropThreshold;
|
|
17
|
-
constructor(options = {}) {
|
|
18
|
-
this.logger = options.logger;
|
|
19
|
-
this.minSampleSize = options.minSampleSize ?? DEFAULT_OPTIONS.minSampleSize;
|
|
20
|
-
this.errorRateThreshold = options.errorRateThreshold ?? DEFAULT_OPTIONS.errorRateThreshold;
|
|
21
|
-
this.latencyP99ThresholdMs = options.latencyP99ThresholdMs ?? DEFAULT_OPTIONS.latencyP99ThresholdMs;
|
|
22
|
-
this.throughputDropThreshold = options.throughputDropThreshold ?? 0.2;
|
|
2
|
+
// src/analyzer/posthog-telemetry-reader.ts
|
|
3
|
+
class PosthogTelemetryReader {
|
|
4
|
+
reader;
|
|
5
|
+
eventPrefix;
|
|
6
|
+
constructor(reader, options = {}) {
|
|
7
|
+
this.reader = reader;
|
|
8
|
+
this.eventPrefix = options.eventPrefix ?? "observability";
|
|
23
9
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
sampleSize: samplesForOp.length,
|
|
45
|
-
minSampleSize: this.minSampleSize
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
return valid;
|
|
10
|
+
async readOperationSamples(input) {
|
|
11
|
+
const result = await this.queryHogQL({
|
|
12
|
+
query: [
|
|
13
|
+
"select",
|
|
14
|
+
" properties.operation as operationKey,",
|
|
15
|
+
" properties.version as version,",
|
|
16
|
+
" properties.durationMs as durationMs,",
|
|
17
|
+
" properties.success as success,",
|
|
18
|
+
" properties.errorCode as errorCode,",
|
|
19
|
+
" properties.tenantId as tenantId,",
|
|
20
|
+
" properties.traceId as traceId,",
|
|
21
|
+
" properties.metadata as metadata,",
|
|
22
|
+
" timestamp as timestamp",
|
|
23
|
+
"from events",
|
|
24
|
+
`where ${buildOperationWhereClause(this.eventPrefix, input)}`,
|
|
25
|
+
"order by timestamp desc",
|
|
26
|
+
`limit ${input.limit ?? 1000}`
|
|
27
|
+
].join(`
|
|
28
|
+
`),
|
|
29
|
+
values: buildOperationValues(input)
|
|
49
30
|
});
|
|
50
|
-
return
|
|
31
|
+
return mapOperationSamples(result);
|
|
51
32
|
}
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
description: `P99 latency ${stat.p99LatencyMs}ms exceeded threshold ${this.latencyP99ThresholdMs}ms`,
|
|
85
|
-
data: { p99LatencyMs: stat.p99LatencyMs }
|
|
86
|
-
});
|
|
87
|
-
anomalies.push({
|
|
88
|
-
operation: stat.operation,
|
|
89
|
-
severity: this.toSeverity(stat.p99LatencyMs / this.latencyP99ThresholdMs),
|
|
90
|
-
metric: "latency",
|
|
91
|
-
description: "Latency regression detected",
|
|
92
|
-
detectedAt: new Date,
|
|
93
|
-
threshold: this.latencyP99ThresholdMs,
|
|
94
|
-
observedValue: stat.p99LatencyMs,
|
|
95
|
-
evidence
|
|
96
|
-
});
|
|
97
|
-
continue;
|
|
98
|
-
}
|
|
99
|
-
const baselineStat = baselineByOp.get(this.operationKey(stat.operation));
|
|
100
|
-
if (baselineStat) {
|
|
101
|
-
const drop = (baselineStat.totalCalls - stat.totalCalls) / baselineStat.totalCalls;
|
|
102
|
-
if (drop >= this.throughputDropThreshold) {
|
|
103
|
-
evidence.push({
|
|
104
|
-
type: "telemetry",
|
|
105
|
-
description: `Throughput dropped by ${(drop * 100).toFixed(1)}% compared to baseline`,
|
|
106
|
-
data: {
|
|
107
|
-
baselineCalls: baselineStat.totalCalls,
|
|
108
|
-
currentCalls: stat.totalCalls
|
|
109
|
-
}
|
|
110
|
-
});
|
|
111
|
-
anomalies.push({
|
|
112
|
-
operation: stat.operation,
|
|
113
|
-
severity: this.toSeverity(drop / this.throughputDropThreshold),
|
|
114
|
-
metric: "throughput",
|
|
115
|
-
description: "Usage drop detected",
|
|
116
|
-
detectedAt: new Date,
|
|
117
|
-
threshold: this.throughputDropThreshold,
|
|
118
|
-
observedValue: drop,
|
|
119
|
-
evidence
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
return anomalies;
|
|
33
|
+
async readAnomalyBaseline(operation, windowDays = 7) {
|
|
34
|
+
const dateRange = buildWindowRange(windowDays);
|
|
35
|
+
const baseResult = await this.queryHogQL({
|
|
36
|
+
query: [
|
|
37
|
+
"select",
|
|
38
|
+
" count() as totalCalls,",
|
|
39
|
+
" avg(properties.durationMs) as averageLatencyMs,",
|
|
40
|
+
" quantile(0.95)(properties.durationMs) as p95LatencyMs,",
|
|
41
|
+
" quantile(0.99)(properties.durationMs) as p99LatencyMs,",
|
|
42
|
+
" max(properties.durationMs) as maxLatencyMs,",
|
|
43
|
+
" sum(if(properties.success = 1, 1, 0)) as successCount,",
|
|
44
|
+
" sum(if(properties.success = 0, 1, 0)) as errorCount",
|
|
45
|
+
"from events",
|
|
46
|
+
`where ${buildOperationWhereClause(this.eventPrefix, {
|
|
47
|
+
operations: [operation],
|
|
48
|
+
dateRange
|
|
49
|
+
})}`
|
|
50
|
+
].join(`
|
|
51
|
+
`),
|
|
52
|
+
values: buildOperationValues({
|
|
53
|
+
operations: [operation],
|
|
54
|
+
dateRange
|
|
55
|
+
})
|
|
56
|
+
});
|
|
57
|
+
const stats = mapBaselineStats(baseResult, operation, dateRange);
|
|
58
|
+
if (!stats)
|
|
59
|
+
return null;
|
|
60
|
+
const topErrors = await this.readTopErrors(operation, dateRange);
|
|
61
|
+
return {
|
|
62
|
+
...stats,
|
|
63
|
+
topErrors
|
|
64
|
+
};
|
|
125
65
|
}
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
};
|
|
66
|
+
async readTopErrors(operation, dateRange) {
|
|
67
|
+
const result = await this.queryHogQL({
|
|
68
|
+
query: [
|
|
69
|
+
"select",
|
|
70
|
+
" properties.errorCode as errorCode,",
|
|
71
|
+
" count() as errorCount",
|
|
72
|
+
"from events",
|
|
73
|
+
`where ${buildOperationWhereClause(this.eventPrefix, {
|
|
74
|
+
operations: [operation],
|
|
75
|
+
dateRange
|
|
76
|
+
})} and properties.success = 0`,
|
|
77
|
+
"group by errorCode",
|
|
78
|
+
"order by errorCount desc",
|
|
79
|
+
"limit 5"
|
|
80
|
+
].join(`
|
|
81
|
+
`),
|
|
82
|
+
values: buildOperationValues({
|
|
83
|
+
operations: [operation],
|
|
84
|
+
dateRange
|
|
85
|
+
})
|
|
147
86
|
});
|
|
87
|
+
const rows = mapRows(result);
|
|
88
|
+
return rows.reduce((acc, row) => {
|
|
89
|
+
const code = asString(row.errorCode);
|
|
90
|
+
if (!code)
|
|
91
|
+
return acc;
|
|
92
|
+
acc[code] = asNumber(row.errorCount);
|
|
93
|
+
return acc;
|
|
94
|
+
}, {});
|
|
148
95
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
for (const stat of stats) {
|
|
153
|
-
const opKey = this.operationKey(stat.operation);
|
|
154
|
-
const opAnomalies = anomaliesByOp.get(opKey) ?? [];
|
|
155
|
-
for (const anomaly of opAnomalies) {
|
|
156
|
-
if (anomaly.metric === "latency") {
|
|
157
|
-
hints.push(this.applyLifecycleContext({
|
|
158
|
-
operation: stat.operation,
|
|
159
|
-
category: "performance",
|
|
160
|
-
summary: "Latency regression detected",
|
|
161
|
-
justification: `P99 latency at ${stat.p99LatencyMs}ms`,
|
|
162
|
-
recommendedActions: [
|
|
163
|
-
"Add batching or caching layer",
|
|
164
|
-
"Replay golden tests to capture slow inputs"
|
|
165
|
-
]
|
|
166
|
-
}, lifecycleContext?.stage));
|
|
167
|
-
} else if (anomaly.metric === "error-rate") {
|
|
168
|
-
const topError = Object.entries(stat.topErrors).sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
169
|
-
hints.push(this.applyLifecycleContext({
|
|
170
|
-
operation: stat.operation,
|
|
171
|
-
category: "error-handling",
|
|
172
|
-
summary: "Error spike detected",
|
|
173
|
-
justification: topError ? `Dominant error code ${topError}` : "Increase in failures",
|
|
174
|
-
recommendedActions: [
|
|
175
|
-
"Generate regression spec from failing payloads",
|
|
176
|
-
"Add policy guardrails before rollout"
|
|
177
|
-
]
|
|
178
|
-
}, lifecycleContext?.stage));
|
|
179
|
-
} else if (anomaly.metric === "throughput") {
|
|
180
|
-
hints.push(this.applyLifecycleContext({
|
|
181
|
-
operation: stat.operation,
|
|
182
|
-
category: "performance",
|
|
183
|
-
summary: "Throughput drop detected",
|
|
184
|
-
justification: "Significant traffic reduction relative to baseline",
|
|
185
|
-
recommendedActions: [
|
|
186
|
-
"Validate routing + feature flag bucketing",
|
|
187
|
-
"Backfill spec variant to rehydrate demand"
|
|
188
|
-
]
|
|
189
|
-
}, lifecycleContext?.stage));
|
|
190
|
-
}
|
|
191
|
-
}
|
|
96
|
+
async queryHogQL(input) {
|
|
97
|
+
if (!this.reader.queryHogQL) {
|
|
98
|
+
throw new Error("Analytics reader does not support HogQL queries.");
|
|
192
99
|
}
|
|
193
|
-
return
|
|
100
|
+
return this.reader.queryHogQL(input);
|
|
194
101
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
102
|
+
}
|
|
103
|
+
function buildOperationWhereClause(eventPrefix, input) {
|
|
104
|
+
const clauses = [`event = '${eventPrefix}.operation'`];
|
|
105
|
+
if (input.operations?.length) {
|
|
106
|
+
clauses.push(`(${buildOperationFilters(input.operations)})`);
|
|
198
107
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const errors = samples.filter((s) => !s.success);
|
|
202
|
-
const totalCalls = samples.length;
|
|
203
|
-
const successRate = (totalCalls - errors.length) / totalCalls;
|
|
204
|
-
const errorRate = errors.length / totalCalls;
|
|
205
|
-
const averageLatencyMs = durations.reduce((sum, value) => sum + value, 0) / totalCalls;
|
|
206
|
-
const topErrors = errors.reduce((acc, sample) => {
|
|
207
|
-
if (!sample.errorCode)
|
|
208
|
-
return acc;
|
|
209
|
-
acc[sample.errorCode] = (acc[sample.errorCode] ?? 0) + 1;
|
|
210
|
-
return acc;
|
|
211
|
-
}, {});
|
|
212
|
-
const timestamps = samples.map((s) => s.timestamp.getTime());
|
|
213
|
-
const windowStart = new Date(Math.min(...timestamps));
|
|
214
|
-
const windowEnd = new Date(Math.max(...timestamps));
|
|
215
|
-
return {
|
|
216
|
-
operation: samples[0].operation,
|
|
217
|
-
totalCalls,
|
|
218
|
-
successRate,
|
|
219
|
-
errorRate,
|
|
220
|
-
averageLatencyMs,
|
|
221
|
-
p95LatencyMs: percentile(durations, 0.95),
|
|
222
|
-
p99LatencyMs: percentile(durations, 0.99),
|
|
223
|
-
maxLatencyMs: Math.max(...durations),
|
|
224
|
-
lastSeenAt: windowEnd,
|
|
225
|
-
windowStart,
|
|
226
|
-
windowEnd,
|
|
227
|
-
topErrors
|
|
228
|
-
};
|
|
108
|
+
if (input.dateRange?.from) {
|
|
109
|
+
clauses.push("timestamp >= {dateFrom}");
|
|
229
110
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
return "high";
|
|
233
|
-
if (ratio >= 1.3)
|
|
234
|
-
return "medium";
|
|
235
|
-
return "low";
|
|
111
|
+
if (input.dateRange?.to) {
|
|
112
|
+
clauses.push("timestamp < {dateTo}");
|
|
236
113
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
114
|
+
return clauses.join(" and ");
|
|
115
|
+
}
|
|
116
|
+
function buildOperationValues(input) {
|
|
117
|
+
const values = {
|
|
118
|
+
dateFrom: toIsoString(input.dateRange?.from),
|
|
119
|
+
dateTo: toIsoString(input.dateRange?.to)
|
|
120
|
+
};
|
|
121
|
+
input.operations?.forEach((op, index) => {
|
|
122
|
+
values[`operationKey${index}`] = op.key;
|
|
123
|
+
values[`operationVersion${index}`] = op.version;
|
|
124
|
+
if (op.tenantId) {
|
|
125
|
+
values[`operationTenant${index}`] = op.tenantId;
|
|
247
126
|
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
127
|
+
});
|
|
128
|
+
return values;
|
|
129
|
+
}
|
|
130
|
+
function buildOperationFilters(operations) {
|
|
131
|
+
return operations.map((op, index) => {
|
|
132
|
+
const clauses = [
|
|
133
|
+
`properties.operation = {operationKey${index}}`,
|
|
134
|
+
`properties.version = {operationVersion${index}}`
|
|
135
|
+
];
|
|
136
|
+
if (op.tenantId) {
|
|
137
|
+
clauses.push(`properties.tenantId = {operationTenant${index}}`);
|
|
256
138
|
}
|
|
257
|
-
return
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
139
|
+
return `(${clauses.join(" and ")})`;
|
|
140
|
+
}).join(" or ");
|
|
141
|
+
}
|
|
142
|
+
function mapOperationSamples(result) {
|
|
143
|
+
const rows = mapRows(result);
|
|
144
|
+
return rows.flatMap((row) => {
|
|
145
|
+
const operationKey = asString(row.operationKey);
|
|
146
|
+
const version = asString(row.version);
|
|
147
|
+
const timestamp = asDate(row.timestamp);
|
|
148
|
+
if (!operationKey || !version || !timestamp) {
|
|
149
|
+
return [];
|
|
266
150
|
}
|
|
267
|
-
return
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
151
|
+
return [
|
|
152
|
+
{
|
|
153
|
+
operation: {
|
|
154
|
+
key: operationKey,
|
|
155
|
+
version,
|
|
156
|
+
tenantId: asOptionalString(row.tenantId) ?? undefined
|
|
157
|
+
},
|
|
158
|
+
durationMs: asNumber(row.durationMs),
|
|
159
|
+
success: asBoolean(row.success),
|
|
160
|
+
timestamp,
|
|
161
|
+
errorCode: asOptionalString(row.errorCode) ?? undefined,
|
|
162
|
+
traceId: asOptionalString(row.traceId) ?? undefined,
|
|
163
|
+
metadata: isRecord(row.metadata) ? row.metadata : undefined
|
|
164
|
+
}
|
|
165
|
+
];
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
function mapBaselineStats(result, operation, dateRange) {
|
|
169
|
+
const rows = mapRows(result);
|
|
170
|
+
const row = rows[0];
|
|
171
|
+
if (!row)
|
|
172
|
+
return null;
|
|
173
|
+
const totalCalls = asNumber(row.totalCalls);
|
|
174
|
+
if (!totalCalls)
|
|
175
|
+
return null;
|
|
176
|
+
const successCount = asNumber(row.successCount);
|
|
177
|
+
const errorCount = asNumber(row.errorCount);
|
|
178
|
+
return {
|
|
179
|
+
operation,
|
|
180
|
+
totalCalls,
|
|
181
|
+
successRate: totalCalls ? successCount / totalCalls : 0,
|
|
182
|
+
errorRate: totalCalls ? errorCount / totalCalls : 0,
|
|
183
|
+
averageLatencyMs: asNumber(row.averageLatencyMs),
|
|
184
|
+
p95LatencyMs: asNumber(row.p95LatencyMs),
|
|
185
|
+
p99LatencyMs: asNumber(row.p99LatencyMs),
|
|
186
|
+
maxLatencyMs: asNumber(row.maxLatencyMs),
|
|
187
|
+
lastSeenAt: new Date,
|
|
188
|
+
windowStart: toDate(dateRange.from) ?? new Date,
|
|
189
|
+
windowEnd: toDate(dateRange.to) ?? new Date,
|
|
190
|
+
topErrors: {}
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
function mapRows(result) {
|
|
194
|
+
if (!Array.isArray(result.results) || !Array.isArray(result.columns)) {
|
|
195
|
+
return [];
|
|
276
196
|
}
|
|
197
|
+
const columns = result.columns;
|
|
198
|
+
return result.results.flatMap((row) => {
|
|
199
|
+
if (!Array.isArray(row))
|
|
200
|
+
return [];
|
|
201
|
+
const record = {};
|
|
202
|
+
columns.forEach((column, index) => {
|
|
203
|
+
record[column] = row[index];
|
|
204
|
+
});
|
|
205
|
+
return [record];
|
|
206
|
+
});
|
|
277
207
|
}
|
|
278
|
-
function
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
208
|
+
function buildWindowRange(windowDays) {
|
|
209
|
+
const windowEnd = new Date;
|
|
210
|
+
const windowStart = new Date(windowEnd.getTime() - windowDays * 24 * 60 * 60 * 1000);
|
|
211
|
+
return {
|
|
212
|
+
from: windowStart,
|
|
213
|
+
to: windowEnd
|
|
214
|
+
};
|
|
285
215
|
}
|
|
286
|
-
|
|
287
|
-
if (
|
|
288
|
-
return
|
|
289
|
-
if (
|
|
290
|
-
return
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
}
|
|
308
|
-
},
|
|
309
|
-
pmf: {
|
|
310
|
-
performance: {
|
|
311
|
-
message: "Stabilize the core use case to avoid regressions while demand grows.",
|
|
312
|
-
supplementalActions: ["Instrument regression tests on critical specs"]
|
|
313
|
-
}
|
|
314
|
-
},
|
|
315
|
-
scale: {
|
|
316
|
-
performance: {
|
|
317
|
-
message: "Prioritize resilience and multi-tenant safety as volumes expand.",
|
|
318
|
-
supplementalActions: [
|
|
319
|
-
"Introduce workload partitioning or isolation per tenant"
|
|
320
|
-
]
|
|
321
|
-
},
|
|
322
|
-
"error-handling": {
|
|
323
|
-
message: "Contain blast radius with policy fallbacks and circuit breakers.",
|
|
324
|
-
supplementalActions: ["Add circuit breakers to high-risk operations"]
|
|
325
|
-
}
|
|
326
|
-
},
|
|
327
|
-
mature: {
|
|
328
|
-
performance: {
|
|
329
|
-
message: "Optimize for margins and predictable SLAs.",
|
|
330
|
-
supplementalActions: [
|
|
331
|
-
"Capture unit-cost impacts alongside latency fixes"
|
|
332
|
-
]
|
|
333
|
-
},
|
|
334
|
-
"error-handling": {
|
|
335
|
-
message: "Prevent regressions with automated regression specs before deploy.",
|
|
336
|
-
supplementalActions: [
|
|
337
|
-
"Run auto-evolution simulations on renewal scenarios"
|
|
338
|
-
]
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
};
|
|
342
|
-
var dedupeActions = (actions) => {
|
|
343
|
-
const seen = new Set;
|
|
344
|
-
const ordered = [];
|
|
345
|
-
for (const action of actions) {
|
|
346
|
-
if (seen.has(action))
|
|
347
|
-
continue;
|
|
348
|
-
seen.add(action);
|
|
349
|
-
ordered.push(action);
|
|
350
|
-
}
|
|
351
|
-
return ordered;
|
|
352
|
-
};
|
|
353
|
-
// src/analyzer/posthog-telemetry-reader.ts
|
|
354
|
-
class PosthogTelemetryReader {
|
|
355
|
-
reader;
|
|
356
|
-
eventPrefix;
|
|
357
|
-
constructor(reader, options = {}) {
|
|
358
|
-
this.reader = reader;
|
|
359
|
-
this.eventPrefix = options.eventPrefix ?? "observability";
|
|
360
|
-
}
|
|
361
|
-
async readOperationSamples(input) {
|
|
362
|
-
const result = await this.queryHogQL({
|
|
363
|
-
query: [
|
|
364
|
-
"select",
|
|
365
|
-
" properties.operation as operationKey,",
|
|
366
|
-
" properties.version as version,",
|
|
367
|
-
" properties.durationMs as durationMs,",
|
|
368
|
-
" properties.success as success,",
|
|
369
|
-
" properties.errorCode as errorCode,",
|
|
370
|
-
" properties.tenantId as tenantId,",
|
|
371
|
-
" properties.traceId as traceId,",
|
|
372
|
-
" properties.metadata as metadata,",
|
|
373
|
-
" timestamp as timestamp",
|
|
374
|
-
"from events",
|
|
375
|
-
`where ${buildOperationWhereClause(this.eventPrefix, input)}`,
|
|
376
|
-
"order by timestamp desc",
|
|
377
|
-
`limit ${input.limit ?? 1000}`
|
|
378
|
-
].join(`
|
|
379
|
-
`),
|
|
380
|
-
values: buildOperationValues(input)
|
|
381
|
-
});
|
|
382
|
-
return mapOperationSamples(result);
|
|
383
|
-
}
|
|
384
|
-
async readAnomalyBaseline(operation, windowDays = 7) {
|
|
385
|
-
const dateRange = buildWindowRange(windowDays);
|
|
386
|
-
const baseResult = await this.queryHogQL({
|
|
387
|
-
query: [
|
|
388
|
-
"select",
|
|
389
|
-
" count() as totalCalls,",
|
|
390
|
-
" avg(properties.durationMs) as averageLatencyMs,",
|
|
391
|
-
" quantile(0.95)(properties.durationMs) as p95LatencyMs,",
|
|
392
|
-
" quantile(0.99)(properties.durationMs) as p99LatencyMs,",
|
|
393
|
-
" max(properties.durationMs) as maxLatencyMs,",
|
|
394
|
-
" sum(if(properties.success = 1, 1, 0)) as successCount,",
|
|
395
|
-
" sum(if(properties.success = 0, 1, 0)) as errorCount",
|
|
396
|
-
"from events",
|
|
397
|
-
`where ${buildOperationWhereClause(this.eventPrefix, {
|
|
398
|
-
operations: [operation],
|
|
399
|
-
dateRange
|
|
400
|
-
})}`
|
|
401
|
-
].join(`
|
|
402
|
-
`),
|
|
403
|
-
values: buildOperationValues({
|
|
404
|
-
operations: [operation],
|
|
405
|
-
dateRange
|
|
406
|
-
})
|
|
407
|
-
});
|
|
408
|
-
const stats = mapBaselineStats(baseResult, operation, dateRange);
|
|
409
|
-
if (!stats)
|
|
410
|
-
return null;
|
|
411
|
-
const topErrors = await this.readTopErrors(operation, dateRange);
|
|
412
|
-
return {
|
|
413
|
-
...stats,
|
|
414
|
-
topErrors
|
|
415
|
-
};
|
|
416
|
-
}
|
|
417
|
-
async readTopErrors(operation, dateRange) {
|
|
418
|
-
const result = await this.queryHogQL({
|
|
419
|
-
query: [
|
|
420
|
-
"select",
|
|
421
|
-
" properties.errorCode as errorCode,",
|
|
422
|
-
" count() as errorCount",
|
|
423
|
-
"from events",
|
|
424
|
-
`where ${buildOperationWhereClause(this.eventPrefix, {
|
|
425
|
-
operations: [operation],
|
|
426
|
-
dateRange
|
|
427
|
-
})} and properties.success = 0`,
|
|
428
|
-
"group by errorCode",
|
|
429
|
-
"order by errorCount desc",
|
|
430
|
-
"limit 5"
|
|
431
|
-
].join(`
|
|
432
|
-
`),
|
|
433
|
-
values: buildOperationValues({
|
|
434
|
-
operations: [operation],
|
|
435
|
-
dateRange
|
|
436
|
-
})
|
|
437
|
-
});
|
|
438
|
-
const rows = mapRows(result);
|
|
439
|
-
return rows.reduce((acc, row) => {
|
|
440
|
-
const code = asString(row.errorCode);
|
|
441
|
-
if (!code)
|
|
442
|
-
return acc;
|
|
443
|
-
acc[code] = asNumber(row.errorCount);
|
|
444
|
-
return acc;
|
|
445
|
-
}, {});
|
|
446
|
-
}
|
|
447
|
-
async queryHogQL(input) {
|
|
448
|
-
if (!this.reader.queryHogQL) {
|
|
449
|
-
throw new Error("Analytics reader does not support HogQL queries.");
|
|
450
|
-
}
|
|
451
|
-
return this.reader.queryHogQL(input);
|
|
216
|
+
function asString(value) {
|
|
217
|
+
if (typeof value === "string" && value.trim())
|
|
218
|
+
return value;
|
|
219
|
+
if (typeof value === "number")
|
|
220
|
+
return String(value);
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
function asOptionalString(value) {
|
|
224
|
+
if (typeof value === "string")
|
|
225
|
+
return value;
|
|
226
|
+
if (typeof value === "number")
|
|
227
|
+
return String(value);
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
function asNumber(value) {
|
|
231
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
232
|
+
return value;
|
|
233
|
+
if (typeof value === "string" && value.trim()) {
|
|
234
|
+
const parsed = Number(value);
|
|
235
|
+
if (Number.isFinite(parsed))
|
|
236
|
+
return parsed;
|
|
452
237
|
}
|
|
238
|
+
return 0;
|
|
453
239
|
}
|
|
454
|
-
function
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
if (
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
240
|
+
function asBoolean(value) {
|
|
241
|
+
if (typeof value === "boolean")
|
|
242
|
+
return value;
|
|
243
|
+
if (typeof value === "number")
|
|
244
|
+
return value !== 0;
|
|
245
|
+
if (typeof value === "string")
|
|
246
|
+
return value.toLowerCase() === "true";
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
function asDate(value) {
|
|
250
|
+
if (value instanceof Date)
|
|
251
|
+
return value;
|
|
252
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
253
|
+
const date = new Date(value);
|
|
254
|
+
if (!Number.isNaN(date.getTime()))
|
|
255
|
+
return date;
|
|
464
256
|
}
|
|
465
|
-
return
|
|
257
|
+
return null;
|
|
466
258
|
}
|
|
467
|
-
function
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
};
|
|
472
|
-
input.operations?.forEach((op, index) => {
|
|
473
|
-
values[`operationKey${index}`] = op.key;
|
|
474
|
-
values[`operationVersion${index}`] = op.version;
|
|
475
|
-
if (op.tenantId) {
|
|
476
|
-
values[`operationTenant${index}`] = op.tenantId;
|
|
477
|
-
}
|
|
478
|
-
});
|
|
479
|
-
return values;
|
|
259
|
+
function toIsoString(value) {
|
|
260
|
+
if (!value)
|
|
261
|
+
return;
|
|
262
|
+
return typeof value === "string" ? value : value.toISOString();
|
|
480
263
|
}
|
|
481
|
-
function
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
`properties.version = {operationVersion${index}}`
|
|
486
|
-
];
|
|
487
|
-
if (op.tenantId) {
|
|
488
|
-
clauses.push(`properties.tenantId = {operationTenant${index}}`);
|
|
489
|
-
}
|
|
490
|
-
return `(${clauses.join(" and ")})`;
|
|
491
|
-
}).join(" or ");
|
|
264
|
+
function toDate(value) {
|
|
265
|
+
if (!value)
|
|
266
|
+
return null;
|
|
267
|
+
return value instanceof Date ? value : new Date(value);
|
|
492
268
|
}
|
|
493
|
-
function
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
269
|
+
function isRecord(value) {
|
|
270
|
+
return typeof value === "object" && value !== null;
|
|
271
|
+
}
|
|
272
|
+
// src/analyzer/spec-analyzer.ts
|
|
273
|
+
import { randomUUID } from "crypto";
|
|
274
|
+
import { LifecycleStage } from "@contractspec/lib.lifecycle";
|
|
275
|
+
var DEFAULT_OPTIONS = {
|
|
276
|
+
minSampleSize: 50,
|
|
277
|
+
errorRateThreshold: 0.05,
|
|
278
|
+
latencyP99ThresholdMs: 750
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
class SpecAnalyzer {
|
|
282
|
+
logger;
|
|
283
|
+
minSampleSize;
|
|
284
|
+
errorRateThreshold;
|
|
285
|
+
latencyP99ThresholdMs;
|
|
286
|
+
throughputDropThreshold;
|
|
287
|
+
constructor(options = {}) {
|
|
288
|
+
this.logger = options.logger;
|
|
289
|
+
this.minSampleSize = options.minSampleSize ?? DEFAULT_OPTIONS.minSampleSize;
|
|
290
|
+
this.errorRateThreshold = options.errorRateThreshold ?? DEFAULT_OPTIONS.errorRateThreshold;
|
|
291
|
+
this.latencyP99ThresholdMs = options.latencyP99ThresholdMs ?? DEFAULT_OPTIONS.latencyP99ThresholdMs;
|
|
292
|
+
this.throughputDropThreshold = options.throughputDropThreshold ?? 0.2;
|
|
293
|
+
}
|
|
294
|
+
analyzeSpecUsage(samples) {
|
|
295
|
+
if (!samples.length) {
|
|
296
|
+
this.logger?.debug("SpecAnalyzer.analyzeSpecUsage.skip", {
|
|
297
|
+
reason: "no-samples"
|
|
298
|
+
});
|
|
500
299
|
return [];
|
|
501
300
|
}
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
if (!row)
|
|
523
|
-
return null;
|
|
524
|
-
const totalCalls = asNumber(row.totalCalls);
|
|
525
|
-
if (!totalCalls)
|
|
526
|
-
return null;
|
|
527
|
-
const successCount = asNumber(row.successCount);
|
|
528
|
-
const errorCount = asNumber(row.errorCount);
|
|
529
|
-
return {
|
|
530
|
-
operation,
|
|
531
|
-
totalCalls,
|
|
532
|
-
successRate: totalCalls ? successCount / totalCalls : 0,
|
|
533
|
-
errorRate: totalCalls ? errorCount / totalCalls : 0,
|
|
534
|
-
averageLatencyMs: asNumber(row.averageLatencyMs),
|
|
535
|
-
p95LatencyMs: asNumber(row.p95LatencyMs),
|
|
536
|
-
p99LatencyMs: asNumber(row.p99LatencyMs),
|
|
537
|
-
maxLatencyMs: asNumber(row.maxLatencyMs),
|
|
538
|
-
lastSeenAt: new Date,
|
|
539
|
-
windowStart: toDate(dateRange.from) ?? new Date,
|
|
540
|
-
windowEnd: toDate(dateRange.to) ?? new Date,
|
|
541
|
-
topErrors: {}
|
|
542
|
-
};
|
|
543
|
-
}
|
|
544
|
-
function mapRows(result) {
|
|
545
|
-
if (!Array.isArray(result.results) || !Array.isArray(result.columns)) {
|
|
546
|
-
return [];
|
|
301
|
+
const groups = new Map;
|
|
302
|
+
for (const sample of samples) {
|
|
303
|
+
const key = this.operationKey(sample);
|
|
304
|
+
const arr = groups.get(key) ?? [];
|
|
305
|
+
arr.push(sample);
|
|
306
|
+
groups.set(key, arr);
|
|
307
|
+
}
|
|
308
|
+
const entries = [...groups.values()];
|
|
309
|
+
const usable = entries.filter((samplesForOp) => {
|
|
310
|
+
const valid = samplesForOp.length >= this.minSampleSize;
|
|
311
|
+
if (!valid) {
|
|
312
|
+
this.logger?.debug("SpecAnalyzer.analyzeSpecUsage.skipOperation", {
|
|
313
|
+
operation: this.operationKey(samplesForOp[0]),
|
|
314
|
+
sampleSize: samplesForOp.length,
|
|
315
|
+
minSampleSize: this.minSampleSize
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
return valid;
|
|
319
|
+
});
|
|
320
|
+
return usable.map((operationSamples) => this.buildUsageStats(operationSamples));
|
|
547
321
|
}
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
if (!
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
322
|
+
detectAnomalies(stats, baseline) {
|
|
323
|
+
const anomalies = [];
|
|
324
|
+
if (!stats.length) {
|
|
325
|
+
this.logger?.debug("SpecAnalyzer.detectAnomalies.skip", {
|
|
326
|
+
reason: "no-stats"
|
|
327
|
+
});
|
|
328
|
+
return anomalies;
|
|
329
|
+
}
|
|
330
|
+
const baselineByOp = new Map((baseline ?? []).map((item) => [this.operationKey(item.operation), item]));
|
|
331
|
+
for (const stat of stats) {
|
|
332
|
+
const evidence = [];
|
|
333
|
+
if (stat.errorRate >= this.errorRateThreshold) {
|
|
334
|
+
evidence.push({
|
|
335
|
+
type: "telemetry",
|
|
336
|
+
description: `Error rate ${stat.errorRate.toFixed(2)} exceeded threshold ${this.errorRateThreshold}`,
|
|
337
|
+
data: { errorRate: stat.errorRate }
|
|
338
|
+
});
|
|
339
|
+
anomalies.push({
|
|
340
|
+
operation: stat.operation,
|
|
341
|
+
severity: this.toSeverity(stat.errorRate / this.errorRateThreshold),
|
|
342
|
+
metric: "error-rate",
|
|
343
|
+
description: "Error rate spike",
|
|
344
|
+
detectedAt: new Date,
|
|
345
|
+
threshold: this.errorRateThreshold,
|
|
346
|
+
observedValue: stat.errorRate,
|
|
347
|
+
evidence
|
|
348
|
+
});
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (stat.p99LatencyMs >= this.latencyP99ThresholdMs) {
|
|
352
|
+
evidence.push({
|
|
353
|
+
type: "telemetry",
|
|
354
|
+
description: `P99 latency ${stat.p99LatencyMs}ms exceeded threshold ${this.latencyP99ThresholdMs}ms`,
|
|
355
|
+
data: { p99LatencyMs: stat.p99LatencyMs }
|
|
356
|
+
});
|
|
357
|
+
anomalies.push({
|
|
358
|
+
operation: stat.operation,
|
|
359
|
+
severity: this.toSeverity(stat.p99LatencyMs / this.latencyP99ThresholdMs),
|
|
360
|
+
metric: "latency",
|
|
361
|
+
description: "Latency regression detected",
|
|
362
|
+
detectedAt: new Date,
|
|
363
|
+
threshold: this.latencyP99ThresholdMs,
|
|
364
|
+
observedValue: stat.p99LatencyMs,
|
|
365
|
+
evidence
|
|
366
|
+
});
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
const baselineStat = baselineByOp.get(this.operationKey(stat.operation));
|
|
370
|
+
if (baselineStat) {
|
|
371
|
+
const drop = (baselineStat.totalCalls - stat.totalCalls) / baselineStat.totalCalls;
|
|
372
|
+
if (drop >= this.throughputDropThreshold) {
|
|
373
|
+
evidence.push({
|
|
374
|
+
type: "telemetry",
|
|
375
|
+
description: `Throughput dropped by ${(drop * 100).toFixed(1)}% compared to baseline`,
|
|
376
|
+
data: {
|
|
377
|
+
baselineCalls: baselineStat.totalCalls,
|
|
378
|
+
currentCalls: stat.totalCalls
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
anomalies.push({
|
|
382
|
+
operation: stat.operation,
|
|
383
|
+
severity: this.toSeverity(drop / this.throughputDropThreshold),
|
|
384
|
+
metric: "throughput",
|
|
385
|
+
description: "Usage drop detected",
|
|
386
|
+
detectedAt: new Date,
|
|
387
|
+
threshold: this.throughputDropThreshold,
|
|
388
|
+
observedValue: drop,
|
|
389
|
+
evidence
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return anomalies;
|
|
395
|
+
}
|
|
396
|
+
toIntentPatterns(anomalies, stats) {
|
|
397
|
+
const statsByOp = new Map(stats.map((item) => [this.operationKey(item.operation), item]));
|
|
398
|
+
return anomalies.map((anomaly) => {
|
|
399
|
+
const stat = statsByOp.get(this.operationKey(anomaly.operation));
|
|
400
|
+
const confidence = {
|
|
401
|
+
score: Math.min(1, (anomaly.observedValue ?? 0) / (anomaly.threshold ?? 1)),
|
|
402
|
+
sampleSize: stat?.totalCalls ?? 0,
|
|
403
|
+
pValue: undefined
|
|
404
|
+
};
|
|
405
|
+
return {
|
|
406
|
+
id: randomUUID(),
|
|
407
|
+
type: this.mapMetricToIntent(anomaly.metric),
|
|
408
|
+
description: anomaly.description,
|
|
409
|
+
operation: anomaly.operation,
|
|
410
|
+
confidence,
|
|
411
|
+
metadata: {
|
|
412
|
+
observedValue: anomaly.observedValue,
|
|
413
|
+
threshold: anomaly.threshold
|
|
414
|
+
},
|
|
415
|
+
evidence: anomaly.evidence
|
|
416
|
+
};
|
|
555
417
|
});
|
|
556
|
-
return [record];
|
|
557
|
-
});
|
|
558
|
-
}
|
|
559
|
-
function buildWindowRange(windowDays) {
|
|
560
|
-
const windowEnd = new Date;
|
|
561
|
-
const windowStart = new Date(windowEnd.getTime() - windowDays * 24 * 60 * 60 * 1000);
|
|
562
|
-
return {
|
|
563
|
-
from: windowStart,
|
|
564
|
-
to: windowEnd
|
|
565
|
-
};
|
|
566
|
-
}
|
|
567
|
-
function asString(value) {
|
|
568
|
-
if (typeof value === "string" && value.trim())
|
|
569
|
-
return value;
|
|
570
|
-
if (typeof value === "number")
|
|
571
|
-
return String(value);
|
|
572
|
-
return null;
|
|
573
|
-
}
|
|
574
|
-
function asOptionalString(value) {
|
|
575
|
-
if (typeof value === "string")
|
|
576
|
-
return value;
|
|
577
|
-
if (typeof value === "number")
|
|
578
|
-
return String(value);
|
|
579
|
-
return null;
|
|
580
|
-
}
|
|
581
|
-
function asNumber(value) {
|
|
582
|
-
if (typeof value === "number" && Number.isFinite(value))
|
|
583
|
-
return value;
|
|
584
|
-
if (typeof value === "string" && value.trim()) {
|
|
585
|
-
const parsed = Number(value);
|
|
586
|
-
if (Number.isFinite(parsed))
|
|
587
|
-
return parsed;
|
|
588
418
|
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
419
|
+
suggestOptimizations(stats, anomalies, lifecycleContext) {
|
|
420
|
+
const anomaliesByOp = new Map(this.groupByOperation(anomalies));
|
|
421
|
+
const hints = [];
|
|
422
|
+
for (const stat of stats) {
|
|
423
|
+
const opKey = this.operationKey(stat.operation);
|
|
424
|
+
const opAnomalies = anomaliesByOp.get(opKey) ?? [];
|
|
425
|
+
for (const anomaly of opAnomalies) {
|
|
426
|
+
if (anomaly.metric === "latency") {
|
|
427
|
+
hints.push(this.applyLifecycleContext({
|
|
428
|
+
operation: stat.operation,
|
|
429
|
+
category: "performance",
|
|
430
|
+
summary: "Latency regression detected",
|
|
431
|
+
justification: `P99 latency at ${stat.p99LatencyMs}ms`,
|
|
432
|
+
recommendedActions: [
|
|
433
|
+
"Add batching or caching layer",
|
|
434
|
+
"Replay golden tests to capture slow inputs"
|
|
435
|
+
]
|
|
436
|
+
}, lifecycleContext?.stage));
|
|
437
|
+
} else if (anomaly.metric === "error-rate") {
|
|
438
|
+
const topError = Object.entries(stat.topErrors).sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
439
|
+
hints.push(this.applyLifecycleContext({
|
|
440
|
+
operation: stat.operation,
|
|
441
|
+
category: "error-handling",
|
|
442
|
+
summary: "Error spike detected",
|
|
443
|
+
justification: topError ? `Dominant error code ${topError}` : "Increase in failures",
|
|
444
|
+
recommendedActions: [
|
|
445
|
+
"Generate regression spec from failing payloads",
|
|
446
|
+
"Add policy guardrails before rollout"
|
|
447
|
+
]
|
|
448
|
+
}, lifecycleContext?.stage));
|
|
449
|
+
} else if (anomaly.metric === "throughput") {
|
|
450
|
+
hints.push(this.applyLifecycleContext({
|
|
451
|
+
operation: stat.operation,
|
|
452
|
+
category: "performance",
|
|
453
|
+
summary: "Throughput drop detected",
|
|
454
|
+
justification: "Significant traffic reduction relative to baseline",
|
|
455
|
+
recommendedActions: [
|
|
456
|
+
"Validate routing + feature flag bucketing",
|
|
457
|
+
"Backfill spec variant to rehydrate demand"
|
|
458
|
+
]
|
|
459
|
+
}, lifecycleContext?.stage));
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return hints;
|
|
607
464
|
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
if (!value)
|
|
612
|
-
return;
|
|
613
|
-
return typeof value === "string" ? value : value.toISOString();
|
|
614
|
-
}
|
|
615
|
-
function toDate(value) {
|
|
616
|
-
if (!value)
|
|
617
|
-
return null;
|
|
618
|
-
return value instanceof Date ? value : new Date(value);
|
|
619
|
-
}
|
|
620
|
-
function isRecord(value) {
|
|
621
|
-
return typeof value === "object" && value !== null;
|
|
622
|
-
}
|
|
623
|
-
// src/generator/spec-generator.ts
|
|
624
|
-
import { randomUUID as randomUUID2 } from "crypto";
|
|
625
|
-
|
|
626
|
-
class SpecGenerator {
|
|
627
|
-
config;
|
|
628
|
-
logger;
|
|
629
|
-
clock;
|
|
630
|
-
getSpec;
|
|
631
|
-
constructor(options = {}) {
|
|
632
|
-
this.config = options.config ?? {};
|
|
633
|
-
this.logger = options.logger;
|
|
634
|
-
this.clock = options.clock ?? (() => new Date);
|
|
635
|
-
this.getSpec = options.getSpec;
|
|
465
|
+
operationKey(op) {
|
|
466
|
+
const coordinate = "operation" in op ? op.operation : op;
|
|
467
|
+
return `${coordinate.key}.v${coordinate.version}${coordinate.tenantId ? `@${coordinate.tenantId}` : ""}`;
|
|
636
468
|
}
|
|
637
|
-
|
|
638
|
-
const
|
|
639
|
-
const
|
|
640
|
-
const
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
const
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
469
|
+
buildUsageStats(samples) {
|
|
470
|
+
const durations = samples.map((s) => s.durationMs).sort((a, b) => a - b);
|
|
471
|
+
const errors = samples.filter((s) => !s.success);
|
|
472
|
+
const totalCalls = samples.length;
|
|
473
|
+
const successRate = (totalCalls - errors.length) / totalCalls;
|
|
474
|
+
const errorRate = errors.length / totalCalls;
|
|
475
|
+
const averageLatencyMs = durations.reduce((sum, value) => sum + value, 0) / totalCalls;
|
|
476
|
+
const topErrors = errors.reduce((acc, sample) => {
|
|
477
|
+
if (!sample.errorCode)
|
|
478
|
+
return acc;
|
|
479
|
+
acc[sample.errorCode] = (acc[sample.errorCode] ?? 0) + 1;
|
|
480
|
+
return acc;
|
|
481
|
+
}, {});
|
|
482
|
+
const timestamps = samples.map((s) => s.timestamp.getTime());
|
|
483
|
+
const windowStart = new Date(Math.min(...timestamps));
|
|
484
|
+
const windowEnd = new Date(Math.max(...timestamps));
|
|
485
|
+
return {
|
|
486
|
+
operation: samples[0].operation,
|
|
487
|
+
totalCalls,
|
|
488
|
+
successRate,
|
|
489
|
+
errorRate,
|
|
490
|
+
averageLatencyMs,
|
|
491
|
+
p95LatencyMs: percentile(durations, 0.95),
|
|
492
|
+
p99LatencyMs: percentile(durations, 0.99),
|
|
493
|
+
maxLatencyMs: Math.max(...durations),
|
|
494
|
+
lastSeenAt: windowEnd,
|
|
495
|
+
windowStart,
|
|
496
|
+
windowEnd,
|
|
497
|
+
topErrors
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
toSeverity(ratio) {
|
|
501
|
+
if (ratio >= 2)
|
|
502
|
+
return "high";
|
|
503
|
+
if (ratio >= 1.3)
|
|
504
|
+
return "medium";
|
|
505
|
+
return "low";
|
|
506
|
+
}
|
|
507
|
+
mapMetricToIntent(metric) {
|
|
508
|
+
switch (metric) {
|
|
509
|
+
case "error-rate":
|
|
510
|
+
return "error-spike";
|
|
511
|
+
case "latency":
|
|
512
|
+
return "latency-regression";
|
|
513
|
+
case "throughput":
|
|
514
|
+
return "throughput-drop";
|
|
515
|
+
default:
|
|
516
|
+
return "schema-mismatch";
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
groupByOperation(items) {
|
|
520
|
+
const map = new Map;
|
|
521
|
+
for (const item of items) {
|
|
522
|
+
const key = this.operationKey(item.operation);
|
|
523
|
+
const arr = map.get(key) ?? [];
|
|
524
|
+
arr.push(item);
|
|
525
|
+
map.set(key, arr);
|
|
526
|
+
}
|
|
527
|
+
return map;
|
|
528
|
+
}
|
|
529
|
+
applyLifecycleContext(hint, stage) {
|
|
530
|
+
if (stage === undefined)
|
|
531
|
+
return hint;
|
|
532
|
+
const band = mapStageBand(stage);
|
|
533
|
+
const advice = LIFECYCLE_HINTS[band]?.[hint.category];
|
|
534
|
+
if (!advice) {
|
|
535
|
+
return { ...hint, lifecycleStage: stage };
|
|
536
|
+
}
|
|
537
|
+
return {
|
|
538
|
+
...hint,
|
|
539
|
+
lifecycleStage: stage,
|
|
540
|
+
lifecycleNotes: advice.message,
|
|
541
|
+
recommendedActions: dedupeActions([
|
|
542
|
+
...hint.recommendedActions,
|
|
543
|
+
...advice.supplementalActions
|
|
544
|
+
])
|
|
664
545
|
};
|
|
665
|
-
return suggestion;
|
|
666
546
|
}
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
547
|
+
}
|
|
548
|
+
function percentile(values, p) {
|
|
549
|
+
if (!values.length)
|
|
550
|
+
return 0;
|
|
551
|
+
if (values.length === 1)
|
|
552
|
+
return values[0];
|
|
553
|
+
const idx = Math.min(values.length - 1, Math.floor(p * values.length));
|
|
554
|
+
return values[idx];
|
|
555
|
+
}
|
|
556
|
+
var mapStageBand = (stage) => {
|
|
557
|
+
if (stage <= 2)
|
|
558
|
+
return "early";
|
|
559
|
+
if (stage === LifecycleStage.ProductMarketFit)
|
|
560
|
+
return "pmf";
|
|
561
|
+
if (stage === LifecycleStage.GrowthScaleUp || stage === LifecycleStage.ExpansionPlatform) {
|
|
562
|
+
return "scale";
|
|
677
563
|
}
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
564
|
+
return "mature";
|
|
565
|
+
};
|
|
566
|
+
var LIFECYCLE_HINTS = {
|
|
567
|
+
early: {
|
|
568
|
+
performance: {
|
|
569
|
+
message: "Favor guardrails that protect learning velocity before heavy rewrites.",
|
|
570
|
+
supplementalActions: [
|
|
571
|
+
"Wrap risky changes behind progressive delivery flags"
|
|
572
|
+
]
|
|
573
|
+
},
|
|
574
|
+
"error-handling": {
|
|
575
|
+
message: "Make failures loud and recoverable so you can learn faster.",
|
|
576
|
+
supplementalActions: ["Add auto-rollbacks or manual kill switches"]
|
|
682
577
|
}
|
|
683
|
-
|
|
684
|
-
|
|
578
|
+
},
|
|
579
|
+
pmf: {
|
|
580
|
+
performance: {
|
|
581
|
+
message: "Stabilize the core use case to avoid regressions while demand grows.",
|
|
582
|
+
supplementalActions: ["Instrument regression tests on critical specs"]
|
|
685
583
|
}
|
|
686
|
-
|
|
687
|
-
|
|
584
|
+
},
|
|
585
|
+
scale: {
|
|
586
|
+
performance: {
|
|
587
|
+
message: "Prioritize resilience and multi-tenant safety as volumes expand.",
|
|
588
|
+
supplementalActions: [
|
|
589
|
+
"Introduce workload partitioning or isolation per tenant"
|
|
590
|
+
]
|
|
591
|
+
},
|
|
592
|
+
"error-handling": {
|
|
593
|
+
message: "Contain blast radius with policy fallbacks and circuit breakers.",
|
|
594
|
+
supplementalActions: ["Add circuit breakers to high-risk operations"]
|
|
688
595
|
}
|
|
689
|
-
|
|
690
|
-
|
|
596
|
+
},
|
|
597
|
+
mature: {
|
|
598
|
+
performance: {
|
|
599
|
+
message: "Optimize for margins and predictable SLAs.",
|
|
600
|
+
supplementalActions: [
|
|
601
|
+
"Capture unit-cost impacts alongside latency fixes"
|
|
602
|
+
]
|
|
603
|
+
},
|
|
604
|
+
"error-handling": {
|
|
605
|
+
message: "Prevent regressions with automated regression specs before deploy.",
|
|
606
|
+
supplementalActions: [
|
|
607
|
+
"Run auto-evolution simulations on renewal scenarios"
|
|
608
|
+
]
|
|
691
609
|
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
var dedupeActions = (actions) => {
|
|
613
|
+
const seen = new Set;
|
|
614
|
+
const ordered = [];
|
|
615
|
+
for (const action of actions) {
|
|
616
|
+
if (seen.has(action))
|
|
617
|
+
continue;
|
|
618
|
+
seen.add(action);
|
|
619
|
+
ordered.push(action);
|
|
620
|
+
}
|
|
621
|
+
return ordered;
|
|
622
|
+
};
|
|
623
|
+
// src/approval/integration.ts
|
|
624
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
625
|
+
import { join } from "path";
|
|
626
|
+
|
|
627
|
+
class SpecSuggestionOrchestrator {
|
|
628
|
+
options;
|
|
629
|
+
constructor(options) {
|
|
630
|
+
this.options = options;
|
|
631
|
+
}
|
|
632
|
+
async submit(suggestion, session, approvalReason) {
|
|
633
|
+
await this.options.repository.create(suggestion);
|
|
634
|
+
if (session && this.options.approval) {
|
|
635
|
+
await this.options.approval.requestApproval({
|
|
636
|
+
sessionId: session.sessionId,
|
|
637
|
+
agentId: session.agentId,
|
|
638
|
+
tenantId: session.tenantId,
|
|
639
|
+
toolName: "evolution_apply_suggestion",
|
|
640
|
+
toolCallId: suggestion.id,
|
|
641
|
+
toolArgs: { suggestionId: suggestion.id },
|
|
642
|
+
reason: approvalReason ?? suggestion.proposal.summary,
|
|
643
|
+
payload: { suggestionId: suggestion.id }
|
|
697
644
|
});
|
|
698
645
|
}
|
|
699
|
-
return
|
|
646
|
+
return suggestion;
|
|
700
647
|
}
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
648
|
+
async approve(id, reviewer, notes) {
|
|
649
|
+
const suggestion = await this.ensureSuggestion(id);
|
|
650
|
+
await this.options.repository.updateStatus(id, "approved", {
|
|
651
|
+
reviewer,
|
|
652
|
+
notes,
|
|
653
|
+
decidedAt: new Date
|
|
654
|
+
});
|
|
655
|
+
if (this.options.writer) {
|
|
656
|
+
await this.options.writer.write({
|
|
657
|
+
...suggestion,
|
|
658
|
+
status: "approved",
|
|
659
|
+
approvals: {
|
|
660
|
+
reviewer,
|
|
661
|
+
notes,
|
|
662
|
+
decidedAt: new Date,
|
|
663
|
+
status: "approved"
|
|
664
|
+
}
|
|
665
|
+
});
|
|
713
666
|
}
|
|
714
667
|
}
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
return "low";
|
|
668
|
+
async reject(id, reviewer, notes) {
|
|
669
|
+
await this.options.repository.updateStatus(id, "rejected", {
|
|
670
|
+
reviewer,
|
|
671
|
+
notes,
|
|
672
|
+
decidedAt: new Date
|
|
673
|
+
});
|
|
722
674
|
}
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
default:
|
|
732
|
-
return "revision";
|
|
733
|
-
}
|
|
675
|
+
list(filters) {
|
|
676
|
+
return this.options.repository.list(filters);
|
|
677
|
+
}
|
|
678
|
+
async ensureSuggestion(id) {
|
|
679
|
+
const suggestion = await this.options.repository.getById(id);
|
|
680
|
+
if (!suggestion)
|
|
681
|
+
throw new Error(`Spec suggestion ${id} not found`);
|
|
682
|
+
return suggestion;
|
|
734
683
|
}
|
|
735
684
|
}
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
685
|
+
|
|
686
|
+
class FileSystemSuggestionWriter {
|
|
687
|
+
outputDir;
|
|
688
|
+
filenameTemplate;
|
|
689
|
+
constructor(options = {}) {
|
|
690
|
+
this.outputDir = options.outputDir ?? join(process.cwd(), "packages/libs/contracts-spec/src/generated");
|
|
691
|
+
this.filenameTemplate = options.filenameTemplate ?? ((suggestion) => `${suggestion.target?.key ?? suggestion.intent.id}.v${suggestion.target?.version ?? "next"}.suggestion.json`);
|
|
692
|
+
}
|
|
693
|
+
async write(suggestion) {
|
|
694
|
+
await mkdir(this.outputDir, { recursive: true });
|
|
695
|
+
const filename = this.filenameTemplate(suggestion);
|
|
696
|
+
const filepath = join(this.outputDir, filename);
|
|
697
|
+
const payload = serializeSuggestion(suggestion);
|
|
698
|
+
await writeFile(filepath, JSON.stringify(payload, null, 2));
|
|
699
|
+
return filepath;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
class InMemorySpecSuggestionRepository {
|
|
704
|
+
items = new Map;
|
|
705
|
+
async create(suggestion) {
|
|
706
|
+
this.items.set(suggestion.id, suggestion);
|
|
707
|
+
}
|
|
708
|
+
async getById(id) {
|
|
709
|
+
return this.items.get(id);
|
|
710
|
+
}
|
|
711
|
+
async updateStatus(id, status, metadata) {
|
|
712
|
+
const suggestion = await this.getById(id);
|
|
713
|
+
if (!suggestion)
|
|
714
|
+
return;
|
|
715
|
+
this.items.set(id, {
|
|
716
|
+
...suggestion,
|
|
717
|
+
status,
|
|
718
|
+
approvals: {
|
|
719
|
+
reviewer: metadata?.reviewer,
|
|
720
|
+
notes: metadata?.notes,
|
|
721
|
+
decidedAt: metadata?.decidedAt,
|
|
722
|
+
status
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
async list(filters) {
|
|
727
|
+
const values = [...this.items.values()];
|
|
728
|
+
if (!filters)
|
|
729
|
+
return values;
|
|
730
|
+
return values.filter((item) => {
|
|
731
|
+
if (filters.status && item.status !== filters.status)
|
|
732
|
+
return false;
|
|
733
|
+
if (filters.operationKey && item.target?.key !== filters.operationKey)
|
|
734
|
+
return false;
|
|
735
|
+
return true;
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
function serializeSuggestion(suggestion) {
|
|
740
|
+
const { proposal, ...rest } = suggestion;
|
|
741
|
+
const { spec, ...proposalRest } = proposal;
|
|
742
|
+
return {
|
|
743
|
+
...rest,
|
|
744
|
+
proposal: {
|
|
745
|
+
...proposalRest,
|
|
746
|
+
specMeta: spec?.meta
|
|
752
747
|
},
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
...
|
|
748
|
+
createdAt: suggestion.createdAt.toISOString(),
|
|
749
|
+
intent: {
|
|
750
|
+
...suggestion.intent,
|
|
751
|
+
confidence: { ...suggestion.intent.confidence },
|
|
752
|
+
evidence: suggestion.intent.evidence
|
|
756
753
|
}
|
|
757
754
|
};
|
|
758
755
|
}
|
|
759
756
|
// src/generator/ai-spec-generator.ts
|
|
757
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
760
758
|
import { generateText, Output } from "ai";
|
|
761
759
|
import * as z from "zod";
|
|
762
|
-
import { randomUUID as randomUUID3 } from "crypto";
|
|
763
760
|
var SpecSuggestionProposalSchema = z.object({
|
|
764
761
|
summary: z.string().describe("Brief summary of the proposed change"),
|
|
765
762
|
rationale: z.string().describe("Detailed explanation of why this change is needed"),
|
|
@@ -906,7 +903,7 @@ Please provide an improved version with more specific recommendations.`;
|
|
|
906
903
|
}
|
|
907
904
|
};
|
|
908
905
|
return {
|
|
909
|
-
id:
|
|
906
|
+
id: randomUUID2(),
|
|
910
907
|
intent,
|
|
911
908
|
target: intent.operation,
|
|
912
909
|
proposal,
|
|
@@ -940,136 +937,139 @@ Please provide an improved version with more specific recommendations.`;
|
|
|
940
937
|
function createAISpecGenerator(config) {
|
|
941
938
|
return new AISpecGenerator(config);
|
|
942
939
|
}
|
|
943
|
-
// src/
|
|
944
|
-
import {
|
|
945
|
-
import { join } from "path";
|
|
940
|
+
// src/generator/spec-generator.ts
|
|
941
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
946
942
|
|
|
947
|
-
class
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
943
|
+
class SpecGenerator {
|
|
944
|
+
config;
|
|
945
|
+
logger;
|
|
946
|
+
clock;
|
|
947
|
+
getSpec;
|
|
948
|
+
constructor(options = {}) {
|
|
949
|
+
this.config = options.config ?? {};
|
|
950
|
+
this.logger = options.logger;
|
|
951
|
+
this.clock = options.clock ?? (() => new Date);
|
|
952
|
+
this.getSpec = options.getSpec;
|
|
951
953
|
}
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
954
|
+
generateFromIntent(intent, options = {}) {
|
|
955
|
+
const now = this.clock();
|
|
956
|
+
const summary = options.summary ?? `${this.intentToVerb(intent.type)} ${intent.operation?.key ?? "operation"}`;
|
|
957
|
+
const rationale = options.rationale ?? [
|
|
958
|
+
intent.description,
|
|
959
|
+
intent.metadata?.observedValue ? `Observed ${intent.metadata.observedValue}` : undefined
|
|
960
|
+
].filter(Boolean).join(" \u2014 ");
|
|
961
|
+
const suggestion = {
|
|
962
|
+
id: randomUUID3(),
|
|
963
|
+
intent,
|
|
964
|
+
target: intent.operation,
|
|
965
|
+
proposal: {
|
|
966
|
+
summary,
|
|
967
|
+
rationale,
|
|
968
|
+
changeType: options.changeType ?? this.inferChangeType(intent),
|
|
969
|
+
kind: options.kind,
|
|
970
|
+
spec: options.spec,
|
|
971
|
+
diff: options.diff,
|
|
972
|
+
metadata: options.metadata
|
|
973
|
+
},
|
|
974
|
+
confidence: intent.confidence.score,
|
|
975
|
+
priority: this.intentToPriority(intent),
|
|
976
|
+
createdAt: now,
|
|
977
|
+
createdBy: options.createdBy ?? "auto-evolution",
|
|
978
|
+
status: options.status ?? "pending",
|
|
979
|
+
evidence: intent.evidence,
|
|
980
|
+
tags: options.tags
|
|
981
|
+
};
|
|
966
982
|
return suggestion;
|
|
967
983
|
}
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
reviewer,
|
|
972
|
-
notes,
|
|
973
|
-
decidedAt: new Date
|
|
974
|
-
});
|
|
975
|
-
if (this.options.writer) {
|
|
976
|
-
await this.options.writer.write({
|
|
977
|
-
...suggestion,
|
|
978
|
-
status: "approved",
|
|
979
|
-
approvals: {
|
|
980
|
-
reviewer,
|
|
981
|
-
notes,
|
|
982
|
-
decidedAt: new Date,
|
|
983
|
-
status: "approved"
|
|
984
|
-
}
|
|
985
|
-
});
|
|
984
|
+
generateVariant(operation, patch, intent, options = {}) {
|
|
985
|
+
if (!this.getSpec) {
|
|
986
|
+
throw new Error("SpecGenerator requires getSpec() to generate variants");
|
|
986
987
|
}
|
|
988
|
+
const base = this.getSpec(operation.key, operation.version);
|
|
989
|
+
if (!base) {
|
|
990
|
+
throw new Error(`Cannot generate variant; spec ${operation.key}.v${operation.version} not found`);
|
|
991
|
+
}
|
|
992
|
+
const merged = mergeContract(base, patch);
|
|
993
|
+
return this.generateFromIntent(intent, { ...options, spec: merged });
|
|
987
994
|
}
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
this.outputDir = options.outputDir ?? join(process.cwd(), "packages/libs/contracts-spec/src/generated");
|
|
1011
|
-
this.filenameTemplate = options.filenameTemplate ?? ((suggestion) => `${suggestion.target?.key ?? suggestion.intent.id}.v${suggestion.target?.version ?? "next"}.suggestion.json`);
|
|
1012
|
-
}
|
|
1013
|
-
async write(suggestion) {
|
|
1014
|
-
await mkdir(this.outputDir, { recursive: true });
|
|
1015
|
-
const filename = this.filenameTemplate(suggestion);
|
|
1016
|
-
const filepath = join(this.outputDir, filename);
|
|
1017
|
-
const payload = serializeSuggestion(suggestion);
|
|
1018
|
-
await writeFile(filepath, JSON.stringify(payload, null, 2));
|
|
1019
|
-
return filepath;
|
|
1020
|
-
}
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
class InMemorySpecSuggestionRepository {
|
|
1024
|
-
items = new Map;
|
|
1025
|
-
async create(suggestion) {
|
|
1026
|
-
this.items.set(suggestion.id, suggestion);
|
|
995
|
+
validateSuggestion(suggestion, config = this.config) {
|
|
996
|
+
const reasons = [];
|
|
997
|
+
if (config.minConfidence != null && suggestion.confidence < config.minConfidence) {
|
|
998
|
+
reasons.push(`Confidence ${suggestion.confidence.toFixed(2)} below minimum ${config.minConfidence}`);
|
|
999
|
+
}
|
|
1000
|
+
if (config.requireApproval && suggestion.status === "approved") {
|
|
1001
|
+
reasons.push("Suggestion cannot be auto-approved when approval is required");
|
|
1002
|
+
}
|
|
1003
|
+
if (suggestion.proposal.spec && !suggestion.proposal.spec.meta?.key) {
|
|
1004
|
+
reasons.push("Proposal spec must include meta.key");
|
|
1005
|
+
}
|
|
1006
|
+
if (!suggestion.proposal.summary) {
|
|
1007
|
+
reasons.push("Proposal summary is required");
|
|
1008
|
+
}
|
|
1009
|
+
const ok = reasons.length === 0;
|
|
1010
|
+
if (!ok) {
|
|
1011
|
+
this.logger?.warn("SpecGenerator.validateSuggestion.failed", {
|
|
1012
|
+
suggestionId: suggestion.id,
|
|
1013
|
+
reasons
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
return { ok, reasons };
|
|
1027
1017
|
}
|
|
1028
|
-
|
|
1029
|
-
|
|
1018
|
+
intentToVerb(intent) {
|
|
1019
|
+
switch (intent) {
|
|
1020
|
+
case "error-spike":
|
|
1021
|
+
return "Stabilize";
|
|
1022
|
+
case "latency-regression":
|
|
1023
|
+
return "Optimize";
|
|
1024
|
+
case "missing-operation":
|
|
1025
|
+
return "Introduce";
|
|
1026
|
+
case "throughput-drop":
|
|
1027
|
+
return "Rebalance";
|
|
1028
|
+
default:
|
|
1029
|
+
return "Adjust";
|
|
1030
|
+
}
|
|
1030
1031
|
}
|
|
1031
|
-
|
|
1032
|
-
const
|
|
1033
|
-
if (
|
|
1034
|
-
return;
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
approvals: {
|
|
1039
|
-
reviewer: metadata?.reviewer,
|
|
1040
|
-
notes: metadata?.notes,
|
|
1041
|
-
decidedAt: metadata?.decidedAt,
|
|
1042
|
-
status
|
|
1043
|
-
}
|
|
1044
|
-
});
|
|
1032
|
+
intentToPriority(intent) {
|
|
1033
|
+
const severity = intent.confidence.score;
|
|
1034
|
+
if (intent.type === "error-spike" || severity >= 0.8)
|
|
1035
|
+
return "high";
|
|
1036
|
+
if (severity >= 0.5)
|
|
1037
|
+
return "medium";
|
|
1038
|
+
return "low";
|
|
1045
1039
|
}
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
}
|
|
1040
|
+
inferChangeType(intent) {
|
|
1041
|
+
switch (intent.type) {
|
|
1042
|
+
case "missing-operation":
|
|
1043
|
+
return "new-spec";
|
|
1044
|
+
case "schema-mismatch":
|
|
1045
|
+
return "schema-update";
|
|
1046
|
+
case "error-spike":
|
|
1047
|
+
return "policy-update";
|
|
1048
|
+
default:
|
|
1049
|
+
return "revision";
|
|
1050
|
+
}
|
|
1057
1051
|
}
|
|
1058
1052
|
}
|
|
1059
|
-
function
|
|
1060
|
-
const { proposal, ...rest } = suggestion;
|
|
1061
|
-
const { spec, ...proposalRest } = proposal;
|
|
1053
|
+
function mergeContract(base, patch) {
|
|
1062
1054
|
return {
|
|
1063
|
-
...
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1055
|
+
...base,
|
|
1056
|
+
...patch,
|
|
1057
|
+
meta: { ...base.meta, ...patch.meta },
|
|
1058
|
+
io: {
|
|
1059
|
+
...base.io,
|
|
1060
|
+
...patch.io
|
|
1067
1061
|
},
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
...
|
|
1071
|
-
|
|
1072
|
-
|
|
1062
|
+
policy: {
|
|
1063
|
+
...base.policy,
|
|
1064
|
+
...patch.policy
|
|
1065
|
+
},
|
|
1066
|
+
telemetry: {
|
|
1067
|
+
...base.telemetry,
|
|
1068
|
+
...patch.telemetry
|
|
1069
|
+
},
|
|
1070
|
+
sideEffects: {
|
|
1071
|
+
...base.sideEffects,
|
|
1072
|
+
...patch.sideEffects
|
|
1073
1073
|
}
|
|
1074
1074
|
};
|
|
1075
1075
|
}
|