@isomorph.ai/app-sdk 2.1.0 → 2.3.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.
@@ -91,14 +91,17 @@ export interface ComputerUseLiveConnection {
91
91
  };
92
92
  viewerPath: string;
93
93
  }
94
+ export type ComputerUseTakeover = ComputerUsePause & {
95
+ connection?: ComputerUseLiveConnection;
96
+ };
94
97
  export interface ComputerUseHandoffOptions extends ComputerUseTransport {
95
98
  runKey: string;
96
99
  authorize(): Promise<boolean> | boolean;
97
100
  }
98
101
  export declare function createComputerUseHandoff(options: ComputerUseHandoffOptions): {
99
- requestTakeover(reason: string, requiredInput?: "login" | "human"): Promise<ComputerUsePause>;
102
+ requestTakeover(reason: string, requiredInput?: "login" | "human", pauseId?: string): Promise<ComputerUseTakeover>;
100
103
  view(pauseId: string): Promise<ComputerUsePage>;
101
- live(pauseId: string): Promise<ComputerUseLiveConnection>;
104
+ live(_pauseId?: string): Promise<ComputerUseLiveConnection>;
102
105
  input(pauseId: string, action: ComputerUseInputAction): Promise<ComputerUseInputResult>;
103
106
  completeLogin(pauseId: string, input: ComputerUseLoginInput): Promise<{
104
107
  status: "login_queued";
@@ -32,7 +32,7 @@ export function waitingInput(answer) {
32
32
  }
33
33
  export function createComputerUseHandoff(options) {
34
34
  if (typeof options.runKey !== 'string' || !options.runKey || typeof options.authorize !== 'function')
35
- throw new Error('Computer use handoff needs runKey and an authorize check for the current user.');
35
+ throw new Error('Computer use handoff needs runKey and an authorize check for the current app task.');
36
36
  const api = computerUseApi(options);
37
37
  const post = async (path, body = {}, privateInput = false) => {
38
38
  if (await options.authorize() !== true)
@@ -61,10 +61,10 @@ export function createComputerUseHandoff(options) {
61
61
  return { pauseId };
62
62
  };
63
63
  return {
64
- requestTakeover(reason, requiredInput = 'human') {
64
+ requestTakeover(reason, requiredInput = 'human', pauseId) {
65
65
  if (!reason.trim() || !['login', 'human'].includes(requiredInput))
66
66
  throw new Error('Takeover needs a reason and login or human input.');
67
- return post('/tasks/takeover', { reason, requiredInput }).then(pauseOf);
67
+ return post('/tasks/takeover', { reason, requiredInput, ...(pauseId ? paused(pauseId) : {}) }).then(answer => ({ ...pauseOf(answer), ...(answer.connection ? { connection: answer.connection } : {}) }));
68
68
  },
69
69
  // The API answers 409 while the task is still pausing or after it resumed; `ready` is the only 200.
70
70
  async view(pauseId) {
@@ -73,8 +73,8 @@ export function createComputerUseHandoff(options) {
73
73
  throw new Error('The computer use API returned an incomplete screen.');
74
74
  return { status: 'ready', screenshot: page.screenshot, url: page.url };
75
75
  },
76
- live(pauseId) {
77
- return post('/tasks/live', paused(pauseId));
76
+ live(_pauseId) {
77
+ return post('/tasks/live');
78
78
  },
79
79
  input(pauseId, action) {
80
80
  return post('/tasks/input', { ...paused(pauseId), action });
package/dist/index.d.ts CHANGED
@@ -36,6 +36,23 @@ export interface IsomorphUser {
36
36
  */
37
37
  role: IsomorphUserRole;
38
38
  appUser?: unknown;
39
+ /**
40
+ * Who is calling: a `'person'` signed in through the browser or an MCP
41
+ * client, or a `'service'` holding a credential the company's IT issued,
42
+ * whose `id` and `name` are the credential's. Absent means a person.
43
+ */
44
+ kind?: 'person' | 'service';
45
+ }
46
+ /**
47
+ * `export const expose = { description, input }` on an action, beside
48
+ * `export const trusted = true`: the action may be called from outside the
49
+ * app by a service credential or an MCP client. `description` is read by
50
+ * people and agents; `input` is a JSON Schema object literal the platform
51
+ * validates the request body against. Literal only.
52
+ */
53
+ export interface ActionExposure {
54
+ description: string;
55
+ input: Record<string, unknown>;
39
56
  }
40
57
  export interface IsomorphResult<T> {
41
58
  data: T;
@@ -2018,7 +2035,9 @@ export declare function createClient(options?: ClientOptions): {
2018
2035
  watch: <T = Record<string, unknown>>(build: () => QueryBuilder<T, any>, onResult: (result: WatchResult) => void, options?: WatchOptions) => (() => void);
2019
2036
  };
2020
2037
  computerView: {
2021
- url(action: string, taskId: string): string;
2038
+ url(action: string, taskId: string, options?: {
2039
+ mode?: "watch" | "control";
2040
+ }): string;
2022
2041
  };
2023
2042
  files: {
2024
2043
  createUpload: (input: {
package/dist/index.js CHANGED
@@ -774,10 +774,13 @@ export function createClient(options = {}) {
774
774
  },
775
775
  },
776
776
  computerView: {
777
- url(action, taskId) {
777
+ url(action, taskId, options = {}) {
778
778
  if (!/^[A-Za-z][A-Za-z0-9_-]{0,127}$/.test(action) || !/^[A-Za-z0-9_-]{1,128}$/.test(taskId))
779
779
  throw new IsomorphError('VALIDATION_FAILED', 'Computer view needs an action and task ID.');
780
- return `${transport.baseUrl}/_harbour/computer-view/${encodeURIComponent(action)}/${encodeURIComponent(taskId)}/`;
780
+ const mode = options.mode ?? 'watch';
781
+ if (!['watch', 'control'].includes(mode))
782
+ throw new IsomorphError('VALIDATION_FAILED', 'Computer view needs watch or control mode.');
783
+ return `${transport.baseUrl}/_harbour/computer-view/${encodeURIComponent(action)}/${encodeURIComponent(taskId)}/${mode}/`;
781
784
  },
782
785
  },
783
786
  files: {
package/dist/jobs.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ActionExposure } from './index.js';
1
2
  export declare const JOBS_DIR = "jobs";
2
3
  export interface EmailArrivalTrigger {
3
4
  type: 'email.arrived';
@@ -25,6 +26,8 @@ export interface JobDefinition {
25
26
  schedule?: string;
26
27
  trigger?: EventTrigger;
27
28
  }
29
+ /** The action's `export const expose = { description, input }`, read without importing it: undefined when the module has none, does not parse, or declares anything but a literal object of that shape. */
30
+ export declare function actionExposure(file: string): Promise<ActionExposure | undefined>;
28
31
  export declare function eventTrigger(value: unknown): EventTrigger;
29
32
  export interface JobContext {
30
33
  scheduledAt: string;
package/dist/jobs.js CHANGED
@@ -8,25 +8,55 @@ export const JOBS_DIR = 'jobs';
8
8
  const LOGICAL_NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/;
9
9
  export const FILE_MIME_TYPES = ['application/pdf', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'image/png', 'image/jpeg'];
10
10
  export const providerFileId = (value) => typeof value === 'string' && /^[A-Za-z0-9!._,=-]{1,256}$/.test(value);
11
- function literalObject(node) {
11
+ function literalObject(node, what = 'trigger') {
12
12
  const value = node;
13
13
  if (value?.type === 'Literal')
14
14
  return value.value;
15
15
  if (value?.type === 'ArrayExpression')
16
- return value.elements.map(literalObject);
16
+ return value.elements.map(element => literalObject(element, what));
17
17
  if (value?.type !== 'ObjectExpression')
18
- throw new Error('trigger must be a literal object');
18
+ throw new Error(`${what} must be a literal object`);
19
19
  const result = {};
20
20
  for (const property of value.properties ?? []) {
21
21
  if (property.type !== 'Property' || property.computed || property.kind !== 'init' || property.method)
22
- throw new Error('trigger must be a literal object');
22
+ throw new Error(`${what} must be a literal object`);
23
23
  const key = property.key.name ?? property.key.value;
24
24
  if (typeof key !== 'string' || Object.hasOwn(result, key))
25
- throw new Error('trigger contains an invalid or repeated field');
26
- Object.defineProperty(result, key, { value: literalObject(property.value), enumerable: true });
25
+ throw new Error(`${what} contains an invalid or repeated field`);
26
+ Object.defineProperty(result, key, { value: literalObject(property.value, what), enumerable: true });
27
27
  }
28
28
  return result;
29
29
  }
30
+ const plainObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
31
+ /** The action's `export const expose = { description, input }`, read without importing it: undefined when the module has none, does not parse, or declares anything but a literal object of that shape. */
32
+ export async function actionExposure(file) {
33
+ let statements;
34
+ try {
35
+ statements = parseModule(await readFile(file, 'utf8'));
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ for (const statement of statements) {
41
+ if (statement.type !== 'ExportNamedDeclaration' || statement.declaration?.type !== 'VariableDeclaration' || statement.declaration.kind !== 'const')
42
+ continue;
43
+ for (const declaration of statement.declaration.declarations) {
44
+ if (declaration.id.type !== 'Identifier' || declaration.id.name !== 'expose')
45
+ continue;
46
+ let value;
47
+ try {
48
+ value = literalObject(declaration.init, 'expose');
49
+ }
50
+ catch {
51
+ return undefined;
52
+ }
53
+ if (!plainObject(value) || Object.keys(value).some(key => key !== 'description' && key !== 'input') || typeof value.description !== 'string' || !plainObject(value.input))
54
+ return undefined;
55
+ return { description: value.description, input: value.input };
56
+ }
57
+ }
58
+ return undefined;
59
+ }
30
60
  export function eventTrigger(value) {
31
61
  if (value?.type === 'file.created') {
32
62
  const t = value;
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, type JobRunSummary } from './index.js';
2
+ import { type ActionExposure, 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';
@@ -37,6 +37,8 @@ export interface RuntimeInventory {
37
37
  actions: string[];
38
38
  /** The actions whose module says `export const trusted = true`: they act as the app on behalf of the caller. */
39
39
  trusted: string[];
40
+ /** The actions whose module says `export const expose = { description, input }`, by name: what the kit gate records as exposed (ADR 0100). */
41
+ exposed: Record<string, ActionExposure>;
40
42
  jobs: JobDefinition[];
41
43
  }
42
44
  /** Every `actions/<name>.ts` and `jobs/<name>.ts` the app carries, by logical name. */
package/dist/runtime.js CHANGED
@@ -68,7 +68,7 @@ import { pathToFileURL } from 'node:url';
68
68
  import { createClient, gatewayRequest, IsomorphError } from './index.js';
69
69
  import { userGroupsFromHeader } from './user-groups.js';
70
70
  import { userRole } from './user-role.js';
71
- import { FILE_MIME_TYPES, providerFileId, discoverJobs, exportsTrue, invokeJob, loadHandler, JOBS_DIR } from './jobs.js';
71
+ import { FILE_MIME_TYPES, providerFileId, actionExposure, discoverJobs, exportsTrue, invokeJob, loadHandler, JOBS_DIR } from './jobs.js';
72
72
  import { LocalGateway } from './local-gateway.js';
73
73
  export { JOBS_DIR } from './jobs.js';
74
74
  const actionIdentity = new AsyncLocalStorage();
@@ -118,8 +118,10 @@ export async function discover(root) {
118
118
  };
119
119
  const actions = (await list(ACTIONS_DIR)).filter((name) => !name.startsWith(RESERVED_PREFIX));
120
120
  const trusted = await Promise.all(actions.map((name) => exportsTrue(join(root, ACTIONS_DIR, `${name}.ts`), 'trusted')));
121
+ const exposures = await Promise.all(actions.map((name) => actionExposure(join(root, ACTIONS_DIR, `${name}.ts`))));
122
+ const exposed = Object.fromEntries(actions.flatMap((name, index) => exposures[index] ? [[name, exposures[index]]] : []));
121
123
  const jobs = await discoverJobs(root);
122
- return { actions, trusted: actions.filter((_, index) => trusted[index]), jobs };
124
+ return { actions, trusted: actions.filter((_, index) => trusted[index]), exposed, jobs };
123
125
  }
124
126
  // ---- Cron ------------------------------------------------------------------
125
127
  /** One field of a five-field cron expression: numbers, `*`, lists, ranges and steps, no names. */
@@ -313,7 +315,7 @@ export class Runs {
313
315
  }
314
316
  export class Runtime {
315
317
  options;
316
- inventory = { actions: [], trusted: [], jobs: [] };
318
+ inventory = { actions: [], trusted: [], exposed: {}, jobs: [] };
317
319
  localGateway;
318
320
  running = new Set();
319
321
  timers = [];
@@ -826,7 +828,7 @@ export class Runtime {
826
828
  const owner = context.user.id;
827
829
  switch (name) {
828
830
  case PING_ACTION: {
829
- const answer = { ok: true, mode: this.options.mode, actions: this.inventory.actions, trusted: this.inventory.trusted, jobs: this.inventory.jobs.map((job) => job.name) };
831
+ const answer = { ok: true, mode: this.options.mode, actions: this.inventory.actions, trusted: this.inventory.trusted, exposed: this.inventory.exposed, jobs: this.inventory.jobs.map((job) => job.name) };
830
832
  return this.options.mode === 'gate' ? { ...answer, loadFailures: await this.loadFailures() } : answer;
831
833
  }
832
834
  case ENQUEUE_ACTION: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isomorph.ai/app-sdk",
3
- "version": "2.1.0",
3
+ "version": "2.3.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",