@wundam/orchex 1.0.0-rc.2 → 1.0.0-rc.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -17
- package/dist/cloud-executor.d.ts +71 -0
- package/dist/cloud-executor.js +335 -0
- package/dist/cloud-sync.d.ts +8 -0
- package/dist/cloud-sync.js +52 -0
- package/dist/config.d.ts +30 -4
- package/dist/config.js +61 -2
- package/dist/context-builder.d.ts +2 -0
- package/dist/context-builder.js +11 -3
- package/dist/cost.js +1 -1
- package/dist/entitlements/jwt.d.ts +7 -0
- package/dist/entitlements/jwt.js +78 -0
- package/dist/entitlements/resolve.d.ts +17 -0
- package/dist/entitlements/resolve.js +49 -0
- package/dist/entitlements/types.d.ts +21 -0
- package/dist/entitlements/types.js +4 -0
- package/dist/executors/base.d.ts +1 -1
- package/dist/executors/bedrock-executor.d.ts +39 -0
- package/dist/executors/bedrock-executor.js +197 -0
- package/dist/executors/index.d.ts +1 -0
- package/dist/executors/index.js +24 -1
- package/dist/index.js +440 -23
- package/dist/intelligence/index.d.ts +44 -0
- package/dist/intelligence/index.js +160 -0
- package/dist/key-cache.d.ts +31 -0
- package/dist/key-cache.js +84 -0
- package/dist/login-helpers.d.ts +25 -0
- package/dist/login-helpers.js +54 -0
- package/dist/manifest.js +18 -1
- package/dist/mcp-instructions.d.ts +1 -0
- package/dist/mcp-instructions.js +83 -0
- package/dist/mcp-resources.d.ts +8 -0
- package/dist/mcp-resources.js +414 -0
- package/dist/orchestrator.d.ts +14 -0
- package/dist/orchestrator.js +147 -29
- package/dist/setup/ide-registry.d.ts +13 -0
- package/dist/setup/ide-registry.js +51 -0
- package/dist/setup/index.d.ts +1 -0
- package/dist/setup/index.js +111 -0
- package/dist/tier-gating.js +0 -16
- package/dist/tiers.d.ts +35 -5
- package/dist/tiers.js +39 -3
- package/dist/tools.d.ts +6 -1
- package/dist/tools.js +832 -95
- package/dist/types.d.ts +71 -60
- package/dist/types.js +3 -0
- package/package.json +37 -5
- package/src/entitlements/public-key.pem +9 -0
- package/dist/intelligence/anti-pattern-detector.d.ts +0 -117
- package/dist/intelligence/anti-pattern-detector.js +0 -327
- package/dist/intelligence/budget-enforcer.d.ts +0 -119
- package/dist/intelligence/budget-enforcer.js +0 -226
- package/dist/intelligence/context-optimizer.d.ts +0 -111
- package/dist/intelligence/context-optimizer.js +0 -282
- package/dist/intelligence/cost-tracker.d.ts +0 -114
- package/dist/intelligence/cost-tracker.js +0 -183
- package/dist/intelligence/deliverable-extractor.d.ts +0 -134
- package/dist/intelligence/deliverable-extractor.js +0 -909
- package/dist/intelligence/dependency-inferrer.d.ts +0 -87
- package/dist/intelligence/dependency-inferrer.js +0 -403
- package/dist/intelligence/diagnostics.d.ts +0 -33
- package/dist/intelligence/diagnostics.js +0 -64
- package/dist/intelligence/error-analyzer.d.ts +0 -7
- package/dist/intelligence/error-analyzer.js +0 -76
- package/dist/intelligence/file-chunker.d.ts +0 -15
- package/dist/intelligence/file-chunker.js +0 -64
- package/dist/intelligence/fix-stream-manager.d.ts +0 -59
- package/dist/intelligence/fix-stream-manager.js +0 -212
- package/dist/intelligence/heuristics.d.ts +0 -23
- package/dist/intelligence/heuristics.js +0 -124
- package/dist/intelligence/learning-engine.d.ts +0 -157
- package/dist/intelligence/learning-engine.js +0 -433
- package/dist/intelligence/learning-feedback.d.ts +0 -96
- package/dist/intelligence/learning-feedback.js +0 -202
- package/dist/intelligence/pattern-analyzer.d.ts +0 -35
- package/dist/intelligence/pattern-analyzer.js +0 -189
- package/dist/intelligence/plan-parser.d.ts +0 -124
- package/dist/intelligence/plan-parser.js +0 -498
- package/dist/intelligence/planner.d.ts +0 -29
- package/dist/intelligence/planner.js +0 -86
- package/dist/intelligence/self-healer.d.ts +0 -16
- package/dist/intelligence/self-healer.js +0 -84
- package/dist/intelligence/slicing-metrics.d.ts +0 -62
- package/dist/intelligence/slicing-metrics.js +0 -202
- package/dist/intelligence/slicing-templates.d.ts +0 -81
- package/dist/intelligence/slicing-templates.js +0 -420
- package/dist/intelligence/split-suggester.d.ts +0 -69
- package/dist/intelligence/split-suggester.js +0 -176
- package/dist/intelligence/stream-generator.d.ts +0 -90
- package/dist/intelligence/stream-generator.js +0 -452
- package/dist/telemetry/telemetry-types.d.ts +0 -85
- package/dist/telemetry/telemetry-types.js +0 -1
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# orchex
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**Describe what you want. Orchex plans, parallelizes, and executes — safely.**
|
|
4
4
|
|
|
5
|
-
The orchestration engine inside your AI coding assistant.
|
|
5
|
+
The orchestration engine inside your AI coding assistant. Describe your intent, orchex auto-generates a plan, splits it into parallel streams with file ownership enforcement, self-healing failures, and multi-LLM routing. Your AI assistant is the driver. Orchex is the engine.
|
|
6
6
|
|
|
7
7
|
## Why Orchex
|
|
8
8
|
|
|
@@ -10,20 +10,22 @@ Your AI assistant does tasks one at a time. Orchex makes it do 10 at once — sa
|
|
|
10
10
|
|
|
11
11
|
- **Parallel Execution** — Multiple streams run simultaneously in dependency-aware waves. 5-10x faster than serial prompting.
|
|
12
12
|
- **Ownership Enforcement** — Each stream can only modify files in its `owns` array. No two agents touch the same file. Zero conflicts.
|
|
13
|
-
- **`orchex
|
|
13
|
+
- **`orchex run`** — Describe what you want, get parallel execution. Auto-generates plans, previews waves, executes with ownership enforcement.
|
|
14
|
+
- **`orchex learn`** — The advanced path. Paste a markdown plan, get executable parallel streams with dependency inference and anti-pattern detection.
|
|
14
15
|
- **Self-Healing** — Categorized error analysis with targeted fix streams. Not blind retry.
|
|
15
|
-
- **Multi-LLM** — OpenAI, Gemini, Claude, DeepSeek, Ollama. Route different orchestrations to different providers.
|
|
16
|
+
- **Multi-LLM** — OpenAI, Gemini, Claude, DeepSeek, Ollama, AWS Bedrock. Route different orchestrations to different providers.
|
|
16
17
|
- **BYOK** — Bring your own API key from any supported provider. You control costs.
|
|
17
18
|
|
|
18
19
|
## Prerequisites
|
|
19
20
|
|
|
20
21
|
- [Node.js](https://nodejs.org/) >= 18
|
|
21
|
-
- LLM API key
|
|
22
|
+
- LLM API key — set via environment variable **or** store on the [dashboard](https://orchex.dev/dashboard/keys) and sync with `orchex login`:
|
|
22
23
|
- `ANTHROPIC_API_KEY` for Anthropic Claude
|
|
23
|
-
- `OPENAI_API_KEY` for OpenAI (GPT-4.1,
|
|
24
|
+
- `OPENAI_API_KEY` for OpenAI (GPT-4.1, o1, o3)
|
|
24
25
|
- `GEMINI_API_KEY` for Google Gemini
|
|
25
|
-
- `DEEPSEEK_API_KEY` for DeepSeek (V3, Coder,
|
|
26
|
-
-
|
|
26
|
+
- `DEEPSEEK_API_KEY` for DeepSeek (V3, Coder, R1)
|
|
27
|
+
- Configure Ollama for local models
|
|
28
|
+
- `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` for AWS Bedrock
|
|
27
29
|
|
|
28
30
|
## Install
|
|
29
31
|
|
|
@@ -45,11 +47,11 @@ Connect to orchex cloud for managed execution:
|
|
|
45
47
|
orchex login
|
|
46
48
|
```
|
|
47
49
|
|
|
48
|
-
Your browser opens — log in or create a free account, click **Allow**. Token saved automatically.
|
|
50
|
+
Your browser opens — log in or create a free account, click **Allow**. Token saved automatically. API keys stored on the dashboard are synced to your local machine so `orchex run` works without environment variables.
|
|
49
51
|
|
|
50
52
|
```bash
|
|
51
53
|
orchex status # Check tier and trial runs
|
|
52
|
-
orchex logout # Clear credentials
|
|
54
|
+
orchex logout # Clear credentials and cached keys
|
|
53
55
|
orchex --help # All commands
|
|
54
56
|
```
|
|
55
57
|
|
|
@@ -57,19 +59,35 @@ See the [cloud setup guide](docs/user-guide/cloud-setup.md) for full details.
|
|
|
57
59
|
|
|
58
60
|
## MCP Configuration
|
|
59
61
|
|
|
60
|
-
|
|
62
|
+
Auto-configure for your IDE (Cursor, Windsurf, Claude Code):
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npx @wundam/orchex setup
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Or manually add to your MCP config (e.g. project `.mcp.json`):
|
|
61
69
|
|
|
62
70
|
```json
|
|
63
71
|
{
|
|
64
72
|
"mcpServers": {
|
|
65
73
|
"orchex": {
|
|
66
74
|
"command": "npx",
|
|
67
|
-
"args": ["@wundam/orchex"]
|
|
75
|
+
"args": ["-y", "@wundam/orchex"]
|
|
68
76
|
}
|
|
69
77
|
}
|
|
70
78
|
}
|
|
71
79
|
```
|
|
72
80
|
|
|
81
|
+
### Zero-Config LLM Discovery
|
|
82
|
+
|
|
83
|
+
Once connected, your AI assistant automatically receives:
|
|
84
|
+
|
|
85
|
+
- **Instructions** — Core concepts, all 12 tools, provider setup, and tier info. No prompt engineering needed.
|
|
86
|
+
- **8 on-demand resources** via `orchex://` URIs — deep guides on streams, waves, ownership, self-healing, providers, examples, and API reference. The LLM reads these when it needs them.
|
|
87
|
+
- **IDE auto-detection** — `orchex setup` detects Cursor, Windsurf, and other MCP clients, writes the correct config, and merges with existing MCP servers.
|
|
88
|
+
|
|
89
|
+
Your AI assistant knows how to use Orchex the moment it connects.
|
|
90
|
+
|
|
73
91
|
## Usage
|
|
74
92
|
|
|
75
93
|
### 1. Initialize an orchestration
|
|
@@ -133,6 +151,22 @@ orchex.status()
|
|
|
133
151
|
orchex.complete({ archive: true })
|
|
134
152
|
```
|
|
135
153
|
|
|
154
|
+
## CLI Commands
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
orchex run "Add user auth" # Auto-plan and execute from intent
|
|
158
|
+
orchex run "..." --yes # Skip approval prompt
|
|
159
|
+
orchex run "..." --dry-run # Generate plan only, don't execute
|
|
160
|
+
orchex run "..." --provider openai # Use specific provider
|
|
161
|
+
orchex setup # Auto-detect IDE and configure MCP
|
|
162
|
+
orchex setup --ide cursor # Target a specific IDE
|
|
163
|
+
orchex login # Authenticate with orchex cloud
|
|
164
|
+
orchex logout # Log out of cloud
|
|
165
|
+
orchex status # Show login state, tier, trial runs
|
|
166
|
+
orchex config # Show/set configuration
|
|
167
|
+
orchex reset-learning # Clear learning data
|
|
168
|
+
```
|
|
169
|
+
|
|
136
170
|
## MCP Tools
|
|
137
171
|
|
|
138
172
|
| Tool | Description |
|
|
@@ -142,10 +176,13 @@ orchex.complete({ archive: true })
|
|
|
142
176
|
| `status` | Get orchestration progress and wave info |
|
|
143
177
|
| `execute` | Run the orchestration — calls LLM API, applies artifacts, verifies |
|
|
144
178
|
| `complete` | Mark streams done or archive orchestration |
|
|
145
|
-
| `
|
|
146
|
-
| `
|
|
147
|
-
| `
|
|
148
|
-
| `
|
|
179
|
+
| `recover` | Reset stuck/failed streams for retry or skip |
|
|
180
|
+
| `learn` | Parse a markdown plan into stream definitions |
|
|
181
|
+
| `init-plan` | Generate an annotated plan template |
|
|
182
|
+
| `auto` | One-shot: intent → plan → preview → execute → report |
|
|
183
|
+
| `reset-learning` | Clear learning data (thresholds, patterns, reports) |
|
|
184
|
+
| `rollback-stream` | Revert a stream's file changes via git |
|
|
185
|
+
| `reload` | Restart MCP server to pick up code changes |
|
|
149
186
|
|
|
150
187
|
## Stream Definition
|
|
151
188
|
|
|
@@ -196,7 +233,11 @@ For local development, point `.mcp.json` to your local build:
|
|
|
196
233
|
|
|
197
234
|
## Pricing
|
|
198
235
|
|
|
199
|
-
|
|
236
|
+
Free local tier included — no account required. Cloud tiers unlock more streams, waves, providers, and run history.
|
|
237
|
+
|
|
238
|
+
**Founding Members** — 50 slots at $49/year with Pro-level limits (200 runs, 30 agents, 20 waves, unlimited providers). Locked-in pricing for life. [Claim a slot](https://orchex.dev/founders) while they last.
|
|
239
|
+
|
|
240
|
+
See [orchex.dev/pricing](https://orchex.dev/pricing) for all plans and limits.
|
|
200
241
|
|
|
201
242
|
## License
|
|
202
243
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ExecutorStrategy, ExecutionRequest, ExecutionResult } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Configuration for CloudExecutor retry and polling behavior.
|
|
4
|
+
*/
|
|
5
|
+
export interface CloudExecutorConfig {
|
|
6
|
+
/** Base polling interval in milliseconds. Default: 1000 (1 second) */
|
|
7
|
+
pollIntervalMs: number;
|
|
8
|
+
/** Maximum polling interval after backoff. Default: 10000 (10 seconds) */
|
|
9
|
+
maxPollIntervalMs: number;
|
|
10
|
+
/** Total timeout for job execution in milliseconds. Default: 660000 (11 minutes) */
|
|
11
|
+
timeoutMs: number;
|
|
12
|
+
/** Maximum retries for transient errors (429, 5xx). Default: 5 */
|
|
13
|
+
maxRetries: number;
|
|
14
|
+
/** Base delay for exponential backoff in milliseconds. Default: 1000 */
|
|
15
|
+
retryBaseDelayMs: number;
|
|
16
|
+
/** Maximum delay for exponential backoff in milliseconds. Default: 30000 */
|
|
17
|
+
retryMaxDelayMs: number;
|
|
18
|
+
/** Jitter factor (0-1) to randomize delays and prevent thundering herd. Default: 0.1 */
|
|
19
|
+
jitterFactor: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* CloudExecutor submits jobs to the orchex cloud server and polls for completion.
|
|
23
|
+
*
|
|
24
|
+
* Features:
|
|
25
|
+
* - Exponential backoff with jitter for transient errors (429, 5xx)
|
|
26
|
+
* - Respects Retry-After header from rate limit responses
|
|
27
|
+
* - Adaptive polling intervals to reduce server load
|
|
28
|
+
* - Configurable timeouts and retry limits
|
|
29
|
+
*/
|
|
30
|
+
export declare class CloudExecutor implements ExecutorStrategy {
|
|
31
|
+
private apiUrl;
|
|
32
|
+
private apiKey;
|
|
33
|
+
readonly provider = "orchex-cloud";
|
|
34
|
+
private config;
|
|
35
|
+
constructor(apiUrl: string, apiKey: string, config?: Partial<CloudExecutorConfig>);
|
|
36
|
+
execute(request: ExecutionRequest): Promise<ExecutionResult>;
|
|
37
|
+
/**
|
|
38
|
+
* Submit a job to the cloud server with retry logic for transient errors.
|
|
39
|
+
*/
|
|
40
|
+
private submitJobWithRetry;
|
|
41
|
+
/**
|
|
42
|
+
* Poll for job completion with adaptive intervals and retry logic.
|
|
43
|
+
* @param jobId The job ID to poll
|
|
44
|
+
* @param timeoutMs Effective timeout in milliseconds
|
|
45
|
+
*/
|
|
46
|
+
private pollForCompletionWithRetry;
|
|
47
|
+
/**
|
|
48
|
+
* Cancel a job on the cloud server to stop token consumption.
|
|
49
|
+
* Best effort — silently fails if server is unreachable.
|
|
50
|
+
*/
|
|
51
|
+
private cancelJob;
|
|
52
|
+
/**
|
|
53
|
+
* Fetch final token usage from job state on timeout.
|
|
54
|
+
* Best effort — returns zeros if fetch fails.
|
|
55
|
+
*/
|
|
56
|
+
private fetchFinalTokenUsage;
|
|
57
|
+
/**
|
|
58
|
+
* Calculate retry delay with exponential backoff and jitter.
|
|
59
|
+
*/
|
|
60
|
+
private calculateRetryDelay;
|
|
61
|
+
/**
|
|
62
|
+
* Parse Retry-After header (supports both seconds and HTTP-date formats).
|
|
63
|
+
*/
|
|
64
|
+
private parseRetryAfter;
|
|
65
|
+
/**
|
|
66
|
+
* Add jitter to a delay to prevent thundering herd.
|
|
67
|
+
* Returns delay ± (jitterFactor * delay).
|
|
68
|
+
*/
|
|
69
|
+
private addJitter;
|
|
70
|
+
private sleep;
|
|
71
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { extractArtifact } from './artifacts.js';
|
|
2
|
+
import { validateOwnership } from './utils/ownership-validator.js';
|
|
3
|
+
/**
|
|
4
|
+
* Returns actionable error messages for cloud API failures.
|
|
5
|
+
* Status-code-specific guidance tells users exactly how to fix each error.
|
|
6
|
+
*/
|
|
7
|
+
function formatCloudError(status, body) {
|
|
8
|
+
const detail = body ? ` (${body.slice(0, 120)})` : '';
|
|
9
|
+
switch (status) {
|
|
10
|
+
case 401:
|
|
11
|
+
return `Authentication failed (401)${detail}.\n\nYour API token has expired or been revoked. Run \`orchex login\` to authenticate again.`;
|
|
12
|
+
case 402:
|
|
13
|
+
return `Payment required (402)${detail}.\n\nCheck your subscription at https://orchex.dev/dashboard/billing`;
|
|
14
|
+
case 403:
|
|
15
|
+
return `Access denied (403)${detail}.\n\nYour account may not have permission for this operation.`;
|
|
16
|
+
case 404: {
|
|
17
|
+
const modelMatch = body.match(/model[:\s]+(\S+)/i);
|
|
18
|
+
const modelHint = modelMatch ? ` "${modelMatch[1]}"` : '';
|
|
19
|
+
return `Model${modelHint} not found (404)${detail}.\n\nRun \`orchex config --model <model>\` to use a different model.`;
|
|
20
|
+
}
|
|
21
|
+
case 429:
|
|
22
|
+
return `Quota exceeded (429)${detail}.\n\nYou've used all cloud runs for this period. Upgrade at https://orchex.dev/pricing`;
|
|
23
|
+
default:
|
|
24
|
+
return `Cloud API error: ${status}${detail}`;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const DEFAULT_CONFIG = {
|
|
28
|
+
pollIntervalMs: 1000, // Start at 1s for faster initial response
|
|
29
|
+
maxPollIntervalMs: 10000,
|
|
30
|
+
timeoutMs: 660_000, // 11 minutes — buffer over server's 10min to allow completion
|
|
31
|
+
maxRetries: 5,
|
|
32
|
+
retryBaseDelayMs: 1000,
|
|
33
|
+
retryMaxDelayMs: 30000,
|
|
34
|
+
jitterFactor: 0.1, // Reduced jitter for more predictable timing
|
|
35
|
+
};
|
|
36
|
+
/** HTTP status codes that are retryable (transient errors). */
|
|
37
|
+
const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]);
|
|
38
|
+
/**
|
|
39
|
+
* CloudExecutor submits jobs to the orchex cloud server and polls for completion.
|
|
40
|
+
*
|
|
41
|
+
* Features:
|
|
42
|
+
* - Exponential backoff with jitter for transient errors (429, 5xx)
|
|
43
|
+
* - Respects Retry-After header from rate limit responses
|
|
44
|
+
* - Adaptive polling intervals to reduce server load
|
|
45
|
+
* - Configurable timeouts and retry limits
|
|
46
|
+
*/
|
|
47
|
+
export class CloudExecutor {
|
|
48
|
+
apiUrl;
|
|
49
|
+
apiKey;
|
|
50
|
+
provider = 'orchex-cloud';
|
|
51
|
+
config;
|
|
52
|
+
constructor(apiUrl, apiKey, config = {}) {
|
|
53
|
+
this.apiUrl = apiUrl;
|
|
54
|
+
this.apiKey = apiKey;
|
|
55
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
56
|
+
}
|
|
57
|
+
async execute(request) {
|
|
58
|
+
// 1. Submit job with retry
|
|
59
|
+
const submitResult = await this.submitJobWithRetry(request);
|
|
60
|
+
if (!submitResult.success) {
|
|
61
|
+
return {
|
|
62
|
+
success: false,
|
|
63
|
+
rawResponse: '',
|
|
64
|
+
tokensUsed: { input: 0, output: 0 },
|
|
65
|
+
error: submitResult.error,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const jobId = submitResult.jobId;
|
|
69
|
+
// 2. Poll for completion with retry and adaptive intervals
|
|
70
|
+
// Use request-level timeout if provided, otherwise fall back to config
|
|
71
|
+
const effectiveTimeout = request.timeoutMs ?? this.config.timeoutMs;
|
|
72
|
+
return this.pollForCompletionWithRetry(jobId, effectiveTimeout, request.ownership);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Submit a job to the cloud server with retry logic for transient errors.
|
|
76
|
+
*/
|
|
77
|
+
async submitJobWithRetry(request) {
|
|
78
|
+
let lastError;
|
|
79
|
+
for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) {
|
|
80
|
+
try {
|
|
81
|
+
const submitRes = await fetch(`${this.apiUrl}/api/v1/execute`, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: {
|
|
84
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
85
|
+
'Content-Type': 'application/json',
|
|
86
|
+
},
|
|
87
|
+
body: JSON.stringify({
|
|
88
|
+
streamId: request.streamId,
|
|
89
|
+
prompt: request.prompt,
|
|
90
|
+
model: request.model,
|
|
91
|
+
maxTokens: request.maxTokens,
|
|
92
|
+
timeoutMs: this.config.timeoutMs,
|
|
93
|
+
...(request.fileContext && { fileContext: request.fileContext }),
|
|
94
|
+
...(request.ownership && { ownership: request.ownership }),
|
|
95
|
+
}),
|
|
96
|
+
});
|
|
97
|
+
if (submitRes.ok) {
|
|
98
|
+
const { jobId } = (await submitRes.json());
|
|
99
|
+
return { success: true, jobId };
|
|
100
|
+
}
|
|
101
|
+
// Check if error is retryable
|
|
102
|
+
if (RETRYABLE_STATUS_CODES.has(submitRes.status)) {
|
|
103
|
+
lastError = `Cloud API error: ${submitRes.status}`;
|
|
104
|
+
if (attempt < this.config.maxRetries) {
|
|
105
|
+
const delay = this.calculateRetryDelay(attempt, submitRes);
|
|
106
|
+
await this.sleep(delay);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
// Non-retryable error (400, 401, 403, 404, etc.)
|
|
112
|
+
const body = await submitRes.text().catch(() => '');
|
|
113
|
+
return {
|
|
114
|
+
success: false,
|
|
115
|
+
error: formatCloudError(submitRes.status, body),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
lastError = `Network error: ${err.message}`;
|
|
121
|
+
if (attempt < this.config.maxRetries) {
|
|
122
|
+
const delay = this.calculateRetryDelay(attempt);
|
|
123
|
+
await this.sleep(delay);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
success: false,
|
|
130
|
+
error: `Failed after ${this.config.maxRetries + 1} attempts: ${lastError}`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Poll for job completion with adaptive intervals and retry logic.
|
|
135
|
+
* @param jobId The job ID to poll
|
|
136
|
+
* @param timeoutMs Effective timeout in milliseconds
|
|
137
|
+
*/
|
|
138
|
+
async pollForCompletionWithRetry(jobId, timeoutMs, ownership) {
|
|
139
|
+
const deadline = Date.now() + timeoutMs;
|
|
140
|
+
let pollInterval = this.config.pollIntervalMs;
|
|
141
|
+
let consecutiveErrors = 0;
|
|
142
|
+
while (Date.now() < deadline) {
|
|
143
|
+
try {
|
|
144
|
+
const pollRes = await fetch(`${this.apiUrl}/api/v1/job/${jobId}`, {
|
|
145
|
+
headers: { 'Authorization': `Bearer ${this.apiKey}` },
|
|
146
|
+
});
|
|
147
|
+
if (pollRes.ok) {
|
|
148
|
+
consecutiveErrors = 0; // Reset error count on success
|
|
149
|
+
const job = (await pollRes.json());
|
|
150
|
+
if (job.status === 'completed') {
|
|
151
|
+
const rawResponse = job.output ?? '';
|
|
152
|
+
// Use server-provided artifact if available, otherwise extract client-side
|
|
153
|
+
const artifact = job.artifact ?? extractArtifact(rawResponse);
|
|
154
|
+
// If artifact extraction fails (both server and client), mark as failure
|
|
155
|
+
if (!artifact) {
|
|
156
|
+
return {
|
|
157
|
+
success: false,
|
|
158
|
+
rawResponse,
|
|
159
|
+
tokensUsed: job.tokensUsed ?? { input: 0, output: 0 },
|
|
160
|
+
error: 'No valid orchex-artifact block found in response',
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
// Validate artifact against ownership constraints (defense-in-depth)
|
|
164
|
+
if (ownership && artifact) {
|
|
165
|
+
const validation = validateOwnership(artifact, ownership);
|
|
166
|
+
if (!validation.valid) {
|
|
167
|
+
return {
|
|
168
|
+
success: false,
|
|
169
|
+
rawResponse,
|
|
170
|
+
tokensUsed: job.tokensUsed ?? { input: 0, output: 0 },
|
|
171
|
+
error: validation.error,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
success: true,
|
|
177
|
+
rawResponse,
|
|
178
|
+
artifact,
|
|
179
|
+
tokensUsed: job.tokensUsed ?? { input: 0, output: 0 },
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
if (job.status === 'failed') {
|
|
183
|
+
return {
|
|
184
|
+
success: false,
|
|
185
|
+
rawResponse: job.output ?? '',
|
|
186
|
+
tokensUsed: job.tokensUsed ?? { input: 0, output: 0 },
|
|
187
|
+
error: job.error ?? 'Cloud execution failed',
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
// Job still pending/running - wait with adaptive interval
|
|
191
|
+
const delay = this.addJitter(pollInterval);
|
|
192
|
+
await this.sleep(delay);
|
|
193
|
+
// Gradually increase poll interval to reduce server load (max: maxPollIntervalMs)
|
|
194
|
+
pollInterval = Math.min(pollInterval * 1.1, this.config.maxPollIntervalMs);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
// Handle poll error
|
|
198
|
+
if (RETRYABLE_STATUS_CODES.has(pollRes.status)) {
|
|
199
|
+
consecutiveErrors++;
|
|
200
|
+
// For rate limiting, back off significantly
|
|
201
|
+
if (pollRes.status === 429) {
|
|
202
|
+
const retryAfter = this.parseRetryAfter(pollRes);
|
|
203
|
+
const backoffDelay = retryAfter ?? this.calculateRetryDelay(consecutiveErrors);
|
|
204
|
+
await this.sleep(backoffDelay);
|
|
205
|
+
// Increase base poll interval to reduce future rate limiting
|
|
206
|
+
pollInterval = Math.min(pollInterval * 2, this.config.maxPollIntervalMs);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
// For server errors, use standard retry logic
|
|
210
|
+
if (consecutiveErrors <= this.config.maxRetries) {
|
|
211
|
+
const delay = this.calculateRetryDelay(consecutiveErrors - 1, pollRes);
|
|
212
|
+
await this.sleep(delay);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// Non-retryable error or max retries exceeded — read body for diagnostic context
|
|
217
|
+
let pollBody = '';
|
|
218
|
+
try {
|
|
219
|
+
pollBody = await pollRes.text();
|
|
220
|
+
}
|
|
221
|
+
catch { /* ignore unreadable body */ }
|
|
222
|
+
return {
|
|
223
|
+
success: false,
|
|
224
|
+
rawResponse: '',
|
|
225
|
+
tokensUsed: { input: 0, output: 0 },
|
|
226
|
+
error: formatCloudError(pollRes.status, pollBody) + ` (after ${consecutiveErrors} retries)`,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
consecutiveErrors++;
|
|
231
|
+
if (consecutiveErrors <= this.config.maxRetries) {
|
|
232
|
+
const delay = this.calculateRetryDelay(consecutiveErrors - 1);
|
|
233
|
+
await this.sleep(delay);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
success: false,
|
|
238
|
+
rawResponse: '',
|
|
239
|
+
tokensUsed: { input: 0, output: 0 },
|
|
240
|
+
error: `Network error: ${err.message} (after ${consecutiveErrors} retries)`,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// On timeout:
|
|
245
|
+
// 1. Cancel the job to stop further token consumption
|
|
246
|
+
// 2. Fetch final token usage for cost tracking
|
|
247
|
+
await this.cancelJob(jobId);
|
|
248
|
+
const tokensUsed = await this.fetchFinalTokenUsage(jobId);
|
|
249
|
+
return {
|
|
250
|
+
success: false,
|
|
251
|
+
rawResponse: '',
|
|
252
|
+
tokensUsed,
|
|
253
|
+
error: `Cloud execution timed out after ${timeoutMs}ms (job cancelled)`,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Cancel a job on the cloud server to stop token consumption.
|
|
258
|
+
* Best effort — silently fails if server is unreachable.
|
|
259
|
+
*/
|
|
260
|
+
async cancelJob(jobId) {
|
|
261
|
+
try {
|
|
262
|
+
await fetch(`${this.apiUrl}/api/v1/job/${jobId}/cancel`, {
|
|
263
|
+
method: 'POST',
|
|
264
|
+
headers: { 'Authorization': `Bearer ${this.apiKey}` },
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
// Best effort — ignore errors
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Fetch final token usage from job state on timeout.
|
|
273
|
+
* Best effort — returns zeros if fetch fails.
|
|
274
|
+
*/
|
|
275
|
+
async fetchFinalTokenUsage(jobId) {
|
|
276
|
+
try {
|
|
277
|
+
const res = await fetch(`${this.apiUrl}/api/v1/job/${jobId}`, {
|
|
278
|
+
headers: { 'Authorization': `Bearer ${this.apiKey}` },
|
|
279
|
+
});
|
|
280
|
+
if (res.ok) {
|
|
281
|
+
const job = (await res.json());
|
|
282
|
+
return job.tokensUsed ?? { input: 0, output: 0 };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
// Best effort — ignore errors
|
|
287
|
+
}
|
|
288
|
+
return { input: 0, output: 0 };
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Calculate retry delay with exponential backoff and jitter.
|
|
292
|
+
*/
|
|
293
|
+
calculateRetryDelay(attempt, response) {
|
|
294
|
+
// Check for Retry-After header
|
|
295
|
+
if (response) {
|
|
296
|
+
const retryAfter = this.parseRetryAfter(response);
|
|
297
|
+
if (retryAfter)
|
|
298
|
+
return retryAfter;
|
|
299
|
+
}
|
|
300
|
+
// Exponential backoff: baseDelay * 2^attempt
|
|
301
|
+
const exponentialDelay = this.config.retryBaseDelayMs * Math.pow(2, attempt);
|
|
302
|
+
const cappedDelay = Math.min(exponentialDelay, this.config.retryMaxDelayMs);
|
|
303
|
+
return this.addJitter(cappedDelay);
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Parse Retry-After header (supports both seconds and HTTP-date formats).
|
|
307
|
+
*/
|
|
308
|
+
parseRetryAfter(response) {
|
|
309
|
+
const retryAfter = response.headers.get('retry-after');
|
|
310
|
+
if (!retryAfter)
|
|
311
|
+
return undefined;
|
|
312
|
+
// Try parsing as seconds
|
|
313
|
+
const seconds = parseInt(retryAfter, 10);
|
|
314
|
+
if (!isNaN(seconds)) {
|
|
315
|
+
return seconds * 1000;
|
|
316
|
+
}
|
|
317
|
+
// Try parsing as HTTP-date
|
|
318
|
+
const date = Date.parse(retryAfter);
|
|
319
|
+
if (!isNaN(date)) {
|
|
320
|
+
return Math.max(0, date - Date.now());
|
|
321
|
+
}
|
|
322
|
+
return undefined;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Add jitter to a delay to prevent thundering herd.
|
|
326
|
+
* Returns delay ± (jitterFactor * delay).
|
|
327
|
+
*/
|
|
328
|
+
addJitter(delay) {
|
|
329
|
+
const jitter = delay * this.config.jitterFactor;
|
|
330
|
+
return delay + (Math.random() * 2 - 1) * jitter;
|
|
331
|
+
}
|
|
332
|
+
sleep(ms) {
|
|
333
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
334
|
+
}
|
|
335
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Config } from './config.js';
|
|
2
|
+
import type { ExecutionReport } from './intelligence/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* Fire-and-forget sync of an execution report to the cloud dashboard.
|
|
5
|
+
* Only runs when user is logged in (mode=cloud + apiKey present).
|
|
6
|
+
* Retries once on transient server errors. Logs warnings on failure.
|
|
7
|
+
*/
|
|
8
|
+
export declare function syncReportToCloud(config: Config, report: ExecutionReport): Promise<void>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** HTTP status codes that are retryable (transient server errors). */
|
|
2
|
+
const RETRYABLE_STATUS = new Set([500, 502, 503, 504]);
|
|
3
|
+
/**
|
|
4
|
+
* Fire-and-forget sync of an execution report to the cloud dashboard.
|
|
5
|
+
* Only runs when user is logged in (mode=cloud + apiKey present).
|
|
6
|
+
* Retries once on transient server errors. Logs warnings on failure.
|
|
7
|
+
*/
|
|
8
|
+
export async function syncReportToCloud(config, report) {
|
|
9
|
+
if (config.mode !== 'cloud' || !config.apiKey)
|
|
10
|
+
return;
|
|
11
|
+
const apiUrl = config.apiUrl;
|
|
12
|
+
const maxAttempts = 2;
|
|
13
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
14
|
+
try {
|
|
15
|
+
const resp = await fetch(`${apiUrl}/api/v1/history/sync`, {
|
|
16
|
+
method: 'POST',
|
|
17
|
+
headers: {
|
|
18
|
+
'Authorization': `Bearer ${config.apiKey}`,
|
|
19
|
+
'Content-Type': 'application/json',
|
|
20
|
+
},
|
|
21
|
+
body: JSON.stringify({ report }),
|
|
22
|
+
});
|
|
23
|
+
if (resp.ok)
|
|
24
|
+
return;
|
|
25
|
+
if (resp.status === 401) {
|
|
26
|
+
console.warn('Cloud sync: token expired. Run `orchex login` to re-authenticate.');
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (RETRYABLE_STATUS.has(resp.status) && attempt < maxAttempts) {
|
|
30
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
// Capture error body for diagnostics
|
|
34
|
+
let detail = '';
|
|
35
|
+
try {
|
|
36
|
+
const body = await resp.json();
|
|
37
|
+
if (body?.error)
|
|
38
|
+
detail = `: ${body.error}`;
|
|
39
|
+
}
|
|
40
|
+
catch { /* response may not be JSON */ }
|
|
41
|
+
console.warn(`Cloud sync failed: HTTP ${resp.status}${detail} (run ${report.runId}). History may be incomplete.`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
if (attempt < maxAttempts) {
|
|
46
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
console.warn(`Cloud sync failed: network error (run ${report.runId}). History may be incomplete.`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|