@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 +2 -2
- package/dist/runtime.js +30 -9
- package/dist/secrets.d.ts +7 -2
- package/dist/secrets.js +27 -8
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
/**
|
|
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 {
|
|
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.
|
|
49
|
-
|
|
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.
|
|
71
|
+
return read.outcome === 'done' ? read.body.SecretString ?? null : null;
|
|
56
72
|
},
|
|
57
|
-
/**
|
|
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 });
|