@isomorph.ai/app-sdk 1.44.0 → 1.46.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 CHANGED
@@ -410,12 +410,13 @@ Options the app passes, never the model:
410
410
  - `session`: the name of a runtime secret a sign-in was saved under (`finishSignIn`'s `saveAs`, below). The task starts signed in and can use only that sign-in's websites. When the task is `done` the refreshed session is saved back to the same secret. A missing one fails with `No saved sign-in named <name>.` Run one task per saved sign-in at a time: two tasks would both save back, and some websites sign out the older copy.
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
- - `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.
413
+ - `allowedHosts`: the websites (1 to 10 lower-case hostnames) the browser may use; without it a task with no sign-in may use any website. A task with `secrets` and no `session` needs it: the websites the secrets may be typed into. 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 no sign-in (with or without `allowedHosts`). `secrets` without a session or hosts has nowhere to be typed, and `session` with `allowedHosts` names websites twice. 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:
@@ -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 Error('Computer use needs 1 to 10 lower-case website hostnames in allowedHosts, such as vendorcentral.amazon.com.');
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 interface ComputerUseHandoffToolOptions extends ComputerUseHandoffOptions, ComputerUseOptions {
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;
@@ -10,22 +10,9 @@ export interface ComputerUseDownload {
10
10
  size: number;
11
11
  blob: Blob;
12
12
  }
13
- export interface ComputerUseOptions {
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,38 @@ 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 no sign-in, with or
46
+ * without `allowedHosts`. A session carries its own websites, so `session` with `allowedHosts` is refused,
47
+ * and `secrets` without `session` or `allowedHosts` has nowhere to be typed.
48
+ */
49
+ export type ComputerUseSignIn = {
50
+ /**
51
+ * The runtime secret a sign-in was saved under (finishSignIn's `saveAs`): the task starts signed in,
52
+ * and the refreshed session is saved back to it when the task is done. Never supplied by the model.
53
+ */
54
+ session: string;
55
+ secrets?: ComputerUseSecrets;
56
+ allowedHosts?: never;
57
+ } | {
58
+ secrets: ComputerUseSecrets;
59
+ /** The websites (1 to 10 lower-case hostnames) the browser may use and type the secrets into. */
60
+ allowedHosts: readonly string[];
61
+ session?: never;
62
+ } | {
63
+ /** The websites (1 to 10 lower-case hostnames) the browser may use; none means any website. */
64
+ allowedHosts?: readonly string[];
65
+ secrets?: never;
66
+ session?: never;
67
+ };
68
+ export type ComputerUseOptions = ComputerUseCommonOptions & ComputerUseSignIn;
50
69
  export interface ComputerUseFinished {
51
70
  status: 'done' | 'failed';
52
71
  summary: string | null;
@@ -262,3 +281,4 @@ export declare function createComputerUseTool(options: ComputerUseOptions): ({
262
281
  task: string;
263
282
  }, ComputerUseResult, NoInfer<import("@ai-sdk/provider-utils").Context>>;
264
283
  });
284
+ export {};
@@ -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,41 @@ 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 new Error('Computer use secrets are 1 to 10 named values.');
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 new Error(`${JSON.stringify(name)} is not a computer use secret name: up to 64 letters, digits, _ and -, starting with a letter.`);
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 new Error(`The computer use secret ${name} must be a non-empty string of at most 4096 bytes.`);
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 new Error('connectionId is no longer supported; pass session, the runtime secret a sign-in was saved under.');
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 new Error("Computer use accepts at most five PDFs.");
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 new Error("Computer use PDF names must be unique safe basenames.");
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 new Error("Computer use needs base64 PDFs, at most 2 MiB total.");
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 new Error("Computer use needs PDFs, at most 2 MiB total.");
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 new Error('Pass session or allowedHosts, not both: a saved sign-in carries its own websites.');
61
- if (options.allowedHosts !== undefined && options.secrets === undefined)
62
- throw new Error('allowedHosts is only for a task with secrets and no session.');
63
+ throw invalid('Pass session or allowedHosts, not both: a saved sign-in carries its own websites.');
63
64
  if (options.secrets !== undefined && session === undefined && options.allowedHosts === undefined)
64
- throw new Error('A task with secrets needs session or allowedHosts: the websites the computer may type them into.');
65
+ throw invalid('A task with secrets needs session or allowedHosts: the websites the computer may type them into.');
65
66
  const allowedHosts = options.allowedHosts === undefined ? undefined : allowedHostsOf(options.allowedHosts);
66
67
  const secrets = options.secrets === undefined ? undefined : secretsOf(options.secrets);
67
68
  const transport = { env: options.env ?? process.env, fetch: options.fetch };
@@ -69,7 +70,7 @@ export function createComputerUseTool(options) {
69
70
  runtimeSecretLocation(session, transport.env);
70
71
  const api = computerUseApi(transport);
71
72
  if (!options.runKey)
72
- throw new Error('createComputerUseTool needs the job run as runKey.');
73
+ throw invalid('createComputerUseTool needs the job run as runKey.');
73
74
  const now = options.now ?? Date.now;
74
75
  const savedSession = async (name) => {
75
76
  const stored = await runtimeSecrets.get(name, transport);
package/dist/index.d.ts CHANGED
@@ -1788,6 +1788,8 @@ export interface JobRun {
1788
1788
  created_at: string;
1789
1789
  updated_at: string;
1790
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'>;
1791
1793
  export type AiRole = 'system' | 'user' | 'assistant';
1792
1794
  export interface AiMessage {
1793
1795
  role: AiRole;
@@ -2054,6 +2056,11 @@ export declare function createClient(options?: ClientOptions): {
2054
2056
  }>;
2055
2057
  get: (id: string) => Promise<JobRun>;
2056
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[]>;
2057
2064
  };
2058
2065
  telemetry: {
2059
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', {
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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
- await this.runs.finish(run, { error: error instanceof Error ? error.message : String(error) });
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
- /** Where a runtime secret lives; throws, before any call, for a bad name or an app without runtime secrets. */
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
- /** Where a runtime secret lives; throws, before any call, for a bad name or an app without runtime secrets. */
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 new Error(`${JSON.stringify(name)} is not a runtime secret name: up to 200 letters, digits and / _ + = . @ -, starting with a letter or digit.`);
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 new Error('This app has no runtime secrets (ISOMORPH_RUNTIME_SECRETS_PREFIX and ISOMORPH_RUNTIME_SECRETS_REGION are not set).');
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 secretsManager(name, { env = process.env, fetch }) {
28
- const { secretId, region } = runtimeSecretLocation(name, env);
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 { secretId, send } = secretsManager(name, options);
51
- if (typeof value !== 'string' || Buffer.byteLength(value) > MAX_VALUE_BYTES)
52
- throw new Error(`The runtime secret ${name} must be a string of at most 64 KB.`);
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 { secretId, send } = secretsManager(name, options);
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 { secretId, send } = secretsManager(name, options);
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isomorph.ai/app-sdk",
3
- "version": "1.44.0",
3
+ "version": "1.46.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",