@sooneocean/agw 1.4.0 → 1.5.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/package.json +1 -1
- package/src/daemon/routes/templates.ts +60 -0
- package/src/daemon/routes/webhooks.ts +18 -0
- package/src/daemon/server.ts +12 -3
- package/src/daemon/services/condition-engine.ts +75 -0
- package/src/daemon/services/template-engine.ts +145 -0
- package/src/daemon/services/webhook-manager.ts +98 -0
package/package.json
CHANGED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import type { TemplateEngine, TaskTemplate, InstantiateRequest } from '../services/template-engine.js';
|
|
3
|
+
import type { TaskExecutor } from '../services/task-executor.js';
|
|
4
|
+
import type { LlmRouter } from '../../router/llm-router.js';
|
|
5
|
+
import type { AgentManager } from '../services/agent-manager.js';
|
|
6
|
+
|
|
7
|
+
export function registerTemplateRoutes(
|
|
8
|
+
app: FastifyInstance,
|
|
9
|
+
templateEngine: TemplateEngine,
|
|
10
|
+
executor: TaskExecutor,
|
|
11
|
+
router: LlmRouter,
|
|
12
|
+
agentManager: AgentManager,
|
|
13
|
+
): void {
|
|
14
|
+
// List templates
|
|
15
|
+
app.get<{ Querystring: { tag?: string } }>('/templates', async (request) => {
|
|
16
|
+
return templateEngine.list(request.query.tag);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// Get template
|
|
20
|
+
app.get<{ Params: { id: string } }>('/templates/:id', async (request, reply) => {
|
|
21
|
+
const t = templateEngine.get(request.params.id);
|
|
22
|
+
if (!t) return reply.status(404).send({ error: 'Template not found' });
|
|
23
|
+
return t;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Register custom template
|
|
27
|
+
app.post<{ Body: TaskTemplate }>('/templates', async (request, reply) => {
|
|
28
|
+
templateEngine.register(request.body);
|
|
29
|
+
return reply.status(201).send(request.body);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Delete template
|
|
33
|
+
app.delete<{ Params: { id: string } }>('/templates/:id', async (request, reply) => {
|
|
34
|
+
const deleted = templateEngine.unregister(request.params.id);
|
|
35
|
+
if (!deleted) return reply.status(404).send({ error: 'Template not found' });
|
|
36
|
+
return { deleted: true };
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Instantiate and execute a template
|
|
40
|
+
app.post<{ Body: InstantiateRequest }>('/templates/execute', async (request, reply) => {
|
|
41
|
+
try {
|
|
42
|
+
const { prompt, agent, priority } = templateEngine.instantiate(request.body);
|
|
43
|
+
const availableAgents = agentManager.getAvailableAgents();
|
|
44
|
+
|
|
45
|
+
const task = await executor.execute(
|
|
46
|
+
{
|
|
47
|
+
prompt,
|
|
48
|
+
preferredAgent: agent ?? request.body.overrides?.agent,
|
|
49
|
+
workingDirectory: request.body.overrides?.workingDirectory,
|
|
50
|
+
priority,
|
|
51
|
+
},
|
|
52
|
+
async (p) => router.route(p, availableAgents, agent),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
return reply.status(201).send(task);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
return reply.status(400).send({ error: (err as Error).message });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import type { WebhookManager, WebhookConfig } from '../services/webhook-manager.js';
|
|
3
|
+
|
|
4
|
+
export function registerWebhookRoutes(app: FastifyInstance, webhookManager: WebhookManager): void {
|
|
5
|
+
app.get('/webhooks', async () => {
|
|
6
|
+
return webhookManager.getWebhooks();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
app.post<{ Body: WebhookConfig }>('/webhooks', async (request, reply) => {
|
|
10
|
+
webhookManager.addWebhook(request.body);
|
|
11
|
+
return reply.status(201).send({ registered: true, url: request.body.url });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
app.delete<{ Body: { url: string } }>('/webhooks', async (request) => {
|
|
15
|
+
webhookManager.removeWebhook(request.body.url);
|
|
16
|
+
return { removed: true };
|
|
17
|
+
});
|
|
18
|
+
}
|
package/src/daemon/server.ts
CHANGED
|
@@ -10,22 +10,26 @@ import { AuditRepo } from '../store/audit-repo.js';
|
|
|
10
10
|
import { CostRepo } from '../store/cost-repo.js';
|
|
11
11
|
import { WorkflowRepo } from '../store/workflow-repo.js';
|
|
12
12
|
import { ComboRepo } from '../store/combo-repo.js';
|
|
13
|
+
import { MemoryRepo } from '../store/memory-repo.js';
|
|
13
14
|
import { AgentManager } from './services/agent-manager.js';
|
|
14
15
|
import { TaskExecutor } from './services/task-executor.js';
|
|
15
16
|
import { WorkflowExecutor } from './services/workflow-executor.js';
|
|
16
17
|
import { ComboExecutor } from './services/combo-executor.js';
|
|
17
18
|
import { LlmRouter } from '../router/llm-router.js';
|
|
19
|
+
import { MetricsCollector } from './services/metrics.js';
|
|
20
|
+
import { CircuitBreakerRegistry } from './services/circuit-breaker.js';
|
|
21
|
+
import { TemplateEngine } from './services/template-engine.js';
|
|
22
|
+
import { WebhookManager } from './services/webhook-manager.js';
|
|
18
23
|
import { registerAuthMiddleware } from './middleware/auth.js';
|
|
19
24
|
import { registerAgentRoutes } from './routes/agents.js';
|
|
20
25
|
import { registerTaskRoutes } from './routes/tasks.js';
|
|
21
26
|
import { registerWorkflowRoutes } from './routes/workflows.js';
|
|
22
27
|
import { registerCostRoutes } from './routes/costs.js';
|
|
23
28
|
import { registerComboRoutes } from './routes/combos.js';
|
|
24
|
-
import { MemoryRepo } from '../store/memory-repo.js';
|
|
25
29
|
import { registerMemoryRoutes } from './routes/memory.js';
|
|
26
30
|
import { registerHealthRoutes } from './routes/health.js';
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
31
|
+
import { registerTemplateRoutes } from './routes/templates.js';
|
|
32
|
+
import { registerWebhookRoutes } from './routes/webhooks.js';
|
|
29
33
|
|
|
30
34
|
interface ServerOptions {
|
|
31
35
|
dbPath?: string;
|
|
@@ -58,6 +62,9 @@ export async function buildServer(options: ServerOptions = {}): Promise<FastifyI
|
|
|
58
62
|
const comboExecutor = new ComboExecutor(comboRepo, auditRepo, executor, agentManager);
|
|
59
63
|
const metrics = new MetricsCollector();
|
|
60
64
|
const cbRegistry = new CircuitBreakerRegistry();
|
|
65
|
+
const templateEngine = new TemplateEngine();
|
|
66
|
+
templateEngine.seedDefaults();
|
|
67
|
+
const webhookManager = new WebhookManager();
|
|
61
68
|
|
|
62
69
|
const app = Fastify({
|
|
63
70
|
logger: false,
|
|
@@ -73,6 +80,8 @@ export async function buildServer(options: ServerOptions = {}): Promise<FastifyI
|
|
|
73
80
|
registerComboRoutes(app, comboExecutor, config);
|
|
74
81
|
registerMemoryRoutes(app, memoryRepo);
|
|
75
82
|
registerHealthRoutes(app, metrics, agentManager, cbRegistry, taskRepo, costRepo, config);
|
|
83
|
+
registerTemplateRoutes(app, templateEngine, executor, router, agentManager);
|
|
84
|
+
registerWebhookRoutes(app, webhookManager);
|
|
76
85
|
|
|
77
86
|
app.register(import('./routes/ui.js'));
|
|
78
87
|
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Condition Engine — evaluates conditions on agent output to control combo flow.
|
|
3
|
+
*
|
|
4
|
+
* Conditions:
|
|
5
|
+
* contains:"APPROVED" — output contains string
|
|
6
|
+
* !contains:"ERROR" — output does NOT contain string
|
|
7
|
+
* exitCode:0 — task exit code equals value
|
|
8
|
+
* length>100 — output length greater than threshold
|
|
9
|
+
* matches:/pattern/i — output matches regex
|
|
10
|
+
* always — always true (default)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface Condition {
|
|
14
|
+
type: 'contains' | 'not-contains' | 'exitCode' | 'length-gt' | 'length-lt' | 'matches' | 'always';
|
|
15
|
+
value?: string | number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ConditionalBranch {
|
|
19
|
+
condition: Condition;
|
|
20
|
+
thenStep: number; // step index to jump to
|
|
21
|
+
elseStep?: number; // step index if condition is false
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function parseCondition(expr: string): Condition {
|
|
25
|
+
if (expr === 'always') return { type: 'always' };
|
|
26
|
+
|
|
27
|
+
const containsMatch = expr.match(/^contains:"(.+)"$/);
|
|
28
|
+
if (containsMatch) return { type: 'contains', value: containsMatch[1] };
|
|
29
|
+
|
|
30
|
+
const notContainsMatch = expr.match(/^!contains:"(.+)"$/);
|
|
31
|
+
if (notContainsMatch) return { type: 'not-contains', value: notContainsMatch[1] };
|
|
32
|
+
|
|
33
|
+
const exitCodeMatch = expr.match(/^exitCode:(\d+)$/);
|
|
34
|
+
if (exitCodeMatch) return { type: 'exitCode', value: parseInt(exitCodeMatch[1], 10) };
|
|
35
|
+
|
|
36
|
+
const lengthGtMatch = expr.match(/^length>(\d+)$/);
|
|
37
|
+
if (lengthGtMatch) return { type: 'length-gt', value: parseInt(lengthGtMatch[1], 10) };
|
|
38
|
+
|
|
39
|
+
const lengthLtMatch = expr.match(/^length<(\d+)$/);
|
|
40
|
+
if (lengthLtMatch) return { type: 'length-lt', value: parseInt(lengthLtMatch[1], 10) };
|
|
41
|
+
|
|
42
|
+
const matchesMatch = expr.match(/^matches:\/(.+)\/([gimsuy]*)$/);
|
|
43
|
+
if (matchesMatch) return { type: 'matches', value: matchesMatch[1] + (matchesMatch[2] ? `/${matchesMatch[2]}` : '') };
|
|
44
|
+
|
|
45
|
+
throw new Error(`Invalid condition: ${expr}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function evaluateCondition(condition: Condition, output: string, exitCode: number = 0): boolean {
|
|
49
|
+
switch (condition.type) {
|
|
50
|
+
case 'always':
|
|
51
|
+
return true;
|
|
52
|
+
|
|
53
|
+
case 'contains':
|
|
54
|
+
return output.includes(String(condition.value));
|
|
55
|
+
|
|
56
|
+
case 'not-contains':
|
|
57
|
+
return !output.includes(String(condition.value));
|
|
58
|
+
|
|
59
|
+
case 'exitCode':
|
|
60
|
+
return exitCode === condition.value;
|
|
61
|
+
|
|
62
|
+
case 'length-gt':
|
|
63
|
+
return output.length > (condition.value as number);
|
|
64
|
+
|
|
65
|
+
case 'length-lt':
|
|
66
|
+
return output.length < (condition.value as number);
|
|
67
|
+
|
|
68
|
+
case 'matches': {
|
|
69
|
+
const parts = String(condition.value).split('/');
|
|
70
|
+
const pattern = parts[0];
|
|
71
|
+
const flags = parts[1] ?? '';
|
|
72
|
+
return new RegExp(pattern, flags).test(output);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task Template Engine — reusable parameterized task definitions.
|
|
3
|
+
*
|
|
4
|
+
* Templates use {{param.name}} syntax for user-supplied values.
|
|
5
|
+
* Templates can be stored, listed, and instantiated.
|
|
6
|
+
*
|
|
7
|
+
* Example:
|
|
8
|
+
* { id: "code-review", prompt: "Review {{param.file}} for {{param.criteria}}", agent: "claude" }
|
|
9
|
+
* Instantiate with: { templateId: "code-review", params: { file: "auth.ts", criteria: "security" } }
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface TaskTemplate {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
description: string;
|
|
16
|
+
prompt: string; // template with {{param.name}} placeholders
|
|
17
|
+
agent?: string; // default agent
|
|
18
|
+
priority?: number;
|
|
19
|
+
params: TemplateParam[]; // parameter definitions
|
|
20
|
+
tags?: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TemplateParam {
|
|
24
|
+
name: string;
|
|
25
|
+
description: string;
|
|
26
|
+
required: boolean;
|
|
27
|
+
default?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface InstantiateRequest {
|
|
31
|
+
templateId: string;
|
|
32
|
+
params: Record<string, string>;
|
|
33
|
+
overrides?: {
|
|
34
|
+
agent?: string;
|
|
35
|
+
priority?: number;
|
|
36
|
+
workingDirectory?: string;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class TemplateEngine {
|
|
41
|
+
private templates = new Map<string, TaskTemplate>();
|
|
42
|
+
|
|
43
|
+
register(template: TaskTemplate): void {
|
|
44
|
+
this.templates.set(template.id, template);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
unregister(id: string): boolean {
|
|
48
|
+
return this.templates.delete(id);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get(id: string): TaskTemplate | undefined {
|
|
52
|
+
return this.templates.get(id);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
list(tag?: string): TaskTemplate[] {
|
|
56
|
+
const all = Array.from(this.templates.values());
|
|
57
|
+
if (tag) return all.filter(t => t.tags?.includes(tag));
|
|
58
|
+
return all;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
instantiate(request: InstantiateRequest): { prompt: string; agent?: string; priority?: number } {
|
|
62
|
+
const template = this.templates.get(request.templateId);
|
|
63
|
+
if (!template) throw new Error(`Template not found: ${request.templateId}`);
|
|
64
|
+
|
|
65
|
+
// Validate required params
|
|
66
|
+
for (const param of template.params) {
|
|
67
|
+
if (param.required && !(request.params[param.name] ?? param.default)) {
|
|
68
|
+
throw new Error(`Missing required parameter: ${param.name}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Interpolate {{param.name}} with provided values
|
|
73
|
+
let prompt = template.prompt;
|
|
74
|
+
for (const param of template.params) {
|
|
75
|
+
const value = request.params[param.name] ?? param.default ?? '';
|
|
76
|
+
prompt = prompt.replace(new RegExp(`\\{\\{param\\.${param.name}\\}\\}`, 'g'), value);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
prompt,
|
|
81
|
+
agent: request.overrides?.agent ?? template.agent,
|
|
82
|
+
priority: request.overrides?.priority ?? template.priority,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Seed built-in templates */
|
|
87
|
+
seedDefaults(): void {
|
|
88
|
+
this.register({
|
|
89
|
+
id: 'code-review',
|
|
90
|
+
name: 'Code Review',
|
|
91
|
+
description: 'Review a file for quality, security, and correctness',
|
|
92
|
+
prompt: 'Review {{param.file}} for {{param.criteria}}. Focus on actionable issues.',
|
|
93
|
+
agent: 'claude',
|
|
94
|
+
priority: 4,
|
|
95
|
+
params: [
|
|
96
|
+
{ name: 'file', description: 'File path to review', required: true },
|
|
97
|
+
{ name: 'criteria', description: 'Review criteria', required: false, default: 'quality, security, correctness' },
|
|
98
|
+
],
|
|
99
|
+
tags: ['review', 'quality'],
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
this.register({
|
|
103
|
+
id: 'implement-feature',
|
|
104
|
+
name: 'Implement Feature',
|
|
105
|
+
description: 'Implement a new feature with tests',
|
|
106
|
+
prompt: 'Implement the following feature: {{param.description}}\n\nRequirements:\n{{param.requirements}}\n\nInclude unit tests.',
|
|
107
|
+
agent: 'codex',
|
|
108
|
+
priority: 3,
|
|
109
|
+
params: [
|
|
110
|
+
{ name: 'description', description: 'Feature description', required: true },
|
|
111
|
+
{ name: 'requirements', description: 'Detailed requirements', required: false, default: 'Follow existing patterns' },
|
|
112
|
+
],
|
|
113
|
+
tags: ['implementation', 'feature'],
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
this.register({
|
|
117
|
+
id: 'explain-code',
|
|
118
|
+
name: 'Explain Code',
|
|
119
|
+
description: 'Explain what a piece of code does',
|
|
120
|
+
prompt: 'Explain this code in {{param.detail_level}} detail:\n\n{{param.code}}',
|
|
121
|
+
agent: 'claude',
|
|
122
|
+
priority: 2,
|
|
123
|
+
params: [
|
|
124
|
+
{ name: 'code', description: 'Code to explain', required: true },
|
|
125
|
+
{ name: 'detail_level', description: 'Detail level (brief/moderate/deep)', required: false, default: 'moderate' },
|
|
126
|
+
],
|
|
127
|
+
tags: ['explanation', 'documentation'],
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
this.register({
|
|
131
|
+
id: 'debug-issue',
|
|
132
|
+
name: 'Debug Issue',
|
|
133
|
+
description: 'Diagnose and fix a bug',
|
|
134
|
+
prompt: 'Debug this issue: {{param.issue}}\n\nError message: {{param.error}}\n\nRelevant file: {{param.file}}',
|
|
135
|
+
agent: 'claude',
|
|
136
|
+
priority: 5,
|
|
137
|
+
params: [
|
|
138
|
+
{ name: 'issue', description: 'Description of the issue', required: true },
|
|
139
|
+
{ name: 'error', description: 'Error message or stack trace', required: false, default: 'N/A' },
|
|
140
|
+
{ name: 'file', description: 'Relevant file path', required: false, default: 'N/A' },
|
|
141
|
+
],
|
|
142
|
+
tags: ['debugging', 'fix'],
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook Manager — sends HTTP POST notifications on AGW events.
|
|
3
|
+
*
|
|
4
|
+
* Config example:
|
|
5
|
+
* webhooks: [
|
|
6
|
+
* { url: "https://slack.com/...", events: ["task.completed", "combo.completed"], secret: "hmac-key" }
|
|
7
|
+
* ]
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createHmac } from 'node:crypto';
|
|
11
|
+
|
|
12
|
+
export interface WebhookConfig {
|
|
13
|
+
url: string;
|
|
14
|
+
events: string[]; // event types to subscribe to, or ["*"] for all
|
|
15
|
+
secret?: string; // HMAC-SHA256 signing key
|
|
16
|
+
headers?: Record<string, string>;
|
|
17
|
+
retries?: number;
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface WebhookPayload {
|
|
22
|
+
event: string;
|
|
23
|
+
timestamp: string;
|
|
24
|
+
data: Record<string, unknown>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class WebhookManager {
|
|
28
|
+
private hooks: WebhookConfig[] = [];
|
|
29
|
+
|
|
30
|
+
addWebhook(config: WebhookConfig): void {
|
|
31
|
+
this.hooks.push(config);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
removeWebhook(url: string): void {
|
|
35
|
+
this.hooks = this.hooks.filter(h => h.url !== url);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
getWebhooks(): WebhookConfig[] {
|
|
39
|
+
return this.hooks.map(h => ({ ...h, secret: h.secret ? '***' : undefined }));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async emit(event: string, data: Record<string, unknown>): Promise<void> {
|
|
43
|
+
const payload: WebhookPayload = {
|
|
44
|
+
event,
|
|
45
|
+
timestamp: new Date().toISOString(),
|
|
46
|
+
data,
|
|
47
|
+
};
|
|
48
|
+
const body = JSON.stringify(payload);
|
|
49
|
+
|
|
50
|
+
const matching = this.hooks.filter(h =>
|
|
51
|
+
h.events.includes('*') || h.events.includes(event)
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const deliveries = matching.map(hook => this.deliver(hook, body));
|
|
55
|
+
await Promise.allSettled(deliveries);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private async deliver(hook: WebhookConfig, body: string): Promise<void> {
|
|
59
|
+
const maxRetries = hook.retries ?? 2;
|
|
60
|
+
const timeout = hook.timeoutMs ?? 10_000;
|
|
61
|
+
|
|
62
|
+
const headers: Record<string, string> = {
|
|
63
|
+
'Content-Type': 'application/json',
|
|
64
|
+
'User-Agent': 'AGW-Webhook/1.5',
|
|
65
|
+
...hook.headers,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
if (hook.secret) {
|
|
69
|
+
const signature = createHmac('sha256', hook.secret).update(body).digest('hex');
|
|
70
|
+
headers['X-AGW-Signature'] = `sha256=${signature}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
74
|
+
try {
|
|
75
|
+
const controller = new AbortController();
|
|
76
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
77
|
+
|
|
78
|
+
const res = await fetch(hook.url, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers,
|
|
81
|
+
body,
|
|
82
|
+
signal: controller.signal,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
|
|
87
|
+
if (res.ok) return;
|
|
88
|
+
if (res.status >= 400 && res.status < 500) return; // Don't retry client errors
|
|
89
|
+
} catch {
|
|
90
|
+
// Retry on network errors
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (attempt < maxRetries) {
|
|
94
|
+
await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|