@studio-foundation/ralph 0.3.0-beta.1 → 0.3.0-beta.5
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/dist/context-enricher.d.ts +3 -0
- package/dist/context-enricher.d.ts.map +1 -0
- package/dist/context-enricher.js +7 -0
- package/dist/context-enricher.js.map +1 -0
- package/dist/contracts.d.ts +4 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +22 -0
- package/dist/contracts.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/{src/index.ts → dist/index.js} +1 -1
- package/dist/index.js.map +1 -0
- package/dist/loop.d.ts +40 -0
- package/dist/loop.d.ts.map +1 -0
- package/dist/loop.js +75 -0
- package/dist/loop.js.map +1 -0
- package/dist/retry-strategy.d.ts +5 -0
- package/dist/retry-strategy.d.ts.map +1 -0
- package/dist/retry-strategy.js +19 -0
- package/dist/retry-strategy.js.map +1 -0
- package/dist/validator.d.ts +14 -0
- package/dist/validator.d.ts.map +1 -0
- package/dist/validator.js +111 -0
- package/dist/validator.js.map +1 -0
- package/package.json +5 -2
- package/ARCHITECTURE.md +0 -41
- package/configs/examples/analysis.contract.yaml +0 -19
- package/configs/examples/code-generation.contract.yaml +0 -21
- package/src/context-enricher.ts +0 -9
- package/src/contracts.ts +0 -28
- package/src/loop.ts +0 -122
- package/src/retry-strategy.ts +0 -23
- package/src/validator.ts +0 -160
- package/tests/loop.test.ts +0 -355
- package/tests/retry.test.ts +0 -87
- package/tests/validator.test.ts +0 -625
- package/tsconfig.json +0 -24
package/src/loop.ts
DELETED
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
// RALPH loop - main function
|
|
2
|
-
import type { ValidationResult } from '@studio-foundation/contracts';
|
|
3
|
-
|
|
4
|
-
export interface ExecutionContext {
|
|
5
|
-
attempt: number;
|
|
6
|
-
previousFailures: string[];
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export interface RetryEvent<T> {
|
|
10
|
-
attempt: number;
|
|
11
|
-
result: T;
|
|
12
|
-
validation: ValidationResult;
|
|
13
|
-
allFailures: string[];
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface RetryStrategy {
|
|
17
|
-
getDelay(attempt: number): number;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface RalphConfig<T> {
|
|
21
|
-
executor: (context: ExecutionContext) => Promise<T>;
|
|
22
|
-
validator: (result: T) => ValidationResult | Promise<ValidationResult>;
|
|
23
|
-
maxAttempts: number;
|
|
24
|
-
retryStrategy: RetryStrategy;
|
|
25
|
-
onRetry?: (event: RetryEvent<T>) => void | Promise<void>;
|
|
26
|
-
onSuccess?: (result: T, attempts: number) => void | Promise<void>;
|
|
27
|
-
onExhausted?: (lastResult: T, allFailures: string[]) => void | Promise<void>;
|
|
28
|
-
signal?: AbortSignal;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export type RalphResult<T> =
|
|
32
|
-
| { status: 'success'; result: T; attempts: number }
|
|
33
|
-
| { status: 'exhausted'; lastResult: T; failures: string[]; attempts: number }
|
|
34
|
-
| { status: 'cancelled'; lastResult?: T; attempts: number };
|
|
35
|
-
|
|
36
|
-
export async function ralph<T>(config: RalphConfig<T>): Promise<RalphResult<T>> {
|
|
37
|
-
const { executor, validator, maxAttempts, retryStrategy, onRetry, onSuccess, onExhausted, signal } = config;
|
|
38
|
-
|
|
39
|
-
let attempt = 1;
|
|
40
|
-
const allFailures: string[] = [];
|
|
41
|
-
let lastResult: T | undefined;
|
|
42
|
-
|
|
43
|
-
while (attempt <= maxAttempts) {
|
|
44
|
-
// Check cancellation before each attempt
|
|
45
|
-
if (signal?.aborted) {
|
|
46
|
-
return { status: 'cancelled', lastResult, attempts: attempt };
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// 1. Execute avec contexte
|
|
50
|
-
const context: ExecutionContext = {
|
|
51
|
-
attempt,
|
|
52
|
-
previousFailures: [...allFailures]
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
let result: T;
|
|
56
|
-
try {
|
|
57
|
-
result = await executor(context);
|
|
58
|
-
} catch (err) {
|
|
59
|
-
// If signal was aborted, the executor likely threw an AbortError
|
|
60
|
-
if (signal?.aborted) {
|
|
61
|
-
return { status: 'cancelled', lastResult, attempts: attempt };
|
|
62
|
-
}
|
|
63
|
-
throw err; // Re-throw non-abort errors
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
lastResult = result;
|
|
67
|
-
|
|
68
|
-
// 2. Validate
|
|
69
|
-
const validation = await Promise.resolve(validator(result));
|
|
70
|
-
|
|
71
|
-
// 3. Si valide → SUCCESS
|
|
72
|
-
if (validation.valid) {
|
|
73
|
-
await onSuccess?.(result, attempt);
|
|
74
|
-
return { status: 'success', result, attempts: attempt };
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// 4. Si invalide → accumuler erreurs
|
|
78
|
-
allFailures.push(...validation.errors);
|
|
79
|
-
|
|
80
|
-
// 5. Si dernière tentative → EXHAUSTED
|
|
81
|
-
if (attempt >= maxAttempts) {
|
|
82
|
-
await onExhausted?.(result, allFailures);
|
|
83
|
-
return { status: 'exhausted', lastResult: result, attempts: attempt, failures: allFailures };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Check cancellation before retry
|
|
87
|
-
if (signal?.aborted) {
|
|
88
|
-
return { status: 'cancelled', lastResult: result, attempts: attempt };
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// 6. Callback + delay + retry
|
|
92
|
-
const retryEvent: RetryEvent<T> = {
|
|
93
|
-
attempt,
|
|
94
|
-
result,
|
|
95
|
-
validation,
|
|
96
|
-
allFailures: [...allFailures]
|
|
97
|
-
};
|
|
98
|
-
await onRetry?.(retryEvent);
|
|
99
|
-
|
|
100
|
-
const delay = retryStrategy.getDelay(attempt);
|
|
101
|
-
if (delay > 0) {
|
|
102
|
-
await abortableDelay(delay, signal);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
attempt++;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Unreachable mais TypeScript est content
|
|
109
|
-
throw new Error('ralph loop should have returned');
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Sleep that resolves immediately if signal is aborted */
|
|
113
|
-
function abortableDelay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
114
|
-
if (signal?.aborted) return Promise.resolve();
|
|
115
|
-
return new Promise((resolve) => {
|
|
116
|
-
const timer = setTimeout(resolve, ms);
|
|
117
|
-
signal?.addEventListener('abort', () => {
|
|
118
|
-
clearTimeout(timer);
|
|
119
|
-
resolve();
|
|
120
|
-
}, { once: true });
|
|
121
|
-
});
|
|
122
|
-
}
|
package/src/retry-strategy.ts
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
// Retry strategies
|
|
2
|
-
import type { RetryStrategy } from './loop.js';
|
|
3
|
-
|
|
4
|
-
export function noDelay(): RetryStrategy {
|
|
5
|
-
return {
|
|
6
|
-
getDelay: () => 0,
|
|
7
|
-
};
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export function fixedDelay(ms: number): RetryStrategy {
|
|
11
|
-
return {
|
|
12
|
-
getDelay: () => ms,
|
|
13
|
-
};
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function exponentialBackoff(baseMs: number, maxMs: number): RetryStrategy {
|
|
17
|
-
return {
|
|
18
|
-
getDelay: (attempt: number) => {
|
|
19
|
-
const delay = baseMs * Math.pow(2, attempt - 1);
|
|
20
|
-
return Math.min(delay, maxMs);
|
|
21
|
-
},
|
|
22
|
-
};
|
|
23
|
-
}
|
package/src/validator.ts
DELETED
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
// Validation engine
|
|
2
|
-
import type { ValidationResult, OutputContract, ToolCall, ToolCallRequirements } from '@studio-foundation/contracts';
|
|
3
|
-
|
|
4
|
-
export type { ToolCallRequirements } from '@studio-foundation/contracts';
|
|
5
|
-
|
|
6
|
-
export type Validator<T> = (result: T) => ValidationResult | Promise<ValidationResult>;
|
|
7
|
-
|
|
8
|
-
export interface AgentRunResult {
|
|
9
|
-
output: unknown;
|
|
10
|
-
tool_calls: ToolCall[];
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function validateSchema(output: unknown, contract: OutputContract): ValidationResult {
|
|
14
|
-
const errors: string[] = [];
|
|
15
|
-
const warnings: string[] = [];
|
|
16
|
-
|
|
17
|
-
// If no schema defined, consider it valid
|
|
18
|
-
if (!contract.schema || !contract.schema.required_fields) {
|
|
19
|
-
return { valid: true, errors, warnings };
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const requiredFields = contract.schema.required_fields as string[];
|
|
23
|
-
|
|
24
|
-
// Output must be an object to check fields
|
|
25
|
-
if (output === null || typeof output !== 'object') {
|
|
26
|
-
errors.push(`Expected object output, got ${output === null ? 'null' : typeof output}`);
|
|
27
|
-
return { valid: false, errors, warnings };
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const outputObj = output as Record<string, unknown>;
|
|
31
|
-
|
|
32
|
-
// Check each required field
|
|
33
|
-
for (const field of requiredFields) {
|
|
34
|
-
if (!(field in outputObj)) {
|
|
35
|
-
errors.push(`Missing required field: ${field}`);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
return {
|
|
40
|
-
valid: errors.length === 0,
|
|
41
|
-
errors,
|
|
42
|
-
warnings
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function isSuccessfulToolCall(tc: ToolCall): boolean {
|
|
47
|
-
return !tc.error;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function validateToolCalls(toolCalls: ToolCall[], requirements?: ToolCallRequirements): ValidationResult {
|
|
51
|
-
const errors: string[] = [];
|
|
52
|
-
const warnings: string[] = [];
|
|
53
|
-
|
|
54
|
-
const successfulCount = toolCalls.filter(isSuccessfulToolCall).length;
|
|
55
|
-
const failedCount = toolCalls.length - successfulCount;
|
|
56
|
-
|
|
57
|
-
if (requirements?.minimum !== undefined) {
|
|
58
|
-
if (successfulCount < requirements.minimum) {
|
|
59
|
-
const plural = requirements.minimum === 1 ? '' : 's';
|
|
60
|
-
const excluded = failedCount > 0 ? ` (${failedCount} failed excluded)` : '';
|
|
61
|
-
errors.push(
|
|
62
|
-
`Expected at least ${requirements.minimum} successful tool call${plural}, got ${successfulCount} successful${excluded}`
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (requirements?.maximum !== undefined) {
|
|
68
|
-
if (successfulCount > requirements.maximum) {
|
|
69
|
-
const plural = successfulCount === 1 ? '' : 's';
|
|
70
|
-
errors.push(
|
|
71
|
-
`Tool call limit exceeded: made ${successfulCount} successful call${plural}, maximum is ${requirements.maximum}. ` +
|
|
72
|
-
`This may indicate a loop. Check that the agent is not repeating the same operation.`
|
|
73
|
-
);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return { valid: errors.length === 0, errors, warnings };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** Normalize tool name: dots → hyphens so both conventions match */
|
|
81
|
-
function normalizeToolName(name: string): string {
|
|
82
|
-
return name.replace(/\./g, '-');
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export function validateRequiredTools(toolCalls: ToolCall[], requirements?: ToolCallRequirements): ValidationResult {
|
|
86
|
-
const errors: string[] = [];
|
|
87
|
-
const warnings: string[] = [];
|
|
88
|
-
|
|
89
|
-
if (requirements?.required_tools && requirements.required_tools.length > 0) {
|
|
90
|
-
for (const requiredTool of requirements.required_tools) {
|
|
91
|
-
const normalizedRequired = normalizeToolName(requiredTool);
|
|
92
|
-
const matchingCalls = toolCalls.filter(tc => normalizeToolName(tc.name) === normalizedRequired);
|
|
93
|
-
|
|
94
|
-
if (matchingCalls.length === 0) {
|
|
95
|
-
errors.push(`Required tool '${requiredTool}' was not called`);
|
|
96
|
-
} else if (!matchingCalls.some(isSuccessfulToolCall)) {
|
|
97
|
-
errors.push(`Required tool '${requiredTool}' has no successful calls (called ${matchingCalls.length} time${matchingCalls.length === 1 ? '' : 's'}, all failed)`);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
return { valid: errors.length === 0, errors, warnings };
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export function validateCountedTools(toolCalls: ToolCall[], requirements?: ToolCallRequirements): ValidationResult {
|
|
106
|
-
const errors: string[] = [];
|
|
107
|
-
const warnings: string[] = [];
|
|
108
|
-
|
|
109
|
-
if (requirements?.counted_tools && requirements.counted_tools.length > 0 && requirements?.minimum !== undefined) {
|
|
110
|
-
const countedSet = new Set(requirements.counted_tools.map(normalizeToolName));
|
|
111
|
-
const count = toolCalls.filter(
|
|
112
|
-
tc => countedSet.has(normalizeToolName(tc.name)) && isSuccessfulToolCall(tc)
|
|
113
|
-
).length;
|
|
114
|
-
|
|
115
|
-
if (count < requirements.minimum) {
|
|
116
|
-
const toolNames = requirements.counted_tools.join(', ');
|
|
117
|
-
errors.push(
|
|
118
|
-
`Expected at least ${requirements.minimum} successful call${requirements.minimum === 1 ? '' : 's'} to counted tools [${toolNames}], got ${count}`
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
return { valid: errors.length === 0, errors, warnings };
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
export function validateToolGroups(toolCalls: ToolCall[], requirements?: ToolCallRequirements): ValidationResult {
|
|
127
|
-
const errors: string[] = [];
|
|
128
|
-
const warnings: string[] = [];
|
|
129
|
-
|
|
130
|
-
if (requirements?.required_tool_groups) {
|
|
131
|
-
for (const group of requirements.required_tool_groups) {
|
|
132
|
-
if (group.length === 0) continue;
|
|
133
|
-
const normalizedGroup = new Set(group.map(normalizeToolName));
|
|
134
|
-
const satisfied = toolCalls.some(
|
|
135
|
-
tc => normalizedGroup.has(normalizeToolName(tc.name)) && isSuccessfulToolCall(tc)
|
|
136
|
-
);
|
|
137
|
-
if (!satisfied) {
|
|
138
|
-
errors.push(
|
|
139
|
-
`Expected at least one successful call from [${group.join(', ')}], got none`
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
return { valid: errors.length === 0, errors, warnings };
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
export function compose<T>(...validators: Validator<T>[]): Validator<T> {
|
|
149
|
-
return async (result: T): Promise<ValidationResult> => {
|
|
150
|
-
const results = await Promise.all(
|
|
151
|
-
validators.map(v => Promise.resolve(v(result)))
|
|
152
|
-
);
|
|
153
|
-
|
|
154
|
-
return {
|
|
155
|
-
valid: results.every(r => r.valid),
|
|
156
|
-
errors: results.flatMap(r => r.errors),
|
|
157
|
-
warnings: results.flatMap(r => r.warnings)
|
|
158
|
-
};
|
|
159
|
-
};
|
|
160
|
-
}
|
package/tests/loop.test.ts
DELETED
|
@@ -1,355 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { ralph, type ExecutionContext } from '../src/loop.js';
|
|
3
|
-
import { noDelay } from '../src/retry-strategy.js';
|
|
4
|
-
|
|
5
|
-
describe('ralph loop', () => {
|
|
6
|
-
it('returns success on first attempt when valid', async () => {
|
|
7
|
-
const executor = vi.fn().mockResolvedValue('result');
|
|
8
|
-
const validator = vi.fn().mockReturnValue({ valid: true, errors: [], warnings: [] });
|
|
9
|
-
|
|
10
|
-
const result = await ralph({
|
|
11
|
-
executor,
|
|
12
|
-
validator,
|
|
13
|
-
maxAttempts: 3,
|
|
14
|
-
retryStrategy: noDelay()
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
expect(result.status).toBe('success');
|
|
18
|
-
expect(result.attempts).toBe(1);
|
|
19
|
-
expect(executor).toHaveBeenCalledTimes(1);
|
|
20
|
-
if (result.status === 'success') {
|
|
21
|
-
expect(result.result).toBe('result');
|
|
22
|
-
}
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it('retries and succeeds on second attempt', async () => {
|
|
26
|
-
const validator = vi.fn()
|
|
27
|
-
.mockReturnValueOnce({ valid: false, errors: ['fail 1'], warnings: [] })
|
|
28
|
-
.mockReturnValueOnce({ valid: true, errors: [], warnings: [] });
|
|
29
|
-
|
|
30
|
-
const result = await ralph({
|
|
31
|
-
executor: async () => 'result',
|
|
32
|
-
validator,
|
|
33
|
-
maxAttempts: 3,
|
|
34
|
-
retryStrategy: noDelay()
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
expect(result.status).toBe('success');
|
|
38
|
-
expect(result.attempts).toBe(2);
|
|
39
|
-
expect(validator).toHaveBeenCalledTimes(2);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('returns exhausted after max attempts', async () => {
|
|
43
|
-
const validator = vi.fn().mockReturnValue({
|
|
44
|
-
valid: false,
|
|
45
|
-
errors: ['always fails'],
|
|
46
|
-
warnings: []
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
const result = await ralph({
|
|
50
|
-
executor: async () => 'result',
|
|
51
|
-
validator,
|
|
52
|
-
maxAttempts: 3,
|
|
53
|
-
retryStrategy: noDelay()
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
expect(result.status).toBe('exhausted');
|
|
57
|
-
expect(result.attempts).toBe(3);
|
|
58
|
-
if (result.status === 'exhausted') {
|
|
59
|
-
expect(result.failures).toHaveLength(3);
|
|
60
|
-
expect(result.failures).toEqual(['always fails', 'always fails', 'always fails']);
|
|
61
|
-
}
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('passes execution context with previousFailures', async () => {
|
|
65
|
-
const executorCalls: ExecutionContext[] = [];
|
|
66
|
-
|
|
67
|
-
const executor = vi.fn(async (ctx: ExecutionContext) => {
|
|
68
|
-
executorCalls.push({ ...ctx });
|
|
69
|
-
return 'result';
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
const validator = vi.fn()
|
|
73
|
-
.mockReturnValueOnce({ valid: false, errors: ['error 1'], warnings: [] })
|
|
74
|
-
.mockReturnValueOnce({ valid: false, errors: ['error 2'], warnings: [] })
|
|
75
|
-
.mockReturnValueOnce({ valid: true, errors: [], warnings: [] });
|
|
76
|
-
|
|
77
|
-
await ralph({
|
|
78
|
-
executor,
|
|
79
|
-
validator,
|
|
80
|
-
maxAttempts: 5,
|
|
81
|
-
retryStrategy: noDelay()
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
// First call: no previous failures
|
|
85
|
-
expect(executorCalls[0]).toEqual({ attempt: 1, previousFailures: [] });
|
|
86
|
-
|
|
87
|
-
// Second call: has first error
|
|
88
|
-
expect(executorCalls[1]).toEqual({ attempt: 2, previousFailures: ['error 1'] });
|
|
89
|
-
|
|
90
|
-
// Third call: has both errors
|
|
91
|
-
expect(executorCalls[2]).toEqual({ attempt: 3, previousFailures: ['error 1', 'error 2'] });
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it('calls onRetry callback on each retry', async () => {
|
|
95
|
-
const onRetry = vi.fn();
|
|
96
|
-
const validator = vi.fn()
|
|
97
|
-
.mockReturnValueOnce({ valid: false, errors: ['fail'], warnings: [] })
|
|
98
|
-
.mockReturnValueOnce({ valid: true, errors: [], warnings: [] });
|
|
99
|
-
|
|
100
|
-
await ralph({
|
|
101
|
-
executor: async () => 'result',
|
|
102
|
-
validator,
|
|
103
|
-
maxAttempts: 3,
|
|
104
|
-
retryStrategy: noDelay(),
|
|
105
|
-
onRetry
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
expect(onRetry).toHaveBeenCalledTimes(1);
|
|
109
|
-
expect(onRetry.mock.calls[0][0]).toMatchObject({
|
|
110
|
-
attempt: 1,
|
|
111
|
-
result: 'result',
|
|
112
|
-
validation: { valid: false, errors: ['fail'], warnings: [] },
|
|
113
|
-
allFailures: ['fail']
|
|
114
|
-
});
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it('calls onSuccess on successful completion', async () => {
|
|
118
|
-
const onSuccess = vi.fn();
|
|
119
|
-
|
|
120
|
-
await ralph({
|
|
121
|
-
executor: async () => 'result',
|
|
122
|
-
validator: () => ({ valid: true, errors: [], warnings: [] }),
|
|
123
|
-
maxAttempts: 3,
|
|
124
|
-
retryStrategy: noDelay(),
|
|
125
|
-
onSuccess
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
expect(onSuccess).toHaveBeenCalledWith('result', 1);
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
it('calls onExhausted when max attempts reached', async () => {
|
|
132
|
-
const onExhausted = vi.fn();
|
|
133
|
-
|
|
134
|
-
await ralph({
|
|
135
|
-
executor: async () => 'result',
|
|
136
|
-
validator: () => ({ valid: false, errors: ['fail'], warnings: [] }),
|
|
137
|
-
maxAttempts: 2,
|
|
138
|
-
retryStrategy: noDelay(),
|
|
139
|
-
onExhausted
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
expect(onExhausted).toHaveBeenCalledWith('result', ['fail', 'fail']);
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it('does not call onRetry on last failed attempt', async () => {
|
|
146
|
-
const onRetry = vi.fn();
|
|
147
|
-
|
|
148
|
-
await ralph({
|
|
149
|
-
executor: async () => 'result',
|
|
150
|
-
validator: () => ({ valid: false, errors: ['fail'], warnings: [] }),
|
|
151
|
-
maxAttempts: 2,
|
|
152
|
-
retryStrategy: noDelay(),
|
|
153
|
-
onRetry
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
// Should only retry once (between attempt 1 and 2)
|
|
157
|
-
expect(onRetry).toHaveBeenCalledTimes(1);
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
it('accumulates errors from multiple validation failures', async () => {
|
|
161
|
-
const validator = vi.fn()
|
|
162
|
-
.mockReturnValueOnce({ valid: false, errors: ['error A', 'error B'], warnings: [] })
|
|
163
|
-
.mockReturnValueOnce({ valid: false, errors: ['error C'], warnings: [] });
|
|
164
|
-
|
|
165
|
-
const result = await ralph({
|
|
166
|
-
executor: async () => 'result',
|
|
167
|
-
validator,
|
|
168
|
-
maxAttempts: 2,
|
|
169
|
-
retryStrategy: noDelay()
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
if (result.status === 'exhausted') {
|
|
173
|
-
expect(result.failures).toEqual(['error A', 'error B', 'error C']);
|
|
174
|
-
}
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
it('supports async validators', async () => {
|
|
178
|
-
const validator = vi.fn(async () => {
|
|
179
|
-
await new Promise(resolve => setTimeout(resolve, 1));
|
|
180
|
-
return { valid: true, errors: [], warnings: [] };
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
const result = await ralph({
|
|
184
|
-
executor: async () => 'result',
|
|
185
|
-
validator,
|
|
186
|
-
maxAttempts: 3,
|
|
187
|
-
retryStrategy: noDelay()
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
expect(result.status).toBe('success');
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
it('supports async callbacks', async () => {
|
|
194
|
-
const onRetry = vi.fn(async () => {
|
|
195
|
-
await new Promise(resolve => setTimeout(resolve, 1));
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
const validator = vi.fn()
|
|
199
|
-
.mockReturnValueOnce({ valid: false, errors: ['fail'], warnings: [] })
|
|
200
|
-
.mockReturnValueOnce({ valid: true, errors: [], warnings: [] });
|
|
201
|
-
|
|
202
|
-
await ralph({
|
|
203
|
-
executor: async () => 'result',
|
|
204
|
-
validator,
|
|
205
|
-
maxAttempts: 3,
|
|
206
|
-
retryStrategy: noDelay(),
|
|
207
|
-
onRetry
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
expect(onRetry).toHaveBeenCalled();
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
it('returns cancelled immediately when signal is already aborted', async () => {
|
|
214
|
-
const controller = new AbortController();
|
|
215
|
-
controller.abort();
|
|
216
|
-
|
|
217
|
-
const executor = vi.fn().mockResolvedValue('result');
|
|
218
|
-
const validator = vi.fn().mockReturnValue({ valid: true, errors: [], warnings: [] });
|
|
219
|
-
|
|
220
|
-
const result = await ralph({
|
|
221
|
-
executor,
|
|
222
|
-
validator,
|
|
223
|
-
maxAttempts: 3,
|
|
224
|
-
retryStrategy: noDelay(),
|
|
225
|
-
signal: controller.signal,
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
expect(result.status).toBe('cancelled');
|
|
229
|
-
expect(executor).not.toHaveBeenCalled();
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
it('returns cancelled when signal aborts between attempts', async () => {
|
|
233
|
-
const controller = new AbortController();
|
|
234
|
-
|
|
235
|
-
const executor = vi.fn().mockResolvedValue('result');
|
|
236
|
-
const validator = vi.fn().mockReturnValueOnce({ valid: false, errors: ['fail'], warnings: [] });
|
|
237
|
-
|
|
238
|
-
// Abort after first validation
|
|
239
|
-
validator.mockImplementationOnce(() => {
|
|
240
|
-
controller.abort();
|
|
241
|
-
return { valid: false, errors: ['fail 2'], warnings: [] };
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
const result = await ralph({
|
|
245
|
-
executor,
|
|
246
|
-
validator,
|
|
247
|
-
maxAttempts: 5,
|
|
248
|
-
retryStrategy: noDelay(),
|
|
249
|
-
signal: controller.signal,
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
expect(result.status).toBe('cancelled');
|
|
253
|
-
expect(executor).toHaveBeenCalledTimes(2);
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
it('returns cancelled when executor throws AbortError', async () => {
|
|
257
|
-
const controller = new AbortController();
|
|
258
|
-
|
|
259
|
-
const executor = vi.fn().mockImplementation(async () => {
|
|
260
|
-
controller.abort();
|
|
261
|
-
const err = new DOMException('The operation was aborted', 'AbortError');
|
|
262
|
-
throw err;
|
|
263
|
-
});
|
|
264
|
-
const validator = vi.fn();
|
|
265
|
-
|
|
266
|
-
const result = await ralph({
|
|
267
|
-
executor,
|
|
268
|
-
validator,
|
|
269
|
-
maxAttempts: 3,
|
|
270
|
-
retryStrategy: noDelay(),
|
|
271
|
-
signal: controller.signal,
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
expect(result.status).toBe('cancelled');
|
|
275
|
-
expect(validator).not.toHaveBeenCalled();
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
it('cancellation resolves pending retry delay immediately', async () => {
|
|
279
|
-
const controller = new AbortController();
|
|
280
|
-
|
|
281
|
-
let attempt = 0;
|
|
282
|
-
const executor = vi.fn().mockImplementation(async () => {
|
|
283
|
-
attempt++;
|
|
284
|
-
if (attempt === 1) {
|
|
285
|
-
// After first attempt, schedule abort in 5ms (well before any real delay)
|
|
286
|
-
setTimeout(() => controller.abort(), 5);
|
|
287
|
-
}
|
|
288
|
-
return 'result';
|
|
289
|
-
});
|
|
290
|
-
const validator = vi.fn().mockReturnValue({ valid: false, errors: ['fail'], warnings: [] });
|
|
291
|
-
|
|
292
|
-
const start = Date.now();
|
|
293
|
-
const result = await ralph({
|
|
294
|
-
executor,
|
|
295
|
-
validator,
|
|
296
|
-
maxAttempts: 5,
|
|
297
|
-
retryStrategy: { getDelay: () => 60_000 }, // 60 second delay — should NOT wait
|
|
298
|
-
signal: controller.signal,
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
const elapsed = Date.now() - start;
|
|
302
|
-
expect(result.status).toBe('cancelled');
|
|
303
|
-
expect(elapsed).toBeLessThan(5000); // Way less than 60s
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
it('works with object result type (generic T)', async () => {
|
|
307
|
-
type Output = { value: number; label: string };
|
|
308
|
-
|
|
309
|
-
const result = await ralph<Output>({
|
|
310
|
-
executor: async () => ({ value: 42, label: 'ok' }),
|
|
311
|
-
validator: () => ({ valid: true, errors: [], warnings: [] }),
|
|
312
|
-
maxAttempts: 3,
|
|
313
|
-
retryStrategy: noDelay()
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
expect(result.status).toBe('success');
|
|
317
|
-
if (result.status === 'success') {
|
|
318
|
-
expect(result.result.value).toBe(42);
|
|
319
|
-
expect(result.result.label).toBe('ok');
|
|
320
|
-
}
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
it('exhausts immediately with maxAttempts=1 on first failure', async () => {
|
|
324
|
-
const validator = vi.fn().mockReturnValue({
|
|
325
|
-
valid: false,
|
|
326
|
-
errors: ['always fails'],
|
|
327
|
-
warnings: []
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
const result = await ralph({
|
|
331
|
-
executor: async () => 'result',
|
|
332
|
-
validator,
|
|
333
|
-
maxAttempts: 1,
|
|
334
|
-
retryStrategy: noDelay()
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
expect(result.status).toBe('exhausted');
|
|
338
|
-
expect(result.attempts).toBe(1);
|
|
339
|
-
if (result.status === 'exhausted') {
|
|
340
|
-
expect(result.failures).toEqual(['always fails']);
|
|
341
|
-
}
|
|
342
|
-
expect(validator).toHaveBeenCalledTimes(1);
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
it('propagates executor exceptions that are not abort errors', async () => {
|
|
346
|
-
const error = new Error('unexpected failure');
|
|
347
|
-
|
|
348
|
-
await expect(ralph({
|
|
349
|
-
executor: async () => { throw error; },
|
|
350
|
-
validator: () => ({ valid: true, errors: [], warnings: [] }),
|
|
351
|
-
maxAttempts: 3,
|
|
352
|
-
retryStrategy: noDelay()
|
|
353
|
-
})).rejects.toThrow('unexpected failure');
|
|
354
|
-
});
|
|
355
|
-
});
|