@isomorph.ai/app-sdk 1.43.0 → 1.45.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/README.md +13 -2
- package/dist/computer-use-api.js +2 -1
- package/dist/computer-use-handoff-tool.d.ts +2 -2
- package/dist/computer-use.d.ts +32 -14
- package/dist/computer-use.js +15 -12
- package/dist/index.d.ts +8 -0
- package/dist/index.js +2 -0
- package/dist/integration-execute-input-rules.d.ts +5 -0
- package/dist/integration-execute-input-rules.js +1 -0
- package/dist/runtime.d.ts +4 -1
- package/dist/runtime.js +44 -1
- package/dist/secrets.d.ts +6 -1
- package/dist/secrets.js +56 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -411,11 +411,12 @@ Options the app passes, never the model:
|
|
|
411
411
|
- `secrets`: `{ name: value }`, at most 10, names `^[A-Za-z][A-Za-z0-9_-]{0,63}$`, values up to 4096 bytes. The computer types a value into a password field by its name, and only there: it refuses to type a secret into a visible field, since the model reads the page after every step. The model sees only the names. Never put a password or any other credential in the task text; pass it here and say "sign in with the `password` secret". A user ID, customer ID or email is not a secret: give it in the task text.
|
|
412
412
|
- `maxQueueMs`: how long the task may wait for a free computer before the tool cancels it (20 minutes unless set).
|
|
413
413
|
- `allowedHosts`: the websites (1 to 10 lower-case hostnames) a task with `secrets` and no `session` may use. A saved sign-in carries its own.
|
|
414
|
+
- The sign-in options are one of three shapes, and the compiler refuses the others, so `isomorph check` (which runs tsc) catches them before a run: `session` (with or without `secrets`), `secrets` with `allowedHosts`, or neither. `allowedHosts` alone is refused: a task without secrets may use any website, and a task without a session or hosts has nowhere to type secrets. A wrong option at run time throws a `VALIDATION_FAILED` error, which the job runner fails at once rather than retrying.
|
|
414
415
|
- `onDownload({ name, contentType, size, blob })`: called once for each file a `done` task downloaded, one at a time, before the tool returns (at most 10 files, 100 MiB each). Store the `blob` yourself; the CUA keeps the files for a day at most. Without it nothing is fetched. A link that does not answer 200 fails the tool. The model only sees `downloads: [{ name, size }]`.
|
|
415
416
|
|
|
416
417
|
A task with `session` or `secrets` works the page through clicks and typing only. `connectionId` is gone: pass `session`.
|
|
417
418
|
|
|
418
|
-
(1.25.0, 1.36.0, 1.37.0, 1.39.0; data plane ADR 0078, ADR 0090, ADR 0091, ADR 0092)
|
|
419
|
+
(1.25.0, 1.36.0, 1.37.0, 1.39.0, 1.45.0; data plane ADR 0078, ADR 0090, ADR 0091, ADR 0092, ADR 2026-10-26 job runs)
|
|
419
420
|
|
|
420
421
|
## Help on the task's existing computer
|
|
421
422
|
|
|
@@ -516,8 +517,10 @@ await runtimeSecrets.delete(`supplier-key/${ctx.user.id}`);
|
|
|
516
517
|
- 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.
|
|
517
518
|
- `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.
|
|
518
519
|
- Each one costs $0.40 a month while it exists.
|
|
520
|
+
- Under `isomorph dev` there is no cell: the CLI sets `ISOMORPH_RUNTIME_SECRETS_DIR` and each secret is one private file (`0600`) in that folder, under `.isomorph/local/`. Computer use itself still needs the deployed app.
|
|
521
|
+
- A bad name or value, or an app with neither setting, throws a `VALIDATION_FAILED` error before any call; the job runner fails such a run at once.
|
|
519
522
|
|
|
520
|
-
(1.36.0, 1.36.1; data plane ADR 0090)
|
|
523
|
+
(1.36.0, 1.36.1, 1.45.0; data plane ADR 0090)
|
|
521
524
|
|
|
522
525
|
## Running and rollout
|
|
523
526
|
|
|
@@ -639,6 +642,14 @@ On a serverless cell Harbour runs an app's server code as Lambda functions, from
|
|
|
639
642
|
|
|
640
643
|
(1.24.0; data plane ADR 0065)
|
|
641
644
|
|
|
645
|
+
### Job runs
|
|
646
|
+
|
|
647
|
+
A queued run (`isomorph.jobs.enqueue`) is tried up to three times; a failure leaves the next attempt queued. A `VALIDATION_FAILED` error (an `IsomorphError` of that category, or any error whose `code` is `VALIDATION_FAILED`: a wrong computer use option, no runtime secrets here, a job's own input check) fails the run at the first attempt, with `error` prefixed `VALIDATION_FAILED:`.
|
|
648
|
+
|
|
649
|
+
`isomorph.jobs.get(id)` and `cancel(id)` work on the signed-in person's own runs. `isomorph.jobs.list({ job?, limit? })` (reserved action `isomorph-jobs-list`) shows a builder every person's newest runs, one job's when named, at most 100 (20 unless set): `id, job, status, attempt, max_attempts, error, note, created_at, updated_at`, never a run's input or result. A member gets `FORBIDDEN`. In the browser, `GET /_harbour/jobs` on the app's API host is the same list as a plain page: the gateway asks `isomorph-jobs-list` for `text/html` (the newest 50), and the runtime answers the table or the same `FORBIDDEN`.
|
|
650
|
+
|
|
651
|
+
(1.45.0; data plane ADR 2026-10-26 job runs)
|
|
652
|
+
|
|
642
653
|
### Email-arrival jobs
|
|
643
654
|
|
|
644
655
|
On managed same-account serverless or Kubernetes hosting, an ordinary job can declare an inbox trigger instead of a schedule:
|
package/dist/computer-use-api.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Harbour's computer use API (CUA) as the runtime's own role, signed with
|
|
2
2
|
// SigV4 (ADR 0078): what the computer use tool and the sign-in helpers share.
|
|
3
3
|
import { runtimeCredentials, signedJsonPost } from './aws.js';
|
|
4
|
+
import { IsomorphError } from './index.js';
|
|
4
5
|
export function refused(path, status, body) {
|
|
5
6
|
const error = body?.error;
|
|
6
7
|
return new Error(`The computer use API refused ${path} (${status}${typeof error === 'string' ? `: ${error}` : ''}).`);
|
|
@@ -27,7 +28,7 @@ const HOST = /^[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?$/;
|
|
|
27
28
|
/** The websites a sign-in or a task may use: 1 to 10 lower-case hostnames, copied. */
|
|
28
29
|
export function allowedHostsOf(hosts) {
|
|
29
30
|
if (!Array.isArray(hosts) || hosts.length < 1 || hosts.length > 10 || hosts.some(host => typeof host !== 'string' || !HOST.test(host))) {
|
|
30
|
-
throw new
|
|
31
|
+
throw new IsomorphError('VALIDATION_FAILED', 'Computer use needs 1 to 10 lower-case website hostnames in allowedHosts, such as vendorcentral.amazon.com.');
|
|
31
32
|
}
|
|
32
33
|
return [...hosts];
|
|
33
34
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { type ComputerUseHandoffOptions, type ComputerUsePage, type ComputerUseWaiting } from './computer-use-handoff.js';
|
|
2
2
|
import { type ComputerUseOptions } from './computer-use.js';
|
|
3
|
-
export
|
|
3
|
+
export type ComputerUseHandoffToolOptions = ComputerUseHandoffOptions & ComputerUseOptions & {
|
|
4
4
|
onInputRequired(input: ComputerUseWaiting, page: ComputerUsePage): Promise<void> | void;
|
|
5
5
|
canResume(pauseId: string): Promise<boolean> | boolean;
|
|
6
|
-
}
|
|
6
|
+
};
|
|
7
7
|
export declare function createComputerUseHandoffTools(options: ComputerUseHandoffToolOptions): {
|
|
8
8
|
request_computer_input: ({
|
|
9
9
|
deferLoading?: boolean;
|
package/dist/computer-use.d.ts
CHANGED
|
@@ -10,22 +10,9 @@ export interface ComputerUseDownload {
|
|
|
10
10
|
size: number;
|
|
11
11
|
blob: Blob;
|
|
12
12
|
}
|
|
13
|
-
|
|
13
|
+
interface ComputerUseCommonOptions {
|
|
14
14
|
/** App-authorized PDFs, at most five files and 2 MiB total. Never supplied by the model. */
|
|
15
15
|
files?: readonly ComputerUseFile[];
|
|
16
|
-
/**
|
|
17
|
-
* The runtime secret a sign-in was saved under (finishSignIn's `saveAs`): the task starts signed in,
|
|
18
|
-
* and the refreshed session is saved back to it when the task is done. Never supplied by the model.
|
|
19
|
-
*/
|
|
20
|
-
session?: string;
|
|
21
|
-
/** The websites a task with `secrets` and no `session` may use. */
|
|
22
|
-
allowedHosts?: readonly string[];
|
|
23
|
-
/**
|
|
24
|
-
* Values the computer types into a password field by name, at most 10; the model sees only the names.
|
|
25
|
-
* Never put a credential in the task text. A user ID, customer ID or email is not a secret: the
|
|
26
|
-
* computer refuses to type a secret into a visible field, so give those in the task text.
|
|
27
|
-
*/
|
|
28
|
-
secrets?: Readonly<Record<string, string>>;
|
|
29
16
|
/** The app's code gets each file the task downloaded, one at a time, when the task is done. Never seen by the model. */
|
|
30
17
|
onDownload?(download: ComputerUseDownload): Promise<void> | void;
|
|
31
18
|
/** The job's runId, or `<job name>/<scheduledAt>` for a scheduled job: one task record per job run. */
|
|
@@ -47,6 +34,36 @@ export interface ComputerUseOptions {
|
|
|
47
34
|
pollIntervalMs?: number;
|
|
48
35
|
now?: () => number;
|
|
49
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Values the computer types into a password field by name, at most 10; the model sees only the names.
|
|
39
|
+
* Never put a credential in the task text. A user ID, customer ID or email is not a secret: the
|
|
40
|
+
* computer refuses to type a secret into a visible field, so give those in the task text.
|
|
41
|
+
*/
|
|
42
|
+
export type ComputerUseSecrets = Readonly<Record<string, string>>;
|
|
43
|
+
/**
|
|
44
|
+
* How the task signs in, one of three shapes so the compiler refuses the others (`isomorph check` runs it):
|
|
45
|
+
* a saved `session`, `secrets` with the `allowedHosts` they may be typed into, or neither.
|
|
46
|
+
* `allowedHosts` alone and `secrets` alone are refused; a session carries its own websites.
|
|
47
|
+
*/
|
|
48
|
+
export type ComputerUseSignIn = {
|
|
49
|
+
/**
|
|
50
|
+
* The runtime secret a sign-in was saved under (finishSignIn's `saveAs`): the task starts signed in,
|
|
51
|
+
* and the refreshed session is saved back to it when the task is done. Never supplied by the model.
|
|
52
|
+
*/
|
|
53
|
+
session: string;
|
|
54
|
+
secrets?: ComputerUseSecrets;
|
|
55
|
+
allowedHosts?: never;
|
|
56
|
+
} | {
|
|
57
|
+
secrets: ComputerUseSecrets;
|
|
58
|
+
/** The websites a task with `secrets` and no `session` may use. */
|
|
59
|
+
allowedHosts: readonly string[];
|
|
60
|
+
session?: never;
|
|
61
|
+
} | {
|
|
62
|
+
session?: never;
|
|
63
|
+
secrets?: never;
|
|
64
|
+
allowedHosts?: never;
|
|
65
|
+
};
|
|
66
|
+
export type ComputerUseOptions = ComputerUseCommonOptions & ComputerUseSignIn;
|
|
50
67
|
export interface ComputerUseFinished {
|
|
51
68
|
status: 'done' | 'failed';
|
|
52
69
|
summary: string | null;
|
|
@@ -262,3 +279,4 @@ export declare function createComputerUseTool(options: ComputerUseOptions): ({
|
|
|
262
279
|
task: string;
|
|
263
280
|
}, ComputerUseResult, NoInfer<import("@ai-sdk/provider-utils").Context>>;
|
|
264
281
|
});
|
|
282
|
+
export {};
|
package/dist/computer-use.js
CHANGED
|
@@ -8,7 +8,10 @@ import { setTimeout as sleep } from 'node:timers/promises';
|
|
|
8
8
|
import { jsonSchema, tool } from 'ai';
|
|
9
9
|
import { allowedHostsOf, computerUseApi, refused } from './computer-use-api.js';
|
|
10
10
|
import { runtimeSecretLocation, runtimeSecrets } from './secrets.js';
|
|
11
|
+
import { IsomorphError } from './index.js';
|
|
11
12
|
import { computerUseMessage, waitingInput } from './computer-use-handoff.js';
|
|
13
|
+
// A wrong option is the app's code, not the computer: the job runner fails such a run without retrying it.
|
|
14
|
+
const invalid = (message) => new IsomorphError('VALIDATION_FAILED', message);
|
|
12
15
|
const HEARTBEAT_MS = 60_000;
|
|
13
16
|
const DEFAULT_MAX_QUEUE_MS = 20 * 60_000;
|
|
14
17
|
// Checks the API did not answer (a throttle, a 5xx, no connection) before the tool gives up: about a minute.
|
|
@@ -25,43 +28,43 @@ function queueNote(answer, waitedMs) {
|
|
|
25
28
|
function secretsOf(secrets) {
|
|
26
29
|
const entries = secrets && typeof secrets === 'object' && !Array.isArray(secrets) ? Object.entries(secrets) : [];
|
|
27
30
|
if (entries.length < 1 || entries.length > 10)
|
|
28
|
-
throw
|
|
31
|
+
throw invalid('Computer use secrets are 1 to 10 named values.');
|
|
29
32
|
for (const [name, value] of entries) {
|
|
30
33
|
if (!/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(name))
|
|
31
|
-
throw
|
|
34
|
+
throw invalid(`${JSON.stringify(name)} is not a computer use secret name: up to 64 letters, digits, _ and -, starting with a letter.`);
|
|
32
35
|
if (typeof value !== 'string' || !value || Buffer.byteLength(value) > 4096)
|
|
33
|
-
throw
|
|
36
|
+
throw invalid(`The computer use secret ${name} must be a non-empty string of at most 4096 bytes.`);
|
|
34
37
|
}
|
|
35
38
|
return Object.fromEntries(entries);
|
|
36
39
|
}
|
|
37
40
|
export function createComputerUseTool(options) {
|
|
38
41
|
if ('connectionId' in options)
|
|
39
|
-
throw
|
|
42
|
+
throw invalid('connectionId is no longer supported; pass session, the runtime secret a sign-in was saved under.');
|
|
40
43
|
const files = options.files?.map(file => ({ ...file }));
|
|
41
44
|
if (files) {
|
|
42
45
|
const names = new Set();
|
|
43
46
|
let total = 0;
|
|
44
47
|
if (files.length > 5)
|
|
45
|
-
throw
|
|
48
|
+
throw invalid("Computer use accepts at most five PDFs.");
|
|
46
49
|
for (const file of files) {
|
|
47
50
|
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,119}\.pdf$/.test(file.name) || names.has(file.name))
|
|
48
|
-
throw
|
|
51
|
+
throw invalid("Computer use PDF names must be unique safe basenames.");
|
|
49
52
|
if (file.contentType !== "application/pdf" || typeof file.dataBase64 !== "string" || file.dataBase64.length > Math.ceil(2 * 1024 * 1024 / 3) * 4)
|
|
50
|
-
throw
|
|
53
|
+
throw invalid("Computer use needs base64 PDFs, at most 2 MiB total.");
|
|
51
54
|
const bytes = Buffer.from(file.dataBase64, "base64");
|
|
52
55
|
total += bytes.length;
|
|
53
56
|
if (bytes.toString("base64") !== file.dataBase64 || bytes.subarray(0, 5).toString() !== "%PDF-" || total > 2 * 1024 * 1024)
|
|
54
|
-
throw
|
|
57
|
+
throw invalid("Computer use needs PDFs, at most 2 MiB total.");
|
|
55
58
|
names.add(file.name);
|
|
56
59
|
}
|
|
57
60
|
}
|
|
58
61
|
const { session } = options;
|
|
59
62
|
if (session !== undefined && options.allowedHosts !== undefined)
|
|
60
|
-
throw
|
|
63
|
+
throw invalid('Pass session or allowedHosts, not both: a saved sign-in carries its own websites.');
|
|
61
64
|
if (options.allowedHosts !== undefined && options.secrets === undefined)
|
|
62
|
-
throw
|
|
65
|
+
throw invalid('allowedHosts is only for a task with secrets and no session.');
|
|
63
66
|
if (options.secrets !== undefined && session === undefined && options.allowedHosts === undefined)
|
|
64
|
-
throw
|
|
67
|
+
throw invalid('A task with secrets needs session or allowedHosts: the websites the computer may type them into.');
|
|
65
68
|
const allowedHosts = options.allowedHosts === undefined ? undefined : allowedHostsOf(options.allowedHosts);
|
|
66
69
|
const secrets = options.secrets === undefined ? undefined : secretsOf(options.secrets);
|
|
67
70
|
const transport = { env: options.env ?? process.env, fetch: options.fetch };
|
|
@@ -69,7 +72,7 @@ export function createComputerUseTool(options) {
|
|
|
69
72
|
runtimeSecretLocation(session, transport.env);
|
|
70
73
|
const api = computerUseApi(transport);
|
|
71
74
|
if (!options.runKey)
|
|
72
|
-
throw
|
|
75
|
+
throw invalid('createComputerUseTool needs the job run as runKey.');
|
|
73
76
|
const now = options.now ?? Date.now;
|
|
74
77
|
const savedSession = async (name) => {
|
|
75
78
|
const stored = await runtimeSecrets.get(name, transport);
|
package/dist/index.d.ts
CHANGED
|
@@ -1161,6 +1161,7 @@ export type ZohoBooksRequest = {
|
|
|
1161
1161
|
input: {
|
|
1162
1162
|
customerId: string;
|
|
1163
1163
|
pageToken?: string;
|
|
1164
|
+
orderStatus?: 'all';
|
|
1164
1165
|
};
|
|
1165
1166
|
} | {
|
|
1166
1167
|
operation: 'zohobooks.salesorder.read' | 'zohobooks.creditnote.read';
|
|
@@ -1787,6 +1788,8 @@ export interface JobRun {
|
|
|
1787
1788
|
created_at: string;
|
|
1788
1789
|
updated_at: string;
|
|
1789
1790
|
}
|
|
1791
|
+
/** One row of `jobs.list`: a run's outcome without its input or result. */
|
|
1792
|
+
export type JobRunSummary = Pick<JobRun, 'id' | 'job' | 'status' | 'attempt' | 'max_attempts' | 'error' | 'note' | 'created_at' | 'updated_at'>;
|
|
1790
1793
|
export type AiRole = 'system' | 'user' | 'assistant';
|
|
1791
1794
|
export interface AiMessage {
|
|
1792
1795
|
role: AiRole;
|
|
@@ -2053,6 +2056,11 @@ export declare function createClient(options?: ClientOptions): {
|
|
|
2053
2056
|
}>;
|
|
2054
2057
|
get: (id: string) => Promise<JobRun>;
|
|
2055
2058
|
cancel: (id: string) => Promise<JobRun>;
|
|
2059
|
+
/** A builder's view of the newest runs, every person's, one job's when named; at most 100. */
|
|
2060
|
+
list: (options?: {
|
|
2061
|
+
job?: string;
|
|
2062
|
+
limit?: number;
|
|
2063
|
+
}) => Promise<JobRunSummary[]>;
|
|
2056
2064
|
};
|
|
2057
2065
|
telemetry: {
|
|
2058
2066
|
track: (name: string, properties?: Record<string, unknown>) => Promise<void>;
|
package/dist/index.js
CHANGED
|
@@ -817,6 +817,8 @@ export function createClient(options = {}) {
|
|
|
817
817
|
enqueue: (name, input) => transport.json('/_harbour/actions/isomorph-jobs-enqueue', { method: 'POST', body: JSON.stringify({ name, input: input ?? null }) }),
|
|
818
818
|
get: (id) => transport.json('/_harbour/actions/isomorph-jobs-get', { method: 'POST', body: JSON.stringify({ id }) }),
|
|
819
819
|
cancel: (id) => transport.json('/_harbour/actions/isomorph-jobs-cancel', { method: 'POST', body: JSON.stringify({ id }) }),
|
|
820
|
+
/** A builder's view of the newest runs, every person's, one job's when named; at most 100. */
|
|
821
|
+
list: (options = {}) => transport.json('/_harbour/actions/isomorph-jobs-list', { method: 'POST', body: JSON.stringify(options) }),
|
|
820
822
|
},
|
|
821
823
|
telemetry: {
|
|
822
824
|
track: (name, properties = {}) => transport.json('/_harbour/telemetry', {
|
|
@@ -2437,6 +2437,11 @@ export declare const IntegrationExecuteInputRules: {
|
|
|
2437
2437
|
readonly minLength: 1;
|
|
2438
2438
|
readonly maxLength: 2048;
|
|
2439
2439
|
};
|
|
2440
|
+
readonly orderStatus: {
|
|
2441
|
+
readonly kind: "enum";
|
|
2442
|
+
readonly required: false;
|
|
2443
|
+
readonly values: readonly ["all"];
|
|
2444
|
+
};
|
|
2440
2445
|
};
|
|
2441
2446
|
};
|
|
2442
2447
|
readonly "zohobooks.salesorder.read": {
|
|
@@ -750,6 +750,7 @@ export const IntegrationExecuteInputRules = {
|
|
|
750
750
|
fields: {
|
|
751
751
|
customerId: { kind: "string", required: true, minLength: 0, maxLength: 32, pattern: "^[0-9]{1,32}$" },
|
|
752
752
|
pageToken: { kind: "string", required: false, minLength: 1, maxLength: 2048 },
|
|
753
|
+
orderStatus: { kind: "enum", required: false, values: ["all"] },
|
|
753
754
|
},
|
|
754
755
|
},
|
|
755
756
|
"zohobooks.salesorder.read": {
|
package/dist/runtime.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type IncomingMessage, type ServerResponse } from 'node:http';
|
|
2
|
-
import { type IsomorphClient, type IsomorphUser, type JobRun } from './index.js';
|
|
2
|
+
import { type IsomorphClient, type IsomorphUser, type JobRun, type JobRunSummary } from './index.js';
|
|
3
3
|
import { type JobDefinition } from './jobs.js';
|
|
4
4
|
import { type LocalGatewayOptions } from './local-gateway.js';
|
|
5
5
|
export { JOBS_DIR, type JobContext } from './jobs.js';
|
|
@@ -10,6 +10,7 @@ export declare const PING_ACTION = "isomorph-ping";
|
|
|
10
10
|
export declare const ENQUEUE_ACTION = "isomorph-jobs-enqueue";
|
|
11
11
|
export declare const GET_RUN_ACTION = "isomorph-jobs-get";
|
|
12
12
|
export declare const CANCEL_RUN_ACTION = "isomorph-jobs-cancel";
|
|
13
|
+
export declare const LIST_RUNS_ACTION = "isomorph-jobs-list";
|
|
13
14
|
export interface ActionContext {
|
|
14
15
|
signal: AbortSignal;
|
|
15
16
|
user: IsomorphUser;
|
|
@@ -59,6 +60,8 @@ export declare class Runs {
|
|
|
59
60
|
id: string;
|
|
60
61
|
}>;
|
|
61
62
|
get(id: string, owner: string): Promise<JobRun | null>;
|
|
63
|
+
/** The newest runs of every person, for a builder: one job's when named. */
|
|
64
|
+
list(job: string | undefined, limit: number): Promise<JobRunSummary[]>;
|
|
62
65
|
cancel(id: string, owner: string): Promise<JobRun | null>;
|
|
63
66
|
/** Rows whose lease ran out go back to the queue while attempts remain, else fail; how many there were. */
|
|
64
67
|
reclaim(now: Date, active?: ReadonlySet<string>): Promise<number>;
|
package/dist/runtime.js
CHANGED
|
@@ -81,6 +81,7 @@ export const PING_ACTION = 'isomorph-ping';
|
|
|
81
81
|
export const ENQUEUE_ACTION = 'isomorph-jobs-enqueue';
|
|
82
82
|
export const GET_RUN_ACTION = 'isomorph-jobs-get';
|
|
83
83
|
export const CANCEL_RUN_ACTION = 'isomorph-jobs-cancel';
|
|
84
|
+
export const LIST_RUNS_ACTION = 'isomorph-jobs-list';
|
|
84
85
|
const LOGICAL_NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
85
86
|
const MAX_BODY_BYTES = 1 << 20;
|
|
86
87
|
const LEASE_MS = 5 * 60_000;
|
|
@@ -94,6 +95,10 @@ const TOKEN_REFRESH_MS = 60_000;
|
|
|
94
95
|
const SESSION_MIN_VALIDITY_MS = 10 * 60_000;
|
|
95
96
|
const JOB_SESSION_MIN_VALIDITY_MS = 15 * 60_000;
|
|
96
97
|
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
98
|
+
const DEFAULT_LIST_LIMIT = 20;
|
|
99
|
+
const MAX_LIST_LIMIT = 100;
|
|
100
|
+
const RUNS_PAGE_LIMIT = 50;
|
|
101
|
+
const LIST_COLUMNS = 'id, job, status, attempt, max_attempts, error, note, created_at, updated_at';
|
|
97
102
|
// A CronJob pod may start while the app's database is still waking: --run-job
|
|
98
103
|
// waits this long for the gateway's data path, probing every retry interval.
|
|
99
104
|
const DATA_PATH_WAIT_MS = 90_000;
|
|
@@ -167,6 +172,17 @@ function refuse(response, status, category, message, requestId) {
|
|
|
167
172
|
answer(response, status, { error: { category, message } }, requestId);
|
|
168
173
|
}
|
|
169
174
|
const REFUSAL_STATUS = { AUTH_REQUIRED: 401, FORBIDDEN: 403, NOT_FOUND: 404, CONFLICT: 409, VALIDATION_FAILED: 400, RATE_LIMITED: 429, UNAVAILABLE: 503, INTERNAL: 500 };
|
|
175
|
+
const escapeHtml = (value) => String(value ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
176
|
+
/** The builder's runs page: one plain table, no script, nothing a run's input or result could inject. */
|
|
177
|
+
function runsPage(app, runs) {
|
|
178
|
+
const rows = runs.map((run) => `<tr><td><code>${escapeHtml(run.id)}</code></td><td>${escapeHtml(run.job)}</td><td>${escapeHtml(run.status)}</td><td>${run.attempt}/${run.max_attempts}</td><td>${escapeHtml(run.updated_at)}</td><td>${escapeHtml(run.note)}</td><td>${escapeHtml(run.error)}</td></tr>`).join('');
|
|
179
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Job runs: ${escapeHtml(app)}</title><style>body{font:14px system-ui,sans-serif;margin:24px}table{border-collapse:collapse}th,td{text-align:left;vertical-align:top;padding:4px 10px;border-bottom:1px solid #ddd}code{font-size:12px}</style></head><body><h1>Job runs of ${escapeHtml(app)}</h1><p>The newest ${runs.length} runs, every person's. A failed run shows its last error; a run that failed at once with <code>VALIDATION_FAILED</code> was the app's own code or setup.</p><table><thead><tr><th>Run</th><th>Job</th><th>Status</th><th>Attempt</th><th>Updated</th><th>Note</th><th>Error</th></tr></thead><tbody>${rows || '<tr><td colspan="7">No runs yet.</td></tr>'}</tbody></table></body></html>`;
|
|
180
|
+
}
|
|
181
|
+
/** True for an IsomorphError of that category, or any error carrying it as `code`; the job's module may hold its own SDK copy, so no instanceof. */
|
|
182
|
+
function isValidationError(error) {
|
|
183
|
+
const { category, code } = (error ?? {});
|
|
184
|
+
return error instanceof Error && (category === 'VALIDATION_FAILED' || code === 'VALIDATION_FAILED');
|
|
185
|
+
}
|
|
170
186
|
/** An action's refusal for the browser: an IsomorphError, or any error carrying one of the SDK's categories as `code`. */
|
|
171
187
|
function refusal(error) {
|
|
172
188
|
if (error instanceof IsomorphError)
|
|
@@ -223,6 +239,14 @@ export class Runs {
|
|
|
223
239
|
const { data } = await this.table().select('*').eq('id', id).eq('owner_subject', owner).maybeSingle();
|
|
224
240
|
return data;
|
|
225
241
|
}
|
|
242
|
+
/** The newest runs of every person, for a builder: one job's when named. */
|
|
243
|
+
async list(job, limit) {
|
|
244
|
+
let query = this.table().select(LIST_COLUMNS);
|
|
245
|
+
if (job)
|
|
246
|
+
query = query.eq('job', job);
|
|
247
|
+
const { data } = await query.order('created_at', { ascending: false }).limit(limit);
|
|
248
|
+
return data;
|
|
249
|
+
}
|
|
226
250
|
async cancel(id, owner) {
|
|
227
251
|
const { data } = await this.table().update({ cancel_requested: true, updated_at: new Date().toISOString() }).eq('id', id).eq('owner_subject', owner).select('*').maybeSingle();
|
|
228
252
|
if (data && data.status === 'queued') {
|
|
@@ -620,7 +644,11 @@ export class Runtime {
|
|
|
620
644
|
await this.runs.finish(run, cancelled ? { cancelled: true } : { result: result === undefined ? null : result });
|
|
621
645
|
}
|
|
622
646
|
catch (error) {
|
|
623
|
-
|
|
647
|
+
// A VALIDATION_FAILED error is the app's code or setup (a wrong computer use option, no runtime
|
|
648
|
+
// secrets here): the same again next time, so the run fails now with its attempts spent.
|
|
649
|
+
const invalid = isValidationError(error);
|
|
650
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
651
|
+
await this.runs.finish(invalid ? { ...run, attempt: run.max_attempts } : run, { error: invalid ? `VALIDATION_FAILED: ${message}` : message });
|
|
624
652
|
}
|
|
625
653
|
return true;
|
|
626
654
|
}
|
|
@@ -699,6 +727,13 @@ export class Runtime {
|
|
|
699
727
|
await this.acceptEventArrival(input);
|
|
700
728
|
return answer(response, 200, { data: { accepted: true } }, requestId);
|
|
701
729
|
}
|
|
730
|
+
if (name === LIST_RUNS_ACTION && header(request, 'accept').toLowerCase().includes('text/html')) {
|
|
731
|
+
// The gateway's GET /_harbour/jobs: a builder's page of the newest runs, opened in the browser.
|
|
732
|
+
const runs = await this.reserved(name, { limit: RUNS_PAGE_LIMIT, ...(input && typeof input === 'object' ? input : {}) }, context);
|
|
733
|
+
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', ...(requestId ? { 'x-request-id': requestId } : {}) });
|
|
734
|
+
response.end(runsPage(app, runs));
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
702
737
|
if (name.startsWith(RESERVED_PREFIX))
|
|
703
738
|
return answer(response, 200, { data: await this.reserved(name, input, context) }, requestId);
|
|
704
739
|
if (!this.inventory.actions.includes(name))
|
|
@@ -803,6 +838,14 @@ export class Runtime {
|
|
|
803
838
|
throw new IsomorphError('NOT_FOUND', 'no run of yours has that id', undefined, undefined, 404);
|
|
804
839
|
return run;
|
|
805
840
|
}
|
|
841
|
+
case LIST_RUNS_ACTION: {
|
|
842
|
+
// Every person's runs, with each failure's error: a builder's view of the app, never a member's.
|
|
843
|
+
if (context.user.role !== 'builder')
|
|
844
|
+
throw new IsomorphError('FORBIDDEN', 'only a builder of this app lists its runs', undefined, undefined, 403);
|
|
845
|
+
const job = typeof body.job === 'string' ? body.job : undefined;
|
|
846
|
+
const limit = typeof body.limit === 'number' && body.limit >= 1 ? Math.min(Math.floor(body.limit), MAX_LIST_LIMIT) : DEFAULT_LIST_LIMIT;
|
|
847
|
+
return this.runs.list(job, limit);
|
|
848
|
+
}
|
|
806
849
|
default:
|
|
807
850
|
throw new IsomorphError('NOT_FOUND', `${name} is not an Isomorph runtime action`, undefined, undefined, 404);
|
|
808
851
|
}
|
package/dist/secrets.d.ts
CHANGED
|
@@ -4,10 +4,15 @@ export interface RuntimeSecretsOptions {
|
|
|
4
4
|
/** The waits before put tries again to create a secret whose deletion Secrets Manager is still finishing. */
|
|
5
5
|
retryDelaysMs?: readonly number[];
|
|
6
6
|
}
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Where a runtime secret lives: a Secrets Manager secret in the app's cell, or a file under `isomorph dev`.
|
|
9
|
+
* Throws, before any call, for a bad name or an app without runtime secrets.
|
|
10
|
+
*/
|
|
8
11
|
export declare function runtimeSecretLocation(name: string, env?: NodeJS.ProcessEnv): {
|
|
9
12
|
secretId: string;
|
|
10
13
|
region: string;
|
|
14
|
+
} | {
|
|
15
|
+
file: string;
|
|
11
16
|
};
|
|
12
17
|
export declare const runtimeSecrets: {
|
|
13
18
|
/** Saves `value` under `name`, creating the secret the first time. */
|
package/dist/secrets.js
CHANGED
|
@@ -4,28 +4,65 @@
|
|
|
4
4
|
// Secrets Manager secret under the app's own `<prefix><org>/<app>/runtime/`
|
|
5
5
|
// path in its cell, which Provision names in ISOMORPH_RUNTIME_SECRETS_PREFIX.
|
|
6
6
|
// The calls are signed as the runtime's own role, as the computer use tool's
|
|
7
|
-
// are (ADR 0078); no AWS SDK client is added.
|
|
7
|
+
// are (ADR 0078); no AWS SDK client is added. Under `isomorph dev` there is
|
|
8
|
+
// no cell: the CLI names a folder in ISOMORPH_RUNTIME_SECRETS_DIR and each
|
|
9
|
+
// secret is one private file there.
|
|
8
10
|
import { randomUUID } from 'node:crypto';
|
|
11
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
12
|
+
import { join } from 'node:path';
|
|
9
13
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
10
14
|
import { runtimeCredentials, signedJsonPost } from './aws.js';
|
|
15
|
+
import { IsomorphError } from './index.js';
|
|
11
16
|
const NAME = /^[A-Za-z0-9][A-Za-z0-9/_+=.@-]{0,199}$/;
|
|
12
17
|
const MAX_VALUE_BYTES = 64 * 1024;
|
|
13
18
|
// A deleted secret stays "marked for deletion" for some seconds: reads and writes are refused and its name
|
|
14
19
|
// cannot be created again until Secrets Manager finishes deleting it.
|
|
15
20
|
const DELETION_RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 10_000];
|
|
16
21
|
const BEING_DELETED = /(marked|scheduled) for deletion/;
|
|
17
|
-
|
|
22
|
+
// A wrong name, value or environment is the app's code or setup, not the secret store: the job runner fails such a run without retrying it.
|
|
23
|
+
const invalid = (message) => new IsomorphError('VALIDATION_FAILED', message);
|
|
24
|
+
/**
|
|
25
|
+
* Where a runtime secret lives: a Secrets Manager secret in the app's cell, or a file under `isomorph dev`.
|
|
26
|
+
* Throws, before any call, for a bad name or an app without runtime secrets.
|
|
27
|
+
*/
|
|
18
28
|
export function runtimeSecretLocation(name, env = process.env) {
|
|
19
29
|
if (typeof name !== 'string' || !NAME.test(name))
|
|
20
|
-
throw
|
|
30
|
+
throw invalid(`${JSON.stringify(name)} is not a runtime secret name: up to 200 letters, digits and / _ + = . @ -, starting with a letter or digit.`);
|
|
21
31
|
const prefix = env.ISOMORPH_RUNTIME_SECRETS_PREFIX;
|
|
22
32
|
const region = env.ISOMORPH_RUNTIME_SECRETS_REGION;
|
|
33
|
+
// The name may hold `/`; one flat file per name keeps the folder listable and a name from naming a path.
|
|
34
|
+
if (!prefix && env.ISOMORPH_RUNTIME_SECRETS_DIR)
|
|
35
|
+
return { file: join(env.ISOMORPH_RUNTIME_SECRETS_DIR, encodeURIComponent(name)) };
|
|
23
36
|
if (!prefix || !region)
|
|
24
|
-
throw
|
|
37
|
+
throw invalid('This app has no runtime secrets here: the deployed app has them in its cell (ISOMORPH_RUNTIME_SECRETS_PREFIX and ISOMORPH_RUNTIME_SECRETS_REGION), and isomorph dev keeps them in ISOMORPH_RUNTIME_SECRETS_DIR.');
|
|
25
38
|
return { secretId: prefix + name, region };
|
|
26
39
|
}
|
|
27
|
-
function
|
|
28
|
-
|
|
40
|
+
function checkValue(name, value) {
|
|
41
|
+
if (typeof value !== 'string' || Buffer.byteLength(value) > MAX_VALUE_BYTES)
|
|
42
|
+
throw invalid(`The runtime secret ${name} must be a string of at most 64 KB.`);
|
|
43
|
+
}
|
|
44
|
+
/** Under `isomorph dev`: one file per secret, readable by the builder only. */
|
|
45
|
+
const localFiles = {
|
|
46
|
+
async put(file, value) {
|
|
47
|
+
await mkdir(join(file, '..'), { recursive: true, mode: 0o700 });
|
|
48
|
+
await writeFile(file, value, { mode: 0o600 });
|
|
49
|
+
},
|
|
50
|
+
async get(file) {
|
|
51
|
+
try {
|
|
52
|
+
return await readFile(file, 'utf8');
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (error.code === 'ENOENT')
|
|
56
|
+
return null;
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
async delete(file) {
|
|
61
|
+
await rm(file, { force: true });
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
function secretsManager(name, { env = process.env, fetch }, location) {
|
|
65
|
+
const { secretId, region } = location;
|
|
29
66
|
const transport = fetch ? { fetch } : {};
|
|
30
67
|
const send = async (action, payload) => {
|
|
31
68
|
const { status, body } = await signedJsonPost({ ...await runtimeCredentials(env, transport), region }, 'secretsmanager', `https://secretsmanager.${region}.amazonaws.com/`, payload, {
|
|
@@ -47,9 +84,11 @@ function secretsManager(name, { env = process.env, fetch }) {
|
|
|
47
84
|
export const runtimeSecrets = {
|
|
48
85
|
/** Saves `value` under `name`, creating the secret the first time. */
|
|
49
86
|
async put(name, value, options = {}) {
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
87
|
+
const location = runtimeSecretLocation(name, options.env);
|
|
88
|
+
checkValue(name, value);
|
|
89
|
+
if ('file' in location)
|
|
90
|
+
return localFiles.put(location.file, value);
|
|
91
|
+
const { secretId, send } = secretsManager(name, options, location);
|
|
53
92
|
// Secrets Manager wants each new version's idempotency token from a caller that is not an AWS SDK.
|
|
54
93
|
const saved = await send('PutSecretValue', { SecretId: secretId, SecretString: value, ClientRequestToken: randomUUID() });
|
|
55
94
|
if (saved.outcome === 'done')
|
|
@@ -66,7 +105,10 @@ export const runtimeSecrets = {
|
|
|
66
105
|
},
|
|
67
106
|
/** The value saved under `name`, or null when there is none or it is being deleted. */
|
|
68
107
|
async get(name, options = {}) {
|
|
69
|
-
const
|
|
108
|
+
const location = runtimeSecretLocation(name, options.env);
|
|
109
|
+
if ('file' in location)
|
|
110
|
+
return localFiles.get(location.file);
|
|
111
|
+
const { secretId, send } = secretsManager(name, options, location);
|
|
70
112
|
const read = await send('GetSecretValue', { SecretId: secretId });
|
|
71
113
|
return read.outcome === 'done' ? read.body.SecretString ?? null : null;
|
|
72
114
|
},
|
|
@@ -75,7 +117,10 @@ export const runtimeSecrets = {
|
|
|
75
117
|
* deletion within seconds, and `put` waits for it before saving the same name again.
|
|
76
118
|
*/
|
|
77
119
|
async delete(name, options = {}) {
|
|
78
|
-
const
|
|
120
|
+
const location = runtimeSecretLocation(name, options.env);
|
|
121
|
+
if ('file' in location)
|
|
122
|
+
return localFiles.delete(location.file);
|
|
123
|
+
const { secretId, send } = secretsManager(name, options, location);
|
|
79
124
|
await send('DeleteSecret', { SecretId: secretId, ForceDeleteWithoutRecovery: true });
|
|
80
125
|
},
|
|
81
126
|
};
|