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.
Files changed (160) hide show
  1. package/.env.example +169 -0
  2. package/.eslintrc.cjs +23 -0
  3. package/.github/workflows/ci.yml +51 -0
  4. package/.github/workflows/keep-alive.yml +22 -0
  5. package/.github/workflows/publish.yml +34 -0
  6. package/ARCHITECTURE.md +594 -0
  7. package/Dockerfile +16 -0
  8. package/LICENSE +28 -0
  9. package/README.md +261 -0
  10. package/dist/alerts/discord.d.ts +19 -0
  11. package/dist/alerts/discord.d.ts.map +1 -0
  12. package/dist/alerts/discord.js +70 -0
  13. package/dist/alerts/discord.js.map +1 -0
  14. package/dist/cache/redisCache.d.ts +45 -0
  15. package/dist/cache/redisCache.d.ts.map +1 -0
  16. package/dist/cache/redisCache.js +171 -0
  17. package/dist/cache/redisCache.js.map +1 -0
  18. package/dist/cli.d.ts +3 -0
  19. package/dist/cli.d.ts.map +1 -0
  20. package/dist/cli.js +8 -0
  21. package/dist/cli.js.map +1 -0
  22. package/dist/config.d.ts +6 -0
  23. package/dist/config.d.ts.map +1 -0
  24. package/dist/config.js +251 -0
  25. package/dist/config.js.map +1 -0
  26. package/dist/fallback/templateFallback.d.ts +7 -0
  27. package/dist/fallback/templateFallback.d.ts.map +1 -0
  28. package/dist/fallback/templateFallback.js +29 -0
  29. package/dist/fallback/templateFallback.js.map +1 -0
  30. package/dist/index.d.ts +121 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +198 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/logging.d.ts +10 -0
  35. package/dist/logging.d.ts.map +1 -0
  36. package/dist/logging.js +44 -0
  37. package/dist/logging.js.map +1 -0
  38. package/dist/metrics/metrics.d.ts +22 -0
  39. package/dist/metrics/metrics.d.ts.map +1 -0
  40. package/dist/metrics/metrics.js +78 -0
  41. package/dist/metrics/metrics.js.map +1 -0
  42. package/dist/providers/factory.d.ts +11 -0
  43. package/dist/providers/factory.d.ts.map +1 -0
  44. package/dist/providers/factory.js +52 -0
  45. package/dist/providers/factory.js.map +1 -0
  46. package/dist/providers/hfSpace.adapter.d.ts +21 -0
  47. package/dist/providers/hfSpace.adapter.d.ts.map +1 -0
  48. package/dist/providers/hfSpace.adapter.js +110 -0
  49. package/dist/providers/hfSpace.adapter.js.map +1 -0
  50. package/dist/providers/httpTemplate.adapter.d.ts +42 -0
  51. package/dist/providers/httpTemplate.adapter.d.ts.map +1 -0
  52. package/dist/providers/httpTemplate.adapter.js +98 -0
  53. package/dist/providers/httpTemplate.adapter.js.map +1 -0
  54. package/dist/providers/promptBuilder.d.ts +34 -0
  55. package/dist/providers/promptBuilder.d.ts.map +1 -0
  56. package/dist/providers/promptBuilder.js +315 -0
  57. package/dist/providers/promptBuilder.js.map +1 -0
  58. package/dist/providers/provider.interface.d.ts +45 -0
  59. package/dist/providers/provider.interface.d.ts.map +1 -0
  60. package/dist/providers/provider.interface.js +47 -0
  61. package/dist/providers/provider.interface.js.map +1 -0
  62. package/dist/providers/specs.d.ts +18 -0
  63. package/dist/providers/specs.d.ts.map +1 -0
  64. package/dist/providers/specs.js +326 -0
  65. package/dist/providers/specs.js.map +1 -0
  66. package/dist/providers/unified.adapter.d.ts +37 -0
  67. package/dist/providers/unified.adapter.d.ts.map +1 -0
  68. package/dist/providers/unified.adapter.js +141 -0
  69. package/dist/providers/unified.adapter.js.map +1 -0
  70. package/dist/queue/producer.d.ts +30 -0
  71. package/dist/queue/producer.d.ts.map +1 -0
  72. package/dist/queue/producer.js +87 -0
  73. package/dist/queue/producer.js.map +1 -0
  74. package/dist/queue/worker.d.ts +9 -0
  75. package/dist/queue/worker.d.ts.map +1 -0
  76. package/dist/queue/worker.js +137 -0
  77. package/dist/queue/worker.js.map +1 -0
  78. package/dist/server/app.d.ts +4 -0
  79. package/dist/server/app.d.ts.map +1 -0
  80. package/dist/server/app.js +394 -0
  81. package/dist/server/app.js.map +1 -0
  82. package/dist/server/start.d.ts +16 -0
  83. package/dist/server/start.d.ts.map +1 -0
  84. package/dist/server/start.js +45 -0
  85. package/dist/server/start.js.map +1 -0
  86. package/dist/stepper/orchestrator.d.ts +22 -0
  87. package/dist/stepper/orchestrator.d.ts.map +1 -0
  88. package/dist/stepper/orchestrator.js +333 -0
  89. package/dist/stepper/orchestrator.js.map +1 -0
  90. package/dist/types.d.ts +216 -0
  91. package/dist/types.d.ts.map +1 -0
  92. package/dist/types.js +14 -0
  93. package/dist/types.js.map +1 -0
  94. package/dist/utils/redaction.d.ts +9 -0
  95. package/dist/utils/redaction.d.ts.map +1 -0
  96. package/dist/utils/redaction.js +41 -0
  97. package/dist/utils/redaction.js.map +1 -0
  98. package/dist/utils/safeRequest.d.ts +38 -0
  99. package/dist/utils/safeRequest.d.ts.map +1 -0
  100. package/dist/utils/safeRequest.js +104 -0
  101. package/dist/utils/safeRequest.js.map +1 -0
  102. package/dist/validation/report.schema.d.ts +48 -0
  103. package/dist/validation/report.schema.d.ts.map +1 -0
  104. package/dist/validation/report.schema.js +72 -0
  105. package/dist/validation/report.schema.js.map +1 -0
  106. package/dist/webhooks/delivery.d.ts +31 -0
  107. package/dist/webhooks/delivery.d.ts.map +1 -0
  108. package/dist/webhooks/delivery.js +102 -0
  109. package/dist/webhooks/delivery.js.map +1 -0
  110. package/docs/assets/architecture.png +0 -0
  111. package/package.json +75 -0
  112. package/render.yaml +25 -0
  113. package/src/alerts/README.md +25 -0
  114. package/src/alerts/discord.ts +86 -0
  115. package/src/cache/How redis caching works in package stepper.md +971 -0
  116. package/src/cache/README.md +51 -0
  117. package/src/cache/redisCache.ts +194 -0
  118. package/src/ci/deploy.sh +36 -0
  119. package/src/cli.ts +9 -0
  120. package/src/config.ts +265 -0
  121. package/src/fallback/templateFallback.ts +32 -0
  122. package/src/index.ts +246 -0
  123. package/src/logging.ts +46 -0
  124. package/src/metrics/README.md +24 -0
  125. package/src/metrics/metrics.ts +84 -0
  126. package/src/providers/How the providers interact.md +121 -0
  127. package/src/providers/README.md +121 -0
  128. package/src/providers/factory.ts +57 -0
  129. package/src/providers/hfSpace.adapter.ts +119 -0
  130. package/src/providers/httpTemplate.adapter.ts +138 -0
  131. package/src/providers/promptBuilder.ts +330 -0
  132. package/src/providers/provider.interface.ts +73 -0
  133. package/src/providers/specs.ts +366 -0
  134. package/src/providers/unified.adapter.ts +172 -0
  135. package/src/queue/How queue works in package stepper.md +149 -0
  136. package/src/queue/README.md +41 -0
  137. package/src/queue/producer.ts +108 -0
  138. package/src/queue/worker.ts +170 -0
  139. package/src/server/app.ts +451 -0
  140. package/src/server/start.ts +68 -0
  141. package/src/stepper/Dockerfile +48 -0
  142. package/src/stepper/How orchestrator works in package stepper.md +746 -0
  143. package/src/stepper/README.md +43 -0
  144. package/src/stepper/orchestrator.ts +437 -0
  145. package/src/types.ts +238 -0
  146. package/src/utils/redaction.ts +50 -0
  147. package/src/utils/safeRequest.ts +140 -0
  148. package/src/validation/README.md +25 -0
  149. package/src/validation/report.schema.ts +96 -0
  150. package/src/webhooks/delivery.ts +162 -0
  151. package/tests/integration/full-flow.test.ts +192 -0
  152. package/tests/unit/alerts/discord.test.ts +119 -0
  153. package/tests/unit/cache.test.ts +87 -0
  154. package/tests/unit/orchestrator-fallback.test.ts +92 -0
  155. package/tests/unit/orchestrator.test.ts +105 -0
  156. package/tests/unit/providers/factory.test.ts +161 -0
  157. package/tests/unit/providers/unified.adapter.test.ts +206 -0
  158. package/tests/unit/utils/redaction.test.ts +140 -0
  159. package/tests/unit/utils/safeRequest.test.ts +164 -0
  160. 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
+ });