ai-inference-stepper 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +169 -0
- package/.eslintrc.cjs +23 -0
- package/.github/workflows/ci.yml +51 -0
- package/.github/workflows/keep-alive.yml +22 -0
- package/.github/workflows/publish.yml +34 -0
- package/ARCHITECTURE.md +594 -0
- package/Dockerfile +16 -0
- package/LICENSE +28 -0
- package/README.md +261 -0
- package/dist/alerts/discord.d.ts +19 -0
- package/dist/alerts/discord.d.ts.map +1 -0
- package/dist/alerts/discord.js +70 -0
- package/dist/alerts/discord.js.map +1 -0
- package/dist/cache/redisCache.d.ts +45 -0
- package/dist/cache/redisCache.d.ts.map +1 -0
- package/dist/cache/redisCache.js +171 -0
- package/dist/cache/redisCache.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +8 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +251 -0
- package/dist/config.js.map +1 -0
- package/dist/fallback/templateFallback.d.ts +7 -0
- package/dist/fallback/templateFallback.d.ts.map +1 -0
- package/dist/fallback/templateFallback.js +29 -0
- package/dist/fallback/templateFallback.js.map +1 -0
- package/dist/index.d.ts +121 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +198 -0
- package/dist/index.js.map +1 -0
- package/dist/logging.d.ts +10 -0
- package/dist/logging.d.ts.map +1 -0
- package/dist/logging.js +44 -0
- package/dist/logging.js.map +1 -0
- package/dist/metrics/metrics.d.ts +22 -0
- package/dist/metrics/metrics.d.ts.map +1 -0
- package/dist/metrics/metrics.js +78 -0
- package/dist/metrics/metrics.js.map +1 -0
- package/dist/providers/factory.d.ts +11 -0
- package/dist/providers/factory.d.ts.map +1 -0
- package/dist/providers/factory.js +52 -0
- package/dist/providers/factory.js.map +1 -0
- package/dist/providers/hfSpace.adapter.d.ts +21 -0
- package/dist/providers/hfSpace.adapter.d.ts.map +1 -0
- package/dist/providers/hfSpace.adapter.js +110 -0
- package/dist/providers/hfSpace.adapter.js.map +1 -0
- package/dist/providers/httpTemplate.adapter.d.ts +42 -0
- package/dist/providers/httpTemplate.adapter.d.ts.map +1 -0
- package/dist/providers/httpTemplate.adapter.js +98 -0
- package/dist/providers/httpTemplate.adapter.js.map +1 -0
- package/dist/providers/promptBuilder.d.ts +34 -0
- package/dist/providers/promptBuilder.d.ts.map +1 -0
- package/dist/providers/promptBuilder.js +315 -0
- package/dist/providers/promptBuilder.js.map +1 -0
- package/dist/providers/provider.interface.d.ts +45 -0
- package/dist/providers/provider.interface.d.ts.map +1 -0
- package/dist/providers/provider.interface.js +47 -0
- package/dist/providers/provider.interface.js.map +1 -0
- package/dist/providers/specs.d.ts +18 -0
- package/dist/providers/specs.d.ts.map +1 -0
- package/dist/providers/specs.js +326 -0
- package/dist/providers/specs.js.map +1 -0
- package/dist/providers/unified.adapter.d.ts +37 -0
- package/dist/providers/unified.adapter.d.ts.map +1 -0
- package/dist/providers/unified.adapter.js +141 -0
- package/dist/providers/unified.adapter.js.map +1 -0
- package/dist/queue/producer.d.ts +30 -0
- package/dist/queue/producer.d.ts.map +1 -0
- package/dist/queue/producer.js +87 -0
- package/dist/queue/producer.js.map +1 -0
- package/dist/queue/worker.d.ts +9 -0
- package/dist/queue/worker.d.ts.map +1 -0
- package/dist/queue/worker.js +137 -0
- package/dist/queue/worker.js.map +1 -0
- package/dist/server/app.d.ts +4 -0
- package/dist/server/app.d.ts.map +1 -0
- package/dist/server/app.js +394 -0
- package/dist/server/app.js.map +1 -0
- package/dist/server/start.d.ts +16 -0
- package/dist/server/start.d.ts.map +1 -0
- package/dist/server/start.js +45 -0
- package/dist/server/start.js.map +1 -0
- package/dist/stepper/orchestrator.d.ts +22 -0
- package/dist/stepper/orchestrator.d.ts.map +1 -0
- package/dist/stepper/orchestrator.js +333 -0
- package/dist/stepper/orchestrator.js.map +1 -0
- package/dist/types.d.ts +216 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +14 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/redaction.d.ts +9 -0
- package/dist/utils/redaction.d.ts.map +1 -0
- package/dist/utils/redaction.js +41 -0
- package/dist/utils/redaction.js.map +1 -0
- package/dist/utils/safeRequest.d.ts +38 -0
- package/dist/utils/safeRequest.d.ts.map +1 -0
- package/dist/utils/safeRequest.js +104 -0
- package/dist/utils/safeRequest.js.map +1 -0
- package/dist/validation/report.schema.d.ts +48 -0
- package/dist/validation/report.schema.d.ts.map +1 -0
- package/dist/validation/report.schema.js +72 -0
- package/dist/validation/report.schema.js.map +1 -0
- package/dist/webhooks/delivery.d.ts +31 -0
- package/dist/webhooks/delivery.d.ts.map +1 -0
- package/dist/webhooks/delivery.js +102 -0
- package/dist/webhooks/delivery.js.map +1 -0
- package/docs/assets/architecture.png +0 -0
- package/package.json +75 -0
- package/render.yaml +25 -0
- package/src/alerts/README.md +25 -0
- package/src/alerts/discord.ts +86 -0
- package/src/cache/How redis caching works in package stepper.md +971 -0
- package/src/cache/README.md +51 -0
- package/src/cache/redisCache.ts +194 -0
- package/src/ci/deploy.sh +36 -0
- package/src/cli.ts +9 -0
- package/src/config.ts +265 -0
- package/src/fallback/templateFallback.ts +32 -0
- package/src/index.ts +246 -0
- package/src/logging.ts +46 -0
- package/src/metrics/README.md +24 -0
- package/src/metrics/metrics.ts +84 -0
- package/src/providers/How the providers interact.md +121 -0
- package/src/providers/README.md +121 -0
- package/src/providers/factory.ts +57 -0
- package/src/providers/hfSpace.adapter.ts +119 -0
- package/src/providers/httpTemplate.adapter.ts +138 -0
- package/src/providers/promptBuilder.ts +330 -0
- package/src/providers/provider.interface.ts +73 -0
- package/src/providers/specs.ts +366 -0
- package/src/providers/unified.adapter.ts +172 -0
- package/src/queue/How queue works in package stepper.md +149 -0
- package/src/queue/README.md +41 -0
- package/src/queue/producer.ts +108 -0
- package/src/queue/worker.ts +170 -0
- package/src/server/app.ts +451 -0
- package/src/server/start.ts +68 -0
- package/src/stepper/Dockerfile +48 -0
- package/src/stepper/How orchestrator works in package stepper.md +746 -0
- package/src/stepper/README.md +43 -0
- package/src/stepper/orchestrator.ts +437 -0
- package/src/types.ts +238 -0
- package/src/utils/redaction.ts +50 -0
- package/src/utils/safeRequest.ts +140 -0
- package/src/validation/README.md +25 -0
- package/src/validation/report.schema.ts +96 -0
- package/src/webhooks/delivery.ts +162 -0
- package/tests/integration/full-flow.test.ts +192 -0
- package/tests/unit/alerts/discord.test.ts +119 -0
- package/tests/unit/cache.test.ts +87 -0
- package/tests/unit/orchestrator-fallback.test.ts +92 -0
- package/tests/unit/orchestrator.test.ts +105 -0
- package/tests/unit/providers/factory.test.ts +161 -0
- package/tests/unit/providers/unified.adapter.test.ts +206 -0
- package/tests/unit/utils/redaction.test.ts +140 -0
- package/tests/unit/utils/safeRequest.test.ts +164 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redact sensitive information from text before sending to AI providers
|
|
3
|
+
*/
|
|
4
|
+
export function redactSecrets(text: string): string {
|
|
5
|
+
if (!text) return text;
|
|
6
|
+
|
|
7
|
+
let redacted = text;
|
|
8
|
+
|
|
9
|
+
// AWS access keys
|
|
10
|
+
redacted = redacted.replace(/(A?KIA|AKIA)[A-Z0-9]{16}/g, '[REDACTED_AWS_KEY]');
|
|
11
|
+
|
|
12
|
+
// Generic API keys and tokens (common patterns)
|
|
13
|
+
redacted = redacted.replace(/[a-zA-Z0-9_-]{32,}/g, (match) => {
|
|
14
|
+
// Don't redact commit SHAs (typically 40 chars) or very long base64
|
|
15
|
+
if (match.length === 40 || match.length > 200) return match;
|
|
16
|
+
return '[REDACTED_TOKEN]';
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// Password/secret assignments in env-style
|
|
20
|
+
redacted = redacted.replace(
|
|
21
|
+
/(password|passwd|secret|api_key|apikey|token|auth)(\s*[:=]\s*)(\S+)/gi,
|
|
22
|
+
'$1$2[REDACTED]'
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
// Email addresses
|
|
26
|
+
redacted = redacted.replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, '[REDACTED_EMAIL]');
|
|
27
|
+
|
|
28
|
+
// Base64 encoded secrets (long base64 strings)
|
|
29
|
+
redacted = redacted.replace(/[A-Za-z0-9+/]{100,}={0,2}/g, '[REDACTED_BASE64]');
|
|
30
|
+
|
|
31
|
+
return redacted;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Redact sensitive fields from an object
|
|
36
|
+
*/
|
|
37
|
+
export function redactObject<T extends Record<string, unknown>>(obj: T): T {
|
|
38
|
+
const result: Record<string, unknown> = { ...obj };
|
|
39
|
+
const sensitiveKeys = ['apiKey', 'api_key', 'token', 'password', 'secret', 'authorization'];
|
|
40
|
+
|
|
41
|
+
for (const key of Object.keys(result)) {
|
|
42
|
+
if (sensitiveKeys.includes(key.toLowerCase())) {
|
|
43
|
+
result[key] = '[REDACTED]';
|
|
44
|
+
} else if (typeof result[key] === 'string') {
|
|
45
|
+
result[key] = redactSecrets(result[key] as string);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return result as T;
|
|
50
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios';
|
|
2
|
+
import { logger } from '../logging.js';
|
|
3
|
+
|
|
4
|
+
export interface SafeRequestOptions extends AxiosRequestConfig {
|
|
5
|
+
timeout?: number;
|
|
6
|
+
retryAfterHeader?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SafeRequestResult<T = unknown> {
|
|
10
|
+
data: T;
|
|
11
|
+
status: number;
|
|
12
|
+
headers: Record<string, string>;
|
|
13
|
+
retryAfter?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class RequestError extends Error {
|
|
17
|
+
constructor(
|
|
18
|
+
message: string,
|
|
19
|
+
public status?: number,
|
|
20
|
+
public code?: string,
|
|
21
|
+
public retryAfter?: number
|
|
22
|
+
) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = 'RequestError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Make an HTTP request with timeout and retry-after header parsing
|
|
30
|
+
*/
|
|
31
|
+
export async function safeRequest<T = unknown>(
|
|
32
|
+
url: string,
|
|
33
|
+
options: SafeRequestOptions = {}
|
|
34
|
+
): Promise<SafeRequestResult<T>> {
|
|
35
|
+
const timeout = options.timeout || 15000;
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const response: AxiosResponse<T> = await axios({
|
|
39
|
+
...options,
|
|
40
|
+
url,
|
|
41
|
+
timeout,
|
|
42
|
+
validateStatus: (status) => status < 600, // Don't throw on any status
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Parse Retry-After header if present
|
|
46
|
+
let retryAfter: number | undefined;
|
|
47
|
+
const retryAfterHeader = response.headers['retry-after'];
|
|
48
|
+
if (retryAfterHeader) {
|
|
49
|
+
const parsed = parseInt(retryAfterHeader, 10);
|
|
50
|
+
retryAfter = isNaN(parsed) ? undefined : parsed;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Throw on error statuses
|
|
54
|
+
if (response.status >= 400) {
|
|
55
|
+
throw new RequestError(
|
|
56
|
+
`HTTP ${response.status}: ${response.statusText}`,
|
|
57
|
+
response.status,
|
|
58
|
+
`HTTP_${response.status}`,
|
|
59
|
+
retryAfter
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
data: response.data,
|
|
65
|
+
status: response.status,
|
|
66
|
+
headers: response.headers as Record<string, string>,
|
|
67
|
+
retryAfter,
|
|
68
|
+
};
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error instanceof RequestError) {
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (axios.isAxiosError(error)) {
|
|
75
|
+
const axiosError = error as AxiosError;
|
|
76
|
+
|
|
77
|
+
// Timeout
|
|
78
|
+
if (axiosError.code === 'ECONNABORTED' || axiosError.code === 'ETIMEDOUT') {
|
|
79
|
+
throw new RequestError('Request timeout', 408, 'TIMEOUT');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Network errors
|
|
83
|
+
if (axiosError.code === 'ENOTFOUND' || axiosError.code === 'ECONNREFUSED') {
|
|
84
|
+
throw new RequestError('Network error', 503, 'NETWORK_ERROR');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Parse response error
|
|
88
|
+
const status = axiosError.response?.status;
|
|
89
|
+
const retryAfter = axiosError.response?.headers['retry-after']
|
|
90
|
+
? parseInt(axiosError.response.headers['retry-after'], 10)
|
|
91
|
+
: undefined;
|
|
92
|
+
|
|
93
|
+
throw new RequestError(
|
|
94
|
+
axiosError.message,
|
|
95
|
+
status,
|
|
96
|
+
status ? `HTTP_${status}` : 'UNKNOWN',
|
|
97
|
+
retryAfter
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
logger.error({ error }, 'Unexpected request error');
|
|
102
|
+
throw new RequestError('Unexpected error', undefined, 'UNKNOWN');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Parse error and extract retry-after information
|
|
108
|
+
*/
|
|
109
|
+
export function parseRetryAfter(error: unknown): number | undefined {
|
|
110
|
+
if (error instanceof RequestError && error.retryAfter) {
|
|
111
|
+
return error.retryAfter;
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Check if error is retryable
|
|
118
|
+
*/
|
|
119
|
+
export function isRetryableError(error: unknown): boolean {
|
|
120
|
+
if (!(error instanceof RequestError)) return false;
|
|
121
|
+
|
|
122
|
+
const retryableCodes = [408, 429, 500, 502, 503, 504];
|
|
123
|
+
return error.status ? retryableCodes.includes(error.status) : false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Check if error is auth-related
|
|
128
|
+
*/
|
|
129
|
+
export function isAuthError(error: unknown): boolean {
|
|
130
|
+
if (!(error instanceof RequestError)) return false;
|
|
131
|
+
return error.status === 401 || error.status === 403;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Check if error is rate limit
|
|
136
|
+
*/
|
|
137
|
+
export function isRateLimitError(error: unknown): boolean {
|
|
138
|
+
if (!(error instanceof RequestError)) return false;
|
|
139
|
+
return error.status === 429;
|
|
140
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# ✅ Validation & Schemas
|
|
2
|
+
|
|
3
|
+
AI models are non-deterministic; they can sometimes return malformed JSON or skip required fields. This module ensures that every report returned by the **Inference Stepper** follows a strict, predictable format.
|
|
4
|
+
|
|
5
|
+
## 🎯 Purpose
|
|
6
|
+
|
|
7
|
+
- **Reliability**: Guarantees the API always returns data that matches the frontend's expectations.
|
|
8
|
+
- **Data Integrity**: Uses **Zod** to validate types and string lengths.
|
|
9
|
+
- **Fail-fast**: If an AI returns bad data, we catch it early and try a different provider.
|
|
10
|
+
|
|
11
|
+
## 📋 The Report Schema
|
|
12
|
+
|
|
13
|
+
Every report must contain:
|
|
14
|
+
|
|
15
|
+
- `title`: A concise summary of the change.
|
|
16
|
+
- `summary`: A detailed explanation.
|
|
17
|
+
- `changes`: A list of specific file/logic modifications.
|
|
18
|
+
- `rationale`: Why the change was made.
|
|
19
|
+
- `impact_and_tests`: What was affected and how it was verified.
|
|
20
|
+
- `next_steps`: Future tasks or related work.
|
|
21
|
+
- `tags`: Keywords for categorization.
|
|
22
|
+
|
|
23
|
+
## 🛠️ Usage
|
|
24
|
+
|
|
25
|
+
This module is used by every provider adapter. After the AI returns a string, the adapter calls `parseAndValidateReport()` to transform that string into a validated object.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ReportOutput } from '../types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Comprehensive Zod schema for validating AI-generated report output
|
|
6
|
+
* with detailed error messages
|
|
7
|
+
*/
|
|
8
|
+
export const ReportOutputSchema = z.object({
|
|
9
|
+
title: z.string()
|
|
10
|
+
.min(10, 'Title must be at least 10 characters')
|
|
11
|
+
.max(120, 'Title must not exceed 120 characters')
|
|
12
|
+
.refine(
|
|
13
|
+
(val) => val.trim().length > 0,
|
|
14
|
+
'Title cannot be empty or whitespace only'
|
|
15
|
+
),
|
|
16
|
+
|
|
17
|
+
summary: z.string()
|
|
18
|
+
.min(50, 'Summary must be at least 50 characters')
|
|
19
|
+
.max(2000, 'Summary must not exceed 2000 characters'),
|
|
20
|
+
|
|
21
|
+
changes: z.array(z.string().min(5, 'Each change must be at least 5 characters'))
|
|
22
|
+
.min(1, 'At least one change must be listed')
|
|
23
|
+
.max(50, 'Maximum 50 changes allowed'),
|
|
24
|
+
|
|
25
|
+
rationale: z.string()
|
|
26
|
+
.min(20, 'Rationale must be at least 20 characters')
|
|
27
|
+
.max(2000, 'Rationale must not exceed 2000 characters'),
|
|
28
|
+
|
|
29
|
+
impact_and_tests: z.string()
|
|
30
|
+
.min(20, 'Impact and tests section must be at least 20 characters')
|
|
31
|
+
.max(2000, 'Impact and tests section must not exceed 2000 characters'),
|
|
32
|
+
|
|
33
|
+
next_steps: z.array(z.string().min(5, 'Each next step must be at least 5 characters'))
|
|
34
|
+
.max(20, 'Maximum 20 next steps allowed'),
|
|
35
|
+
|
|
36
|
+
tags: z.string()
|
|
37
|
+
.max(200, 'Tags must not exceed 200 characters')
|
|
38
|
+
.refine(
|
|
39
|
+
(val) => val.split(',').every(tag => tag.trim().length > 0),
|
|
40
|
+
'Tags must be comma-separated with no empty values'
|
|
41
|
+
),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Validate report output against schema
|
|
46
|
+
*/
|
|
47
|
+
export function validateReportOutput(data: unknown): {
|
|
48
|
+
valid: boolean;
|
|
49
|
+
result?: ReportOutput;
|
|
50
|
+
error?: string;
|
|
51
|
+
} {
|
|
52
|
+
try {
|
|
53
|
+
const validated = ReportOutputSchema.parse(data);
|
|
54
|
+
return { valid: true, result: validated as ReportOutput };
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error instanceof z.ZodError) {
|
|
57
|
+
return {
|
|
58
|
+
valid: false,
|
|
59
|
+
error: error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join('; '),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return { valid: false, error: 'Unknown validation error' };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Safely parse JSON and validate
|
|
68
|
+
*/
|
|
69
|
+
export function parseAndValidateReport(jsonString: string): {
|
|
70
|
+
valid: boolean;
|
|
71
|
+
result?: ReportOutput;
|
|
72
|
+
error?: string;
|
|
73
|
+
} {
|
|
74
|
+
try {
|
|
75
|
+
// Clean up common AI model output issues
|
|
76
|
+
let cleaned = jsonString.trim();
|
|
77
|
+
|
|
78
|
+
// Remove markdown code blocks if present
|
|
79
|
+
cleaned = cleaned.replace(/^```json\s*/i, '').replace(/```\s*$/, '');
|
|
80
|
+
cleaned = cleaned.replace(/^```\s*/i, '').replace(/```\s*$/, '');
|
|
81
|
+
|
|
82
|
+
// Remove any leading/trailing text that isn't JSON
|
|
83
|
+
const jsonMatch = cleaned.match(/\{[\s\S]*\}/);
|
|
84
|
+
if (jsonMatch) {
|
|
85
|
+
cleaned = jsonMatch[0];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const parsed = JSON.parse(cleaned);
|
|
89
|
+
return validateReportOutput(parsed);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (error instanceof SyntaxError) {
|
|
92
|
+
return { valid: false, error: `Invalid JSON: ${error.message}` };
|
|
93
|
+
}
|
|
94
|
+
return { valid: false, error: 'Failed to parse JSON' };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// packages/stepper/src/webhooks/delivery.ts
|
|
2
|
+
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import { logger, createChildLogger } from '../logging.js';
|
|
5
|
+
|
|
6
|
+
export interface WebhookPayload {
|
|
7
|
+
jobId: string;
|
|
8
|
+
status: 'completed' | 'failed';
|
|
9
|
+
result?: unknown;
|
|
10
|
+
error?: string;
|
|
11
|
+
timestamp: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface WebhookConfig {
|
|
15
|
+
url: string;
|
|
16
|
+
secret: string;
|
|
17
|
+
maxRetries?: number;
|
|
18
|
+
retryDelayMs?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Generate HMAC-SHA256 signature for webhook payload
|
|
23
|
+
*/
|
|
24
|
+
function generateSignature(payload: string, secret: string): string {
|
|
25
|
+
return crypto
|
|
26
|
+
.createHmac('sha256', secret)
|
|
27
|
+
.update(payload)
|
|
28
|
+
.digest('hex');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Send webhook notification with bearer token + HMAC signature
|
|
33
|
+
* Implements retry logic for failed deliveries
|
|
34
|
+
*/
|
|
35
|
+
export async function sendWebhook(
|
|
36
|
+
config: WebhookConfig,
|
|
37
|
+
payload: WebhookPayload,
|
|
38
|
+
attempt: number = 1
|
|
39
|
+
): Promise<{ success: boolean; statusCode?: number; error?: string }> {
|
|
40
|
+
const log = createChildLogger({ jobId: payload.jobId, webhookAttempt: attempt });
|
|
41
|
+
|
|
42
|
+
const maxRetries = config.maxRetries || 3;
|
|
43
|
+
const retryDelayMs = config.retryDelayMs || 5000;
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const payloadString = JSON.stringify(payload);
|
|
47
|
+
const signature = generateSignature(payloadString, config.secret);
|
|
48
|
+
|
|
49
|
+
log.info({ url: config.url, attempt, maxRetries }, 'Sending webhook');
|
|
50
|
+
|
|
51
|
+
const response = await fetch(config.url, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: {
|
|
54
|
+
'Content-Type': 'application/json',
|
|
55
|
+
'Authorization': `Bearer ${config.secret}`,
|
|
56
|
+
'X-Webhook-Signature': signature,
|
|
57
|
+
'X-Webhook-Timestamp': payload.timestamp.toString(),
|
|
58
|
+
'User-Agent': 'Stepper/1.0'
|
|
59
|
+
},
|
|
60
|
+
body: payloadString,
|
|
61
|
+
signal: AbortSignal.timeout(10000) // 10s timeout
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (response.ok) {
|
|
65
|
+
log.info({ statusCode: response.status }, 'Webhook delivered successfully');
|
|
66
|
+
return { success: true, statusCode: response.status };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Non-OK response
|
|
70
|
+
const errorBody = await response.text().catch(() => 'Unable to read response');
|
|
71
|
+
log.warn({ statusCode: response.status, errorBody }, 'Webhook delivery failed with non-OK status');
|
|
72
|
+
|
|
73
|
+
// Retry on 5xx errors or specific 4xx errors
|
|
74
|
+
const shouldRetry = response.status >= 500 || response.status === 408 || response.status === 429;
|
|
75
|
+
|
|
76
|
+
if (shouldRetry && attempt < maxRetries) {
|
|
77
|
+
log.info({ nextAttempt: attempt + 1, delayMs: retryDelayMs }, 'Retrying webhook delivery');
|
|
78
|
+
await new Promise(resolve => setTimeout(resolve, retryDelayMs * attempt)); // Exponential backoff
|
|
79
|
+
return sendWebhook(config, payload, attempt + 1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
success: false,
|
|
84
|
+
statusCode: response.status,
|
|
85
|
+
error: `HTTP ${response.status}: ${errorBody.substring(0, 200)}`
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
} catch (error) {
|
|
89
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
90
|
+
log.error({ error: errorMessage, attempt }, 'Webhook delivery error');
|
|
91
|
+
|
|
92
|
+
// Retry on network errors
|
|
93
|
+
if (attempt < maxRetries) {
|
|
94
|
+
log.info({ nextAttempt: attempt + 1, delayMs: retryDelayMs }, 'Retrying webhook after error');
|
|
95
|
+
await new Promise(resolve => setTimeout(resolve, retryDelayMs * attempt));
|
|
96
|
+
return sendWebhook(config, payload, attempt + 1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
success: false,
|
|
101
|
+
error: `Network error after ${maxRetries} attempts: ${errorMessage}`
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Send success webhook notification
|
|
108
|
+
*/
|
|
109
|
+
export async function notifyWebhookSuccess(
|
|
110
|
+
webhookUrl: string,
|
|
111
|
+
webhookSecret: string,
|
|
112
|
+
jobId: string,
|
|
113
|
+
result: unknown
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
const payload: WebhookPayload = {
|
|
116
|
+
jobId,
|
|
117
|
+
status: 'completed',
|
|
118
|
+
result,
|
|
119
|
+
timestamp: Date.now()
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const webhookResult = await sendWebhook(
|
|
123
|
+
{ url: webhookUrl, secret: webhookSecret },
|
|
124
|
+
payload
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
if (!webhookResult.success) {
|
|
128
|
+
logger.warn(
|
|
129
|
+
{ jobId, error: webhookResult.error },
|
|
130
|
+
'Webhook delivery failed after all retries - job completed but notification not delivered'
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Send failure webhook notification
|
|
137
|
+
*/
|
|
138
|
+
export async function notifyWebhookFailure(
|
|
139
|
+
webhookUrl: string,
|
|
140
|
+
webhookSecret: string,
|
|
141
|
+
jobId: string,
|
|
142
|
+
error: string
|
|
143
|
+
): Promise<void> {
|
|
144
|
+
const payload: WebhookPayload = {
|
|
145
|
+
jobId,
|
|
146
|
+
status: 'failed',
|
|
147
|
+
error,
|
|
148
|
+
timestamp: Date.now()
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const webhookResult = await sendWebhook(
|
|
152
|
+
{ url: webhookUrl, secret: webhookSecret },
|
|
153
|
+
payload
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
if (!webhookResult.success) {
|
|
157
|
+
logger.warn(
|
|
158
|
+
{ jobId, error: webhookResult.error },
|
|
159
|
+
'Failure webhook delivery failed - job failed and notification not delivered'
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
2
|
+
import { enqueueReport, generateReport, deleteReport, registerCallbacks } from '../../src/index.js';
|
|
3
|
+
import { initializeProviders } from '../../src/stepper/orchestrator.js';
|
|
4
|
+
import { PromptInput, StepperCallbacks } from '../../src/types.js';
|
|
5
|
+
|
|
6
|
+
// Mock provider adapter for controlled testing
|
|
7
|
+
const mockProviderCall = vi.fn();
|
|
8
|
+
|
|
9
|
+
vi.mock('../../src/providers/hfSpace.adapter.js', () => ({
|
|
10
|
+
HuggingFaceSpaceAdapter: class {
|
|
11
|
+
name = 'hf-space';
|
|
12
|
+
async call(_input: PromptInput) {
|
|
13
|
+
return mockProviderCall();
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
// Mock Redis operations
|
|
19
|
+
vi.mock('../../src/cache/redisCache.js', async () => {
|
|
20
|
+
const actual = await vi.importActual<typeof import('../../src/cache/redisCache.js')>('../../src/cache/redisCache.js');
|
|
21
|
+
const cache = new Map<string, any>();
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
...actual,
|
|
25
|
+
getReportCache: vi.fn(async (key: string) => cache.get(key) ?? null),
|
|
26
|
+
setDehydrated: vi.fn(async (key: string, jobId: string) => {
|
|
27
|
+
cache.set(key, { status: 'dehydrated', jobId });
|
|
28
|
+
}),
|
|
29
|
+
setHydrated: vi.fn(async (key: string, result: any) => {
|
|
30
|
+
cache.set(key, {
|
|
31
|
+
status: 'hydrated',
|
|
32
|
+
result,
|
|
33
|
+
timestamps: { created: new Date().toISOString(), updated: new Date().toISOString() }
|
|
34
|
+
});
|
|
35
|
+
}),
|
|
36
|
+
deleteCacheEntry: vi.fn(async (key: string) => {
|
|
37
|
+
cache.delete(key);
|
|
38
|
+
}),
|
|
39
|
+
isHydratedFresh: vi.fn(() => true),
|
|
40
|
+
isStaleButUsable: vi.fn(() => false),
|
|
41
|
+
buildCacheKey: actual.buildCacheKey,
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Mock queue operations
|
|
46
|
+
vi.mock('../../src/queue/producer.js', () => ({
|
|
47
|
+
enqueueReportJob: vi.fn(async () => 'mock-job-id'),
|
|
48
|
+
getJobStatus: vi.fn(async () => null),
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
describe('Full Report Generation Flow', () => {
|
|
52
|
+
const testInput: PromptInput = {
|
|
53
|
+
userId: 'integration-test-user',
|
|
54
|
+
commitSha: 'int123abc',
|
|
55
|
+
repo: 'test/integration-repo',
|
|
56
|
+
message: 'Integration test commit message',
|
|
57
|
+
files: ['src/app.ts', 'src/utils.ts'],
|
|
58
|
+
components: ['app', 'utils'],
|
|
59
|
+
diffSummary: '+ added new feature\n- removed deprecated code',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const mockReport = {
|
|
63
|
+
title: 'Integration Test Report',
|
|
64
|
+
summary: 'This is a test report generated during integration testing',
|
|
65
|
+
changes: ['Added new feature', 'Removed deprecated code'],
|
|
66
|
+
rationale: 'To improve code quality and add requested functionality',
|
|
67
|
+
impact_and_tests: 'Medium impact, unit tests added',
|
|
68
|
+
next_steps: ['Review PR', 'Deploy to staging'],
|
|
69
|
+
tags: 'feature, cleanup',
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
vi.clearAllMocks();
|
|
74
|
+
|
|
75
|
+
// Initialize providers with test config
|
|
76
|
+
initializeProviders([
|
|
77
|
+
{
|
|
78
|
+
name: 'hf-space',
|
|
79
|
+
enabled: true,
|
|
80
|
+
baseUrl: 'http://test.local',
|
|
81
|
+
rateLimitRPS: 10,
|
|
82
|
+
concurrency: 1,
|
|
83
|
+
timeout: 5000,
|
|
84
|
+
},
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
afterEach(() => {
|
|
89
|
+
vi.restoreAllMocks();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('generateReport (synchronous)', () => {
|
|
93
|
+
it('should generate report end-to-end with provider', async () => {
|
|
94
|
+
mockProviderCall.mockResolvedValueOnce(mockReport);
|
|
95
|
+
|
|
96
|
+
const result = await generateReport(testInput);
|
|
97
|
+
|
|
98
|
+
expect(result.result).toBeDefined();
|
|
99
|
+
expect(result.result.title).toBe('Integration Test Report');
|
|
100
|
+
expect(result.result.summary).toBeTruthy();
|
|
101
|
+
expect(result.usedProvider).toBe('hf-space');
|
|
102
|
+
expect(result.fallback).toBe(false);
|
|
103
|
+
expect(result.timings).toBeDefined();
|
|
104
|
+
expect(result.timings.totalMs).toBeGreaterThanOrEqual(0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should invoke all lifecycle callbacks in correct order', async () => {
|
|
108
|
+
const callOrder: string[] = [];
|
|
109
|
+
|
|
110
|
+
const callbacks: StepperCallbacks = {
|
|
111
|
+
onStart: vi.fn(() => { callOrder.push('start'); }),
|
|
112
|
+
onProviderAttempt: vi.fn(() => { callOrder.push('attempt'); }),
|
|
113
|
+
onSuccess: vi.fn(() => { callOrder.push('success'); }),
|
|
114
|
+
onFallback: vi.fn(() => { callOrder.push('fallback'); }),
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
registerCallbacks(callbacks);
|
|
118
|
+
mockProviderCall.mockResolvedValueOnce(mockReport);
|
|
119
|
+
|
|
120
|
+
await generateReport(testInput);
|
|
121
|
+
|
|
122
|
+
expect(callOrder).toEqual(['start', 'attempt', 'success']);
|
|
123
|
+
expect(callbacks.onStart).toHaveBeenCalledTimes(1);
|
|
124
|
+
expect(callbacks.onProviderAttempt).toHaveBeenCalledTimes(1);
|
|
125
|
+
expect(callbacks.onSuccess).toHaveBeenCalledTimes(1);
|
|
126
|
+
expect(callbacks.onFallback).not.toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should fallback when all providers fail', async () => {
|
|
130
|
+
mockProviderCall.mockRejectedValueOnce(new Error('Provider unavailable'));
|
|
131
|
+
|
|
132
|
+
const result = await generateReport(testInput);
|
|
133
|
+
|
|
134
|
+
expect(result.fallback).toBe(true);
|
|
135
|
+
expect(result.usedProvider).toBe('fallback');
|
|
136
|
+
expect(result.result).toBeDefined();
|
|
137
|
+
// Fallback should still produce a valid report structure
|
|
138
|
+
expect(result.result.title).toBeTruthy();
|
|
139
|
+
expect(result.result.summary).toBeTruthy();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('enqueueReport (asynchronous)', () => {
|
|
144
|
+
it('should enqueue job on cache miss', async () => {
|
|
145
|
+
const result = await enqueueReport(testInput);
|
|
146
|
+
|
|
147
|
+
// On cache miss, should return 202 with jobId
|
|
148
|
+
expect(result.status).toBe(202);
|
|
149
|
+
if (result.status === 202) {
|
|
150
|
+
expect(result.jobId).toBeDefined();
|
|
151
|
+
expect(result.cached).toBe(false);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('deleteReport', () => {
|
|
157
|
+
it('should not throw when deleting non-existent report', async () => {
|
|
158
|
+
await expect(
|
|
159
|
+
deleteReport('non-existent-user', 'non-existent-sha')
|
|
160
|
+
).resolves.not.toThrow();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('Error handling', () => {
|
|
165
|
+
it('should handle provider timeout gracefully', async () => {
|
|
166
|
+
mockProviderCall.mockImplementationOnce(() =>
|
|
167
|
+
new Promise((_, reject) =>
|
|
168
|
+
setTimeout(() => reject(new Error('Timeout')), 100)
|
|
169
|
+
)
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const result = await generateReport(testInput);
|
|
173
|
+
|
|
174
|
+
// Should fallback on timeout
|
|
175
|
+
expect(result.fallback).toBe(true);
|
|
176
|
+
expect(result.providersAttempted.length).toBeGreaterThan(0);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('should track all provider attempts in metadata', async () => {
|
|
180
|
+
mockProviderCall.mockRejectedValueOnce(new Error('First failure'));
|
|
181
|
+
|
|
182
|
+
const result = await generateReport(testInput);
|
|
183
|
+
|
|
184
|
+
expect(result.providersAttempted).toBeDefined();
|
|
185
|
+
expect(result.providersAttempted.length).toBeGreaterThan(0);
|
|
186
|
+
|
|
187
|
+
const attempt = result.providersAttempted[0];
|
|
188
|
+
expect(attempt.provider).toBe('hf-space');
|
|
189
|
+
expect(attempt.error).toBeDefined();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
});
|