@isomorph.ai/app-sdk 1.36.0 → 1.36.2

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 CHANGED
@@ -452,10 +452,10 @@ await runtimeSecrets.delete(`supplier-key/${ctx.user.id}`);
452
452
 
453
453
  - Names match `^[A-Za-z0-9][A-Za-z0-9/_+=.@-]{0,199}$`; values are strings up to 64 KB.
454
454
  - Each is one Secrets Manager secret in the app's own cell, under `ISOMORPH_RUNTIME_SECRETS_PREFIX` (the app's secret path plus `runtime/`, in `ISOMORPH_RUNTIME_SECRETS_REGION`); Provision sets both. The calls are signed as the runtime's own role, which may use only this app's runtime secrets. Secrets the builder set stay read-only.
455
- - `delete` removes the secret at once, with no recovery window, so the same name can be saved again straight away.
455
+ - `delete` removes the secret with no recovery window. Secrets Manager finishes deleting it within seconds; until then `get` returns null, and a `put` of the same name waits for the deletion to finish (up to about 25 seconds) before saving.
456
456
  - Each one costs $0.40 a month while it exists.
457
457
 
458
- (1.36.0; data plane ADR 0090)
458
+ (1.36.0, 1.36.1; data plane ADR 0090)
459
459
 
460
460
  ## Running and rollout
461
461
 
package/dist/runtime.js CHANGED
@@ -237,18 +237,31 @@ export class Runs {
237
237
  const interrupted = expired.filter(run => !active.has(run.id));
238
238
  for (const run of interrupted) {
239
239
  const exhausted = run.attempt >= run.max_attempts;
240
- await this.table().update({ status: exhausted ? 'failed' : 'queued', error: exhausted ? 'the worker was interrupted and no attempts remain' : run.error, lease_until: null, updated_at: now.toISOString() }).eq('id', run.id).eq('status', 'running');
240
+ const status = run.cancel_requested ? 'cancelled' : exhausted ? 'failed' : 'queued';
241
+ const { data } = await this.table().update({ status, error: status === 'failed' ? 'the worker was interrupted and no attempts remain' : run.error, lease_until: null, updated_at: now.toISOString() }).eq('id', run.id).eq('status', 'running').eq('cancel_requested', run.cancel_requested).select('id').maybeSingle();
242
+ // Cancellation may have arrived between reading the expired lease and
243
+ // writing its retry. It wins that race without leaving another attempt.
244
+ if (!data && !run.cancel_requested)
245
+ await this.table().update({ status: 'cancelled', lease_until: null, updated_at: now.toISOString() }).eq('id', run.id).eq('status', 'running').eq('cancel_requested', true);
241
246
  }
242
247
  return interrupted.length;
243
248
  }
244
249
  /** Claims the oldest queued run by a conditional update; null when the queue is empty or another worker won. */
245
250
  async claim(now) {
246
- const { data: queued } = await this.table().select('id, attempt').eq('status', 'queued').order('created_at').limit(1);
247
- const next = queued[0];
248
- if (!next)
249
- return null;
250
- const { data } = await this.table().update({ status: 'running', attempt: next.attempt + 1, lease_until: new Date(now.getTime() + LEASE_MS).toISOString(), updated_at: now.toISOString() }).eq('id', next.id).eq('status', 'queued').select('*').maybeSingle();
251
- return data;
251
+ while (true) {
252
+ const { data: queued } = await this.table().select('id, attempt, cancel_requested').eq('status', 'queued').order('created_at').limit(1);
253
+ const next = queued[0];
254
+ if (!next)
255
+ return null;
256
+ if (next.cancel_requested) {
257
+ const { data } = await this.table().update({ status: 'cancelled', lease_until: null, updated_at: now.toISOString() }).eq('id', next.id).eq('status', 'queued').eq('cancel_requested', true).select('id').maybeSingle();
258
+ if (!data)
259
+ return null;
260
+ continue;
261
+ }
262
+ const { data } = await this.table().update({ status: 'running', attempt: next.attempt + 1, lease_until: new Date(now.getTime() + LEASE_MS).toISOString(), updated_at: now.toISOString() }).eq('id', next.id).eq('status', 'queued').eq('cancel_requested', false).select('*').maybeSingle();
263
+ return data;
264
+ }
252
265
  }
253
266
  async progress(id, percent, note) {
254
267
  await this.table().update({ progress: Math.max(0, Math.min(100, Math.round(percent))), ...(note === undefined ? {} : { note }), lease_until: new Date(Date.now() + LEASE_MS).toISOString(), updated_at: new Date().toISOString() }).eq('id', id);
@@ -261,8 +274,16 @@ export class Runs {
261
274
  async finish(run, outcome) {
262
275
  const retry = outcome.error !== undefined && !outcome.cancelled && run.attempt < run.max_attempts;
263
276
  const status = outcome.cancelled ? 'cancelled' : outcome.error === undefined ? 'succeeded' : retry ? 'queued' : 'failed';
264
- await this.table().update({ status, result: outcome.result ?? null, error: outcome.error ?? null, progress: status === 'succeeded' ? 100 : run.progress, lease_until: null, updated_at: new Date().toISOString() }).eq('id', run.id).eq('status', 'running');
265
- return status;
277
+ const now = new Date().toISOString();
278
+ if (!outcome.cancelled) {
279
+ const { data } = await this.table().update({ status, result: outcome.result ?? null, error: outcome.error ?? null, progress: status === 'succeeded' ? 100 : run.progress, lease_until: null, updated_at: now }).eq('id', run.id).eq('status', 'running').eq('cancel_requested', false).select('id').maybeSingle();
280
+ if (data)
281
+ return status;
282
+ }
283
+ // The handler may throw after the user cancels. A cancelled run is
284
+ // terminal even when that failure would otherwise be retried.
285
+ await this.table().update({ status: 'cancelled', result: null, error: null, lease_until: null, updated_at: now }).eq('id', run.id).eq('status', 'running').eq('cancel_requested', true);
286
+ return 'cancelled';
266
287
  }
267
288
  }
268
289
  export class Runtime {
package/dist/secrets.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export interface RuntimeSecretsOptions {
2
2
  env?: NodeJS.ProcessEnv;
3
3
  fetch?: typeof fetch;
4
+ /** The waits before put tries again to create a secret whose deletion Secrets Manager is still finishing. */
5
+ retryDelaysMs?: readonly number[];
4
6
  }
5
7
  /** Where a runtime secret lives; throws, before any call, for a bad name or an app without runtime secrets. */
6
8
  export declare function runtimeSecretLocation(name: string, env?: NodeJS.ProcessEnv): {
@@ -10,8 +12,11 @@ export declare function runtimeSecretLocation(name: string, env?: NodeJS.Process
10
12
  export declare const runtimeSecrets: {
11
13
  /** Saves `value` under `name`, creating the secret the first time. */
12
14
  put(name: string, value: string, options?: RuntimeSecretsOptions): Promise<void>;
13
- /** The value saved under `name`, or null when there is none. */
15
+ /** The value saved under `name`, or null when there is none or it is being deleted. */
14
16
  get(name: string, options?: RuntimeSecretsOptions): Promise<string | null>;
15
- /** Deletes the secret at once, so the same name can be saved again straight away; a missing one is already deleted. */
17
+ /**
18
+ * Deletes the secret with no recovery window; a missing one is already deleted. Secrets Manager finishes the
19
+ * deletion within seconds, and `put` waits for it before saving the same name again.
20
+ */
16
21
  delete(name: string, options?: RuntimeSecretsOptions): Promise<void>;
17
22
  };
package/dist/secrets.js CHANGED
@@ -6,9 +6,14 @@
6
6
  // The calls are signed as the runtime's own role, as the computer use tool's
7
7
  // are (ADR 0078); no AWS SDK client is added.
8
8
  import { randomUUID } from 'node:crypto';
9
+ import { setTimeout as sleep } from 'node:timers/promises';
9
10
  import { runtimeCredentials, signedJsonPost } from './aws.js';
10
11
  const NAME = /^[A-Za-z0-9][A-Za-z0-9/_+=.@-]{0,199}$/;
11
12
  const MAX_VALUE_BYTES = 64 * 1024;
13
+ // A deleted secret stays "marked for deletion" for some seconds: reads and writes are refused and its name
14
+ // cannot be created again until Secrets Manager finishes deleting it.
15
+ const DELETION_RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 10_000];
16
+ const BEING_DELETED = /(marked|scheduled) for deletion/;
12
17
  /** Where a runtime secret lives; throws, before any call, for a bad name or an app without runtime secrets. */
13
18
  export function runtimeSecretLocation(name, env = process.env) {
14
19
  if (typeof name !== 'string' || !NAME.test(name))
@@ -27,12 +32,14 @@ function secretsManager(name, { env = process.env, fetch }) {
27
32
  ...transport, headers: { 'content-type': 'application/x-amz-json-1.1', 'x-amz-target': `secretsmanager.${action}` },
28
33
  });
29
34
  if (status === 200)
30
- return { missing: false, body };
35
+ return { outcome: 'done', body };
31
36
  const answer = body;
32
37
  const type = typeof answer?.__type === 'string' ? answer.__type.replace(/^.*#/, '').replace(/:.*$/, '') : '';
33
- if (type === 'ResourceNotFoundException')
34
- return { missing: true, body };
35
38
  const message = answer?.message ?? answer?.Message;
39
+ if (type === 'ResourceNotFoundException')
40
+ return { outcome: 'missing', body };
41
+ if (type === 'InvalidRequestException' && typeof message === 'string' && BEING_DELETED.test(message))
42
+ return { outcome: 'deleting', body };
36
43
  throw new Error(`Secrets Manager refused ${action} on the runtime secret ${name} (${status}${type ? ` ${type}` : ''}${typeof message === 'string' ? `: ${message}` : ''}).`);
37
44
  };
38
45
  return { secretId, send };
@@ -45,16 +52,28 @@ export const runtimeSecrets = {
45
52
  throw new Error(`The runtime secret ${name} must be a string of at most 64 KB.`);
46
53
  // Secrets Manager wants each new version's idempotency token from a caller that is not an AWS SDK.
47
54
  const saved = await send('PutSecretValue', { SecretId: secretId, SecretString: value, ClientRequestToken: randomUUID() });
48
- if (saved.missing)
49
- await send('CreateSecret', { Name: secretId, SecretString: value, ClientRequestToken: randomUUID() });
55
+ if (saved.outcome === 'done')
56
+ return;
57
+ const delays = options.retryDelaysMs ?? DELETION_RETRY_DELAYS_MS;
58
+ for (let attempt = 0;; attempt++) {
59
+ const created = await send('CreateSecret', { Name: secretId, SecretString: value, ClientRequestToken: randomUUID() });
60
+ if (created.outcome === 'done')
61
+ return;
62
+ if (attempt >= delays.length)
63
+ throw new Error(`The runtime secret ${name} is still being deleted; save it again in a minute.`);
64
+ await sleep(delays[attempt]);
65
+ }
50
66
  },
51
- /** The value saved under `name`, or null when there is none. */
67
+ /** The value saved under `name`, or null when there is none or it is being deleted. */
52
68
  async get(name, options = {}) {
53
69
  const { secretId, send } = secretsManager(name, options);
54
70
  const read = await send('GetSecretValue', { SecretId: secretId });
55
- return read.missing ? null : read.body.SecretString ?? null;
71
+ return read.outcome === 'done' ? read.body.SecretString ?? null : null;
56
72
  },
57
- /** Deletes the secret at once, so the same name can be saved again straight away; a missing one is already deleted. */
73
+ /**
74
+ * Deletes the secret with no recovery window; a missing one is already deleted. Secrets Manager finishes the
75
+ * deletion within seconds, and `put` waits for it before saving the same name again.
76
+ */
58
77
  async delete(name, options = {}) {
59
78
  const { secretId, send } = secretsManager(name, options);
60
79
  await send('DeleteSecret', { SecretId: secretId, ForceDeleteWithoutRecovery: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isomorph.ai/app-sdk",
3
- "version": "1.36.0",
3
+ "version": "1.36.2",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",