@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 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
- (task) => task.status === 'running' && isProcessRunning(task.pid)
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 clusterResults = await orchestrator.killAll();
2142
- for (const id of clusterResults.killed) {
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
- for (const err of clusterResults.errors) {
2146
- console.log(chalk.red(`✗ Failed to kill cluster ${err.id}: ${err.error}`));
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, isProcessRunning) {
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 { killTask } = await import('../task-lib/runner.js');
2156
- const { updateTask } = await import('../task-lib/store.js');
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
- if (!isProcessRunning(task.pid)) {
2160
- updateTask(task.id, {
2161
- status: 'stale',
2162
- error: 'Process died unexpectedly',
2163
- });
2164
- console.log(chalk.yellow(`○ Task ${task.id} was already dead, marked stale`));
2165
- continue;
2166
- }
2167
-
2168
- const killed = killTask(task.pid);
2169
- if (killed) {
2170
- updateTask(task.id, { status: 'killed', error: 'Killed by clear' });
2171
- console.log(chalk.green(`✓ Killed task: ${task.id}`));
2172
- } else {
2173
- console.log(chalk.red(`✗ Failed to kill task: ${task.id}`));
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 } = await import(
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
- // Kill clusters
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
- const killed = killTask(task.pid);
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, purgeData.isProcessRunning);
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.25.0",
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
- if (!Object.keys(value).every((key) => IDENTITY_KEYS.has(key))) return null;
71
- if (!DECIMAL_STRING.test(String(value.device)) || !DECIMAL_STRING.test(String(value.inode))) {
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
- return { device: String(value.device), inode: String(value.inode) };
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(ompOwnership?.state === 'committed' ? ompOwnership.session?.sessionId : null)
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
+ }