@the-open-engine/zeroshot 6.25.0 → 6.26.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/cli/index.js +102 -74
- package/package.json +4 -2
- package/src/agent/provider-session.js +18 -4
- package/src/hosted-target/bounds.ts +6 -0
- package/src/hosted-target/errors.ts +90 -0
- package/src/hosted-target/index.ts +44 -0
- package/src/hosted-target/response-validation.ts +86 -0
- package/src/hosted-target/retry.ts +46 -0
- package/src/hosted-target/target-adapter.ts +10 -0
- package/src/hosted-target/types.ts +58 -0
- package/src/hosted-target/zero-cloud-v1-adapter.ts +386 -0
- package/src/omp-session-limits.js +25 -1
- package/src/omp-session-partition.js +159 -32
- package/src/omp-session-verifier.js +251 -87
- package/task-lib/commands/clean.js +80 -46
- package/task-lib/commands/kill.js +21 -0
- package/task-lib/commands/resume.js +20 -0
- package/task-lib/commands/run.js +17 -2
- package/task-lib/omp-session-cleanup.js +46 -9
- package/task-lib/omp-session-ownership-schema.js +21 -15
- package/task-lib/omp-session-ownership.js +66 -31
- package/task-lib/rpc-watcher.js +51 -15
- package/task-lib/runner.js +51 -4
- package/task-lib/store.js +67 -72
package/cli/index.js
CHANGED
|
@@ -2086,12 +2086,9 @@ async function getPurgeData(orchestrator) {
|
|
|
2086
2086
|
cluster.state === 'running' || cluster.state === 'initializing' || cluster.state === 'setup'
|
|
2087
2087
|
);
|
|
2088
2088
|
const { loadTasks } = await import('../task-lib/store.js');
|
|
2089
|
-
const { isProcessRunning } = await import('../task-lib/runner.js');
|
|
2090
2089
|
const tasks = Object.values(loadTasks());
|
|
2091
|
-
const runningTasks = tasks.filter(
|
|
2092
|
-
|
|
2093
|
-
);
|
|
2094
|
-
return { clusters, runningClusters, tasks, runningTasks, isProcessRunning };
|
|
2090
|
+
const runningTasks = tasks.filter((task) => task.status === 'running');
|
|
2091
|
+
return { clusters, runningClusters, tasks, runningTasks };
|
|
2095
2092
|
}
|
|
2096
2093
|
|
|
2097
2094
|
function printPurgeSummary({ clusters, runningClusters, tasks, runningTasks }) {
|
|
@@ -2133,45 +2130,105 @@ async function confirmPurge(options) {
|
|
|
2133
2130
|
return answer.toLowerCase() === 'y';
|
|
2134
2131
|
}
|
|
2135
2132
|
|
|
2133
|
+
function validateClusterKillResults(runningClusters, clusterResults) {
|
|
2134
|
+
if (
|
|
2135
|
+
clusterResults === null ||
|
|
2136
|
+
typeof clusterResults !== 'object' ||
|
|
2137
|
+
!Array.isArray(clusterResults.killed) ||
|
|
2138
|
+
!Array.isArray(clusterResults.errors)
|
|
2139
|
+
) {
|
|
2140
|
+
throw new Error(
|
|
2141
|
+
'Refusing destructive cluster cleanup: kill-all returned incomplete or invalid results ' +
|
|
2142
|
+
'(malformed fields: killed or errors). Retry after confirming every cluster process ' +
|
|
2143
|
+
'boundary is terminal.'
|
|
2144
|
+
);
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
const expectedIds = runningClusters.map((cluster) => cluster.id);
|
|
2148
|
+
const expected = new Set(expectedIds);
|
|
2149
|
+
const outcomeIds = [...clusterResults.killed, ...clusterResults.errors.map((error) => error?.id)];
|
|
2150
|
+
const counts = outcomeIds.reduce((byId, id) => {
|
|
2151
|
+
byId.set(id, (byId.get(id) || 0) + 1);
|
|
2152
|
+
return byId;
|
|
2153
|
+
}, new Map());
|
|
2154
|
+
const problems = [
|
|
2155
|
+
['unknown outcomes', outcomeIds.filter((id) => typeof id !== 'string' || !expected.has(id))],
|
|
2156
|
+
['duplicate outcomes', expectedIds.filter((id) => (counts.get(id) || 0) > 1)],
|
|
2157
|
+
['missing outcomes', expectedIds.filter((id) => (counts.get(id) || 0) === 0)],
|
|
2158
|
+
]
|
|
2159
|
+
.filter(([, ids]) => ids.length > 0)
|
|
2160
|
+
.map(([label, ids]) => `${label}: ${ids.join(', ')}`);
|
|
2161
|
+
|
|
2162
|
+
if (problems.length > 0) {
|
|
2163
|
+
throw new Error(
|
|
2164
|
+
`Refusing destructive cluster cleanup: kill-all returned incomplete or invalid results (${problems.join(
|
|
2165
|
+
'; '
|
|
2166
|
+
)}). Retry after confirming every cluster process boundary is terminal.`
|
|
2167
|
+
);
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
return clusterResults;
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2136
2173
|
async function killRunningClusters(orchestrator, runningClusters) {
|
|
2137
2174
|
if (runningClusters.length === 0) {
|
|
2138
2175
|
return;
|
|
2139
2176
|
}
|
|
2140
2177
|
console.log(chalk.bold('Killing running clusters...'));
|
|
2141
|
-
const
|
|
2142
|
-
|
|
2178
|
+
const { killed, errors } = validateClusterKillResults(
|
|
2179
|
+
runningClusters,
|
|
2180
|
+
await orchestrator.killAll()
|
|
2181
|
+
);
|
|
2182
|
+
|
|
2183
|
+
for (const id of killed) {
|
|
2143
2184
|
console.log(chalk.green(`✓ Killed cluster: ${id}`));
|
|
2144
2185
|
}
|
|
2145
|
-
|
|
2146
|
-
|
|
2186
|
+
if (errors.length > 0) {
|
|
2187
|
+
for (const err of errors) {
|
|
2188
|
+
console.log(chalk.red(`✗ Failed to kill cluster ${err.id}: ${err.error}`));
|
|
2189
|
+
}
|
|
2190
|
+
throw new Error(
|
|
2191
|
+
`Refusing destructive cluster cleanup: termination failed for ${errors
|
|
2192
|
+
.map((error) => error.id)
|
|
2193
|
+
.join(', ')}. Retry after confirming every cluster process boundary is terminal.`
|
|
2194
|
+
);
|
|
2147
2195
|
}
|
|
2148
2196
|
}
|
|
2149
2197
|
|
|
2150
|
-
async function killRunningTasks(runningTasks
|
|
2198
|
+
async function killRunningTasks(runningTasks) {
|
|
2151
2199
|
if (runningTasks.length === 0) {
|
|
2152
2200
|
return;
|
|
2153
2201
|
}
|
|
2154
2202
|
console.log(chalk.bold('Killing running tasks...'));
|
|
2155
|
-
const {
|
|
2156
|
-
|
|
2157
|
-
|
|
2203
|
+
const [{ killTaskCommand }, { getTask }] = await Promise.all([
|
|
2204
|
+
import('../task-lib/commands/kill.js'),
|
|
2205
|
+
import('../task-lib/store.js'),
|
|
2206
|
+
]);
|
|
2207
|
+
const unconfirmed = [];
|
|
2208
|
+
|
|
2209
|
+
// Reuse the standalone kill boundary instead of treating successful signal delivery as process
|
|
2210
|
+
// termination. Then verify its durable terminal write: killTaskCommand also serves the interactive
|
|
2211
|
+
// CLI and reports an unconfirmed boundary through process.exitCode rather than throwing. Purge
|
|
2212
|
+
// must turn that report into a hard gate before it reaches any destructive cleanup.
|
|
2158
2213
|
for (const task of runningTasks) {
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2214
|
+
await killTaskCommand(task.id);
|
|
2215
|
+
const current = getTask(task.id);
|
|
2216
|
+
if (
|
|
2217
|
+
!current ||
|
|
2218
|
+
current.status === 'running' ||
|
|
2219
|
+
Number.isInteger(current.pid) ||
|
|
2220
|
+
Number.isInteger(current.processGroupId)
|
|
2221
|
+
) {
|
|
2222
|
+
unconfirmed.push(task.id);
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
if (unconfirmed.length > 0) {
|
|
2227
|
+
throw new Error(
|
|
2228
|
+
`Refusing destructive task cleanup: provider termination is unconfirmed for ${unconfirmed.join(
|
|
2229
|
+
', '
|
|
2230
|
+
)}. Retry after confirming the persisted provider process boundary is terminal.`
|
|
2231
|
+
);
|
|
2175
2232
|
}
|
|
2176
2233
|
}
|
|
2177
2234
|
|
|
@@ -2183,17 +2240,18 @@ async function killRunningTasks(runningTasks, isProcessRunning) {
|
|
|
2183
2240
|
* cannot be safely resolved keeps its owner record plus a warning instead of being deleted.
|
|
2184
2241
|
*/
|
|
2185
2242
|
async function deleteClusterOmpSessions(clusters) {
|
|
2186
|
-
const { cleanupOmpSessionPartitionsForCluster } =
|
|
2187
|
-
'../task-lib/omp-session-cleanup.js'
|
|
2188
|
-
);
|
|
2243
|
+
const { cleanupOmpSessionPartitionsForCluster } =
|
|
2244
|
+
await import('../task-lib/omp-session-cleanup.js');
|
|
2189
2245
|
let deleted = 0;
|
|
2190
2246
|
let retained = 0;
|
|
2247
|
+
const unreadable = new Set();
|
|
2191
2248
|
for (const cluster of clusters) {
|
|
2192
2249
|
const result = cleanupOmpSessionPartitionsForCluster(cluster.id, (message) =>
|
|
2193
2250
|
console.log(chalk.yellow(`Warning: ${message}`))
|
|
2194
2251
|
);
|
|
2195
2252
|
deleted += result.deleted.length;
|
|
2196
2253
|
retained += result.retained.length;
|
|
2254
|
+
for (const taskId of result.unreadable) unreadable.add(taskId);
|
|
2197
2255
|
}
|
|
2198
2256
|
if (deleted > 0) {
|
|
2199
2257
|
console.log(chalk.green(`✓ Deleted ${deleted} OMP session partition(s)`));
|
|
@@ -2201,6 +2259,13 @@ async function deleteClusterOmpSessions(clusters) {
|
|
|
2201
2259
|
if (retained > 0) {
|
|
2202
2260
|
console.log(chalk.yellow(`○ Retained ${retained} OMP session partition(s) for inspection`));
|
|
2203
2261
|
}
|
|
2262
|
+
if (unreadable.size > 0) {
|
|
2263
|
+
console.log(
|
|
2264
|
+
chalk.yellow(
|
|
2265
|
+
`○ ${unreadable.size} task row(s) hold an unreadable OMP session ownership record and were left intact`
|
|
2266
|
+
)
|
|
2267
|
+
);
|
|
2268
|
+
}
|
|
2204
2269
|
}
|
|
2205
2270
|
|
|
2206
2271
|
async function deleteClusterData(orchestrator, clusters) {
|
|
@@ -3204,11 +3269,8 @@ program
|
|
|
3204
3269
|
);
|
|
3205
3270
|
|
|
3206
3271
|
const { loadTasks } = await import('../task-lib/store.js');
|
|
3207
|
-
const { isProcessRunning } = await import('../task-lib/runner.js');
|
|
3208
3272
|
const tasks = loadTasks();
|
|
3209
|
-
const runningTasks = Object.values(tasks).filter(
|
|
3210
|
-
(t) => t.status === 'running' && isProcessRunning(t.pid)
|
|
3211
|
-
);
|
|
3273
|
+
const runningTasks = Object.values(tasks).filter((task) => task.status === 'running');
|
|
3212
3274
|
|
|
3213
3275
|
const totalCount = runningClusters.length + runningTasks.length;
|
|
3214
3276
|
|
|
@@ -3253,44 +3315,9 @@ program
|
|
|
3253
3315
|
|
|
3254
3316
|
console.log('');
|
|
3255
3317
|
|
|
3256
|
-
|
|
3257
|
-
if (runningClusters.length > 0) {
|
|
3258
|
-
const clusterResults = await orchestrator.killAll();
|
|
3259
|
-
for (const id of clusterResults.killed) {
|
|
3260
|
-
console.log(chalk.green(`✓ Killed cluster: ${id}`));
|
|
3261
|
-
}
|
|
3262
|
-
for (const err of clusterResults.errors) {
|
|
3263
|
-
console.log(chalk.red(`✗ Failed to kill cluster ${err.id}: ${err.error}`));
|
|
3264
|
-
}
|
|
3265
|
-
}
|
|
3266
|
-
|
|
3267
|
-
// Kill tasks
|
|
3268
|
-
if (runningTasks.length > 0) {
|
|
3269
|
-
const { killTask, isProcessRunning: checkPid } = await import('../task-lib/runner.js');
|
|
3270
|
-
const { updateTask } = await import('../task-lib/store.js');
|
|
3271
|
-
|
|
3272
|
-
for (const task of runningTasks) {
|
|
3273
|
-
if (!checkPid(task.pid)) {
|
|
3274
|
-
updateTask(task.id, {
|
|
3275
|
-
status: 'stale',
|
|
3276
|
-
error: 'Process died unexpectedly',
|
|
3277
|
-
});
|
|
3278
|
-
console.log(chalk.yellow(`○ Task ${task.id} was already dead, marked stale`));
|
|
3279
|
-
continue;
|
|
3280
|
-
}
|
|
3318
|
+
await killRunningClusters(orchestrator, runningClusters);
|
|
3281
3319
|
|
|
3282
|
-
|
|
3283
|
-
if (killed) {
|
|
3284
|
-
updateTask(task.id, {
|
|
3285
|
-
status: 'killed',
|
|
3286
|
-
error: 'Killed by kill-all',
|
|
3287
|
-
});
|
|
3288
|
-
console.log(chalk.green(`✓ Killed task: ${task.id}`));
|
|
3289
|
-
} else {
|
|
3290
|
-
console.log(chalk.red(`✗ Failed to kill task: ${task.id}`));
|
|
3291
|
-
}
|
|
3292
|
-
}
|
|
3293
|
-
}
|
|
3320
|
+
await killRunningTasks(runningTasks);
|
|
3294
3321
|
|
|
3295
3322
|
console.log(chalk.bold.green(`\nDone.`));
|
|
3296
3323
|
} catch (error) {
|
|
@@ -3801,7 +3828,7 @@ program
|
|
|
3801
3828
|
console.log('');
|
|
3802
3829
|
|
|
3803
3830
|
await killRunningClusters(orchestrator, purgeData.runningClusters);
|
|
3804
|
-
await killRunningTasks(purgeData.runningTasks
|
|
3831
|
+
await killRunningTasks(purgeData.runningTasks);
|
|
3805
3832
|
await deleteClusterData(orchestrator, purgeData.clusters);
|
|
3806
3833
|
await deleteTaskData(purgeData.tasks);
|
|
3807
3834
|
|
|
@@ -6089,4 +6116,5 @@ module.exports = {
|
|
|
6089
6116
|
renderRecentMessagesToTerminal,
|
|
6090
6117
|
isStartupUpdateEligible,
|
|
6091
6118
|
resolveRunMode,
|
|
6119
|
+
killRunningClusters,
|
|
6092
6120
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@the-open-engine/zeroshot",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.26.0",
|
|
4
4
|
"description": "Multi-agent orchestration engine for Claude, Codex, and Gemini",
|
|
5
5
|
"main": "src/orchestrator.js",
|
|
6
6
|
"bin": {
|
|
@@ -40,8 +40,10 @@
|
|
|
40
40
|
"test:coverage:report": "c8 --reporter=html npm run test:unit && echo 'Coverage report generated at coverage/index.html'",
|
|
41
41
|
"postinstall": "node scripts/fix-node-pty-permissions.js && node scripts/check-path.js",
|
|
42
42
|
"start": "node cli/index.js",
|
|
43
|
-
"typecheck": "tsc --noEmit && npm run typecheck:cluster",
|
|
43
|
+
"typecheck": "tsc --noEmit && npm run typecheck:cluster && npm run typecheck:hosted-target",
|
|
44
44
|
"typecheck:agent-cli-provider": "tsc --project tsconfig.agent-cli-provider.json",
|
|
45
|
+
"typecheck:hosted-target": "tsc --project tsconfig.hosted-target.json",
|
|
46
|
+
"test:hosted-target": "node --test tests/hosted-target/*.test.ts",
|
|
45
47
|
"typecheck:cluster": "tsc --project tsconfig.cluster.json",
|
|
46
48
|
"lint:agent-cli-provider": "eslint \"src/agent-cli-provider/**/*.ts\" \"tests/agent-cli-provider/**/*.ts\"",
|
|
47
49
|
"build:agent-cli-provider": "tsc --project tsconfig.agent-cli-provider.build.json",
|
|
@@ -65,13 +65,25 @@ const OMP_SESSION_KEYS = new Set([
|
|
|
65
65
|
]);
|
|
66
66
|
const IDENTITY_KEYS = new Set(['device', 'inode']);
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* A `{device, inode}` pair, both already canonical unsigned decimal *strings* (issue #866).
|
|
70
|
+
*
|
|
71
|
+
* The type is required, not coerced. `String(value.device)` used to accept a JSON number, a
|
|
72
|
+
* boxed String, a one-element array, or anything else whose `toString()` happened to look decimal,
|
|
73
|
+
* and then write the coerced result into the snapshot — so a snapshot that had never contained the
|
|
74
|
+
* canonical form would compare equal to the persisted ownership record it is supposed to be
|
|
75
|
+
* checked against. A snapshot that is not already canonical is not this writer's snapshot, and is
|
|
76
|
+
* rejected rather than repaired. Both keys are required and no others are allowed.
|
|
77
|
+
*/
|
|
68
78
|
function normalizeIdentity(value) {
|
|
69
79
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
70
|
-
|
|
71
|
-
if (
|
|
80
|
+
const keys = Object.keys(value);
|
|
81
|
+
if (keys.length !== IDENTITY_KEYS.size || !keys.every((key) => IDENTITY_KEYS.has(key))) {
|
|
72
82
|
return null;
|
|
73
83
|
}
|
|
74
|
-
|
|
84
|
+
if (typeof value.device !== 'string' || typeof value.inode !== 'string') return null;
|
|
85
|
+
if (!DECIMAL_STRING.test(value.device) || !DECIMAL_STRING.test(value.inode)) return null;
|
|
86
|
+
return { device: value.device, inode: value.inode };
|
|
75
87
|
}
|
|
76
88
|
|
|
77
89
|
/**
|
|
@@ -299,7 +311,9 @@ function providerSessionFromCompletedTask({
|
|
|
299
311
|
// rpc-watcher.js never populates the generic sessionId column; the OMP-observed session ID
|
|
300
312
|
// committed alongside ompSession is the one authoritative identity for this provider.
|
|
301
313
|
const sessionId = isOmp
|
|
302
|
-
? normalizeNonEmptyString(
|
|
314
|
+
? normalizeNonEmptyString(
|
|
315
|
+
ompOwnership?.state === 'committed' ? ompOwnership.session?.sessionId : null
|
|
316
|
+
)
|
|
303
317
|
: normalizeNonEmptyString(taskInfo.sessionId);
|
|
304
318
|
const taskId = normalizeNonEmptyString(taskInfo.id);
|
|
305
319
|
const generation = agent?.iteration;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
|
2
|
+
export const MAX_PAGINATION_PAGES = 100;
|
|
3
|
+
export const MAX_RETRY_ATTEMPTS = 3;
|
|
4
|
+
export const MAX_RETRY_ELAPSED_MS = 30_000;
|
|
5
|
+
export const MAX_ERROR_BODY_BYTES = 8192;
|
|
6
|
+
export const IDEMPOTENCY_KEY_PATTERN = /^[a-zA-Z0-9_-]{1,128}$/;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
function sanitize(text: string): string {
|
|
2
|
+
return text
|
|
3
|
+
.replace(/Authorization:\s*Bearer\s+\S+/gi, 'Authorization: Bearer [REDACTED]')
|
|
4
|
+
.replace(/token["']?\s*[:=]\s*["'][^"']+["']/gi, 'token: "[REDACTED]"')
|
|
5
|
+
.replace(/https?:\/\/[^\s]*(?:token|key|secret|credential|auth)[^\s]*/gi, '[REDACTED_URL]');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function sanitizeCause(cause: unknown): unknown {
|
|
9
|
+
if (!cause) return cause;
|
|
10
|
+
if (cause instanceof Error) {
|
|
11
|
+
const cleaned = new Error(sanitize(cause.message));
|
|
12
|
+
cleaned.name = cause.name;
|
|
13
|
+
if (cause.cause) cleaned.cause = sanitizeCause(cause.cause);
|
|
14
|
+
return cleaned;
|
|
15
|
+
}
|
|
16
|
+
if (typeof cause === 'string') return sanitize(cause);
|
|
17
|
+
return cause;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class TargetAdapterError extends Error {
|
|
21
|
+
readonly code: string;
|
|
22
|
+
readonly retryable: boolean;
|
|
23
|
+
|
|
24
|
+
constructor(code: string, message: string, retryable: boolean, cause?: unknown) {
|
|
25
|
+
super(sanitize(message), { cause: sanitizeCause(cause) });
|
|
26
|
+
this.name = 'TargetAdapterError';
|
|
27
|
+
this.code = code;
|
|
28
|
+
this.retryable = retryable;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class TargetAuthError extends TargetAdapterError {
|
|
33
|
+
constructor(message: string, cause?: unknown) {
|
|
34
|
+
super('AUTH_FAILED', message, false, cause);
|
|
35
|
+
this.name = 'TargetAuthError';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class TargetConflictError extends TargetAdapterError {
|
|
40
|
+
readonly idempotencyKey: string;
|
|
41
|
+
|
|
42
|
+
constructor(idempotencyKey: string, message: string, cause?: unknown) {
|
|
43
|
+
super('CONFLICT', message, true, cause);
|
|
44
|
+
this.name = 'TargetConflictError';
|
|
45
|
+
this.idempotencyKey = idempotencyKey;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class TargetRateLimitError extends TargetAdapterError {
|
|
50
|
+
readonly retryAfterMs: number | undefined;
|
|
51
|
+
|
|
52
|
+
constructor(message: string, retryAfterMs?: number, cause?: unknown) {
|
|
53
|
+
super('RATE_LIMITED', message, true, cause);
|
|
54
|
+
this.name = 'TargetRateLimitError';
|
|
55
|
+
this.retryAfterMs = retryAfterMs;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class TargetTransportError extends TargetAdapterError {
|
|
60
|
+
constructor(message: string, cause?: unknown) {
|
|
61
|
+
super('TRANSPORT', message, true, cause);
|
|
62
|
+
this.name = 'TargetTransportError';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class TargetProtocolError extends TargetAdapterError {
|
|
67
|
+
constructor(message: string, cause?: unknown) {
|
|
68
|
+
super('PROTOCOL', message, false, cause);
|
|
69
|
+
this.name = 'TargetProtocolError';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class TargetCapacityError extends TargetAdapterError {
|
|
74
|
+
constructor(message: string, cause?: unknown) {
|
|
75
|
+
super('CAPACITY', message, false, cause);
|
|
76
|
+
this.name = 'TargetCapacityError';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class TargetNotFoundError extends TargetAdapterError {
|
|
81
|
+
constructor(message: string, cause?: unknown) {
|
|
82
|
+
super('NOT_FOUND', message, false, cause);
|
|
83
|
+
this.name = 'TargetNotFoundError';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function isRetryable(error: unknown): boolean {
|
|
88
|
+
if (error instanceof TargetAdapterError) return error.retryable;
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type { TargetAdapter } from './target-adapter.ts';
|
|
2
|
+
export { ZeroCloudV1TargetAdapter } from './zero-cloud-v1-adapter.ts';
|
|
3
|
+
export {
|
|
4
|
+
TargetAdapterError,
|
|
5
|
+
TargetAuthError,
|
|
6
|
+
TargetConflictError,
|
|
7
|
+
TargetRateLimitError,
|
|
8
|
+
TargetTransportError,
|
|
9
|
+
TargetProtocolError,
|
|
10
|
+
TargetCapacityError,
|
|
11
|
+
TargetNotFoundError,
|
|
12
|
+
isRetryable,
|
|
13
|
+
} from './errors.ts';
|
|
14
|
+
export type {
|
|
15
|
+
TargetAccessTokenProvider,
|
|
16
|
+
CapsuleState,
|
|
17
|
+
Capsule,
|
|
18
|
+
CapsuleAccess,
|
|
19
|
+
CapsuleListPage,
|
|
20
|
+
CapsuleLimits,
|
|
21
|
+
AllocateRequest,
|
|
22
|
+
HttpTransport,
|
|
23
|
+
Clock,
|
|
24
|
+
RetryPolicy,
|
|
25
|
+
TargetDiscovery,
|
|
26
|
+
} from './types.ts';
|
|
27
|
+
export { KNOWN_CAPSULE_STATES } from './types.ts';
|
|
28
|
+
export {
|
|
29
|
+
MAX_RESPONSE_BYTES,
|
|
30
|
+
MAX_PAGINATION_PAGES,
|
|
31
|
+
MAX_RETRY_ATTEMPTS,
|
|
32
|
+
MAX_RETRY_ELAPSED_MS,
|
|
33
|
+
MAX_ERROR_BODY_BYTES,
|
|
34
|
+
IDEMPOTENCY_KEY_PATTERN,
|
|
35
|
+
} from './bounds.ts';
|
|
36
|
+
export { DefaultRetryPolicy, parseRetryAfter } from './retry.ts';
|
|
37
|
+
export {
|
|
38
|
+
assertRequiredFields,
|
|
39
|
+
assertKnownEnum,
|
|
40
|
+
assertCapsule,
|
|
41
|
+
assertCapsuleAccess,
|
|
42
|
+
assertCapsuleLimits,
|
|
43
|
+
assertCapsuleListPage,
|
|
44
|
+
} from './response-validation.ts';
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { TargetProtocolError } from './errors.ts';
|
|
2
|
+
import { KNOWN_CAPSULE_STATES } from './types.ts';
|
|
3
|
+
import type { Capsule, CapsuleAccess, CapsuleLimits, CapsuleListPage } from './types.ts';
|
|
4
|
+
|
|
5
|
+
export function assertRequiredFields(
|
|
6
|
+
body: unknown,
|
|
7
|
+
fields: readonly string[],
|
|
8
|
+
context: string,
|
|
9
|
+
): asserts body is Record<string, unknown> {
|
|
10
|
+
if (body === null || typeof body !== 'object') {
|
|
11
|
+
throw new TargetProtocolError(`${context}: expected object, got ${typeof body}`);
|
|
12
|
+
}
|
|
13
|
+
const record = body as Record<string, unknown>;
|
|
14
|
+
for (const field of fields) {
|
|
15
|
+
if (record[field] === undefined || record[field] === null) {
|
|
16
|
+
throw new TargetProtocolError(`${context}: missing required field "${field}"`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function assertKnownEnum(value: string, known: readonly string[], field: string): void {
|
|
22
|
+
if (!known.includes(value)) {
|
|
23
|
+
// eslint-disable-next-line no-console
|
|
24
|
+
console.warn(`Unknown ${field} value: "${value}". Known values: ${known.join(', ')}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function assertCapsule(body: unknown): Capsule {
|
|
29
|
+
assertRequiredFields(body, ['id', 'state', 'createdAt'], 'Capsule');
|
|
30
|
+
const record = body as Record<string, unknown>;
|
|
31
|
+
if (typeof record['id'] !== 'string') {
|
|
32
|
+
throw new TargetProtocolError('Capsule: "id" must be a string');
|
|
33
|
+
}
|
|
34
|
+
if (typeof record['state'] !== 'string') {
|
|
35
|
+
throw new TargetProtocolError('Capsule: "state" must be a string');
|
|
36
|
+
}
|
|
37
|
+
if (typeof record['createdAt'] !== 'string') {
|
|
38
|
+
throw new TargetProtocolError('Capsule: "createdAt" must be a string');
|
|
39
|
+
}
|
|
40
|
+
assertKnownEnum(record['state'] as string, KNOWN_CAPSULE_STATES, 'CapsuleState');
|
|
41
|
+
return record as unknown as Capsule;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function assertCapsuleAccess(body: unknown): CapsuleAccess {
|
|
45
|
+
assertRequiredFields(body, ['endpoint', 'token', 'expiresAt'], 'CapsuleAccess');
|
|
46
|
+
const record = body as Record<string, unknown>;
|
|
47
|
+
if (typeof record['endpoint'] !== 'string') {
|
|
48
|
+
throw new TargetProtocolError('CapsuleAccess: "endpoint" must be a string');
|
|
49
|
+
}
|
|
50
|
+
if (typeof record['token'] !== 'string') {
|
|
51
|
+
throw new TargetProtocolError('CapsuleAccess: "token" must be a string');
|
|
52
|
+
}
|
|
53
|
+
if (typeof record['expiresAt'] !== 'string') {
|
|
54
|
+
throw new TargetProtocolError('CapsuleAccess: "expiresAt" must be a string');
|
|
55
|
+
}
|
|
56
|
+
return record as unknown as CapsuleAccess;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function assertCapsuleLimits(body: unknown): CapsuleLimits {
|
|
60
|
+
assertRequiredFields(body, ['maxConcurrent', 'maxPerHour'], 'CapsuleLimits');
|
|
61
|
+
const record = body as Record<string, unknown>;
|
|
62
|
+
if (typeof record['maxConcurrent'] !== 'number') {
|
|
63
|
+
throw new TargetProtocolError('CapsuleLimits: "maxConcurrent" must be a number');
|
|
64
|
+
}
|
|
65
|
+
if (typeof record['maxPerHour'] !== 'number') {
|
|
66
|
+
throw new TargetProtocolError('CapsuleLimits: "maxPerHour" must be a number');
|
|
67
|
+
}
|
|
68
|
+
return record as unknown as CapsuleLimits;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function assertCapsuleListPage(body: unknown): CapsuleListPage {
|
|
72
|
+
assertRequiredFields(body, ['items'], 'CapsuleListPage');
|
|
73
|
+
const record = body as Record<string, unknown>;
|
|
74
|
+
if (!Array.isArray(record['items'])) {
|
|
75
|
+
throw new TargetProtocolError('CapsuleListPage: "items" must be an array');
|
|
76
|
+
}
|
|
77
|
+
const items = (record['items'] as unknown[]).map((item) => assertCapsule(item));
|
|
78
|
+
const cursor = record['cursor'];
|
|
79
|
+
if (cursor !== undefined && cursor !== null && typeof cursor !== 'string') {
|
|
80
|
+
throw new TargetProtocolError('CapsuleListPage: "cursor" must be a string if present');
|
|
81
|
+
}
|
|
82
|
+
if (typeof cursor === 'string') {
|
|
83
|
+
return { items, cursor };
|
|
84
|
+
}
|
|
85
|
+
return { items };
|
|
86
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { MAX_RETRY_ATTEMPTS, MAX_RETRY_ELAPSED_MS } from './bounds.ts';
|
|
2
|
+
import type { TargetAdapterError } from './errors.ts';
|
|
3
|
+
import { TargetAuthError, TargetRateLimitError } from './errors.ts';
|
|
4
|
+
import type { Clock, RetryPolicy } from './types.ts';
|
|
5
|
+
|
|
6
|
+
export class DefaultRetryPolicy implements RetryPolicy {
|
|
7
|
+
shouldRetry(
|
|
8
|
+
attempt: number,
|
|
9
|
+
elapsed: number,
|
|
10
|
+
error: TargetAdapterError,
|
|
11
|
+
): { retry: boolean; delayMs: number } {
|
|
12
|
+
if (error instanceof TargetAuthError) return { retry: false, delayMs: 0 };
|
|
13
|
+
if (!error.retryable) return { retry: false, delayMs: 0 };
|
|
14
|
+
if (attempt >= MAX_RETRY_ATTEMPTS) return { retry: false, delayMs: 0 };
|
|
15
|
+
if (elapsed >= MAX_RETRY_ELAPSED_MS) return { retry: false, delayMs: 0 };
|
|
16
|
+
|
|
17
|
+
const requestedDelay =
|
|
18
|
+
error instanceof TargetRateLimitError && error.retryAfterMs !== undefined
|
|
19
|
+
? Math.max(0, error.retryAfterMs)
|
|
20
|
+
: Math.min(1000 * Math.pow(2, attempt), 10_000);
|
|
21
|
+
const remaining = MAX_RETRY_ELAPSED_MS - elapsed;
|
|
22
|
+
if (!Number.isFinite(requestedDelay) || requestedDelay >= remaining) {
|
|
23
|
+
return { retry: false, delayMs: 0 };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return { retry: true, delayMs: requestedDelay };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function parseRetryAfter(header: string | null, clock: Clock): number | null {
|
|
31
|
+
if (header === null) return null;
|
|
32
|
+
|
|
33
|
+
const trimmed = header.trim();
|
|
34
|
+
if (trimmed.length === 0) return null;
|
|
35
|
+
|
|
36
|
+
const numericSeconds = Number(trimmed);
|
|
37
|
+
if (Number.isFinite(numericSeconds) && numericSeconds >= 0) {
|
|
38
|
+
return Math.ceil(numericSeconds * 1000);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const date = Date.parse(trimmed);
|
|
42
|
+
if (Number.isNaN(date)) return null;
|
|
43
|
+
|
|
44
|
+
const delayMs = date - clock.now();
|
|
45
|
+
return delayMs > 0 ? Math.ceil(delayMs) : 0;
|
|
46
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { AllocateRequest, Capsule, CapsuleAccess, CapsuleLimits, CapsuleListPage } from './types.ts';
|
|
2
|
+
|
|
3
|
+
export interface TargetAdapter {
|
|
4
|
+
allocate(req: AllocateRequest, signal?: AbortSignal): Promise<Capsule>;
|
|
5
|
+
list(cursor?: string, signal?: AbortSignal): Promise<CapsuleListPage>;
|
|
6
|
+
inspect(capsuleId: string, signal?: AbortSignal): Promise<Capsule>;
|
|
7
|
+
terminate(capsuleId: string, signal?: AbortSignal): Promise<void>;
|
|
8
|
+
limits(signal?: AbortSignal): Promise<CapsuleLimits>;
|
|
9
|
+
access(capsuleId: string, signal?: AbortSignal): Promise<CapsuleAccess>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { TargetAdapterError } from './errors.ts';
|
|
2
|
+
|
|
3
|
+
export interface TargetAccessTokenProvider {
|
|
4
|
+
getAccessToken(signal?: AbortSignal): Promise<string>;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type CapsuleState = 'provisioning' | 'running' | 'stopping' | 'terminated' | 'failed' | (string & {});
|
|
8
|
+
|
|
9
|
+
export const KNOWN_CAPSULE_STATES = ['provisioning', 'running', 'stopping', 'terminated', 'failed'] as const;
|
|
10
|
+
|
|
11
|
+
export interface Capsule {
|
|
12
|
+
readonly id: string;
|
|
13
|
+
readonly state: CapsuleState;
|
|
14
|
+
readonly createdAt: string;
|
|
15
|
+
readonly [key: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CapsuleAccess {
|
|
19
|
+
readonly endpoint: string;
|
|
20
|
+
readonly token: string;
|
|
21
|
+
readonly expiresAt: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CapsuleListPage {
|
|
25
|
+
readonly items: readonly Capsule[];
|
|
26
|
+
readonly cursor?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface CapsuleLimits {
|
|
30
|
+
readonly maxConcurrent: number;
|
|
31
|
+
readonly maxPerHour: number;
|
|
32
|
+
readonly [key: string]: unknown;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface AllocateRequest {
|
|
36
|
+
readonly idempotencyKey: string;
|
|
37
|
+
readonly profile: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface HttpTransport {
|
|
41
|
+
fetch(url: string, init: RequestInit & { redirect: 'error' }): Promise<Response>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface Clock {
|
|
45
|
+
now(): number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface RetryPolicy {
|
|
49
|
+
shouldRetry(
|
|
50
|
+
attempt: number,
|
|
51
|
+
elapsed: number,
|
|
52
|
+
error: TargetAdapterError,
|
|
53
|
+
): { retry: boolean; delayMs: number };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface TargetDiscovery {
|
|
57
|
+
readonly capsuleV1: string;
|
|
58
|
+
}
|