@isomorph.ai/app-sdk 1.10.0 → 1.12.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/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { IntegrationOperations } from './integration-operations.js';
1
2
  import { validateIntegrationExecuteInput } from './integration-execute-input-rules.js';
2
3
  import { userGroups } from './user-groups.js';
3
4
  import { userRole } from './user-role.js';
@@ -29,13 +30,6 @@ function warehouseReadProblem({ columns, aggregates }) {
29
30
  return { field: 'columns', message: 'columns may use "*" only as the sole entry' };
30
31
  return undefined;
31
32
  }
32
- // The operations whose request carries a `mode`, and the only ones it is sent
33
- // for (every other operation's mode is fixed, and the platform refuses the
34
- // field there). The key type is derived from the union, so adding or removing
35
- // `mode` on a request without changing this table does not compile.
36
- const declaredModeOperations = {
37
- 'slack.channel.history': true, 'slack.message.post': true, 'slack.message.update': true, 'slack.message.delete': true,
38
- };
39
33
  /** The AI reason code carried by an error, when it is a governed-AI refusal. */
40
34
  export function aiErrorCode(error) {
41
35
  const code = error instanceof IsomorphError ? error.details?.code : undefined;
@@ -498,6 +492,19 @@ export function createClient(options = {}) {
498
492
  },
499
493
  };
500
494
  }
495
+ // A kit page on its own API host pays for its first call three times over:
496
+ // a fresh connection (TCP, TLS, then a CORS preflight), the 401 → context
497
+ // refresh → resend detour once the context cookie has lapsed, and only then
498
+ // the call. Sending the identity GET now — the one the API host heals — opens
499
+ // the connection, renews a lapsed context and caches the user while the page
500
+ // is still rendering, so the first action rides an open connection with a
501
+ // live context; a refusal while it is in flight shares it (renew above). A
502
+ // page-relative base (isomorph dev, a git app on the shared edge) already has
503
+ // its connection and the edge heals its GETs, so nothing is sent there, and
504
+ // under Node the identity is a static token. Its outcome is the page's to
505
+ // read through identity.current() and onChange, never surfaced here.
506
+ if (transport.separateApiHost)
507
+ void refreshIdentity().catch(() => undefined);
501
508
  const scheduleIdentityRefresh = () => {
502
509
  if (refreshTimer !== undefined || authListeners.size === 0 || identityRefreshMs <= 0)
503
510
  return;
@@ -726,11 +733,11 @@ export function createClient(options = {}) {
726
733
  if (problem) {
727
734
  return Promise.reject(new IsomorphError('VALIDATION_FAILED', problem.message, undefined, { code: 'INPUT_INVALID', field: problem.field }));
728
735
  }
729
- // Exactly the contract's fields go on the wire: `mode` only for the
730
- // declaredModeOperations; an `identity` or any other stray field is not sent.
736
+ // Exactly the contract's fields go on the wire: metadata declares which
737
+ // operations accept `mode`; identity and stray fields are not sent.
731
738
  const { operation, resource, input, idempotencyKey, mode } = request;
732
739
  return transport.json('/_harbour/integrations/execute', {
733
- method: 'POST', body: JSON.stringify({ connection, operation, resource, ...(input !== undefined ? { input } : {}), ...(idempotencyKey !== undefined ? { idempotencyKey } : {}), ...(operation in declaredModeOperations && mode !== undefined ? { mode } : {}) }),
740
+ method: 'POST', body: JSON.stringify({ connection, operation, resource, ...(input !== undefined ? { input } : {}), ...(idempotencyKey !== undefined ? { idempotencyKey } : {}), ...(IntegrationOperations[operation]?.declaredMode && mode !== undefined ? { mode } : {}) }),
734
741
  });
735
742
  },
736
743
  // In a browser the consent returns the person to this page (without its
@@ -0,0 +1,87 @@
1
+ /** Declaration and execute metadata from the contract schemas. */
2
+ export declare const IntegrationOperations: {
3
+ readonly "slack.channel.history": {
4
+ readonly kind: "saas";
5
+ readonly identities: readonly ["user", "app"];
6
+ readonly readOnly: true;
7
+ readonly declaredMode: true;
8
+ readonly idempotencyKey: false;
9
+ };
10
+ readonly "slack.message.post": {
11
+ readonly kind: "saas";
12
+ readonly identities: readonly ["app", "user"];
13
+ readonly readOnly: false;
14
+ readonly declaredMode: true;
15
+ readonly idempotencyKey: true;
16
+ };
17
+ readonly "slack.message.update": {
18
+ readonly kind: "saas";
19
+ readonly identities: readonly ["app", "user"];
20
+ readonly readOnly: false;
21
+ readonly declaredMode: true;
22
+ readonly idempotencyKey: false;
23
+ };
24
+ readonly "slack.message.delete": {
25
+ readonly kind: "saas";
26
+ readonly identities: readonly ["app", "user"];
27
+ readonly readOnly: false;
28
+ readonly declaredMode: true;
29
+ readonly idempotencyKey: false;
30
+ };
31
+ readonly "slack.dm.send": {
32
+ readonly kind: "saas";
33
+ readonly identities: readonly ["app"];
34
+ readonly readOnly: false;
35
+ readonly declaredMode: false;
36
+ readonly idempotencyKey: true;
37
+ };
38
+ readonly "slack.user.lookup": {
39
+ readonly kind: "saas";
40
+ readonly identities: readonly ["app"];
41
+ readonly readOnly: true;
42
+ readonly declaredMode: false;
43
+ readonly idempotencyKey: false;
44
+ };
45
+ readonly "gmail.thread.list": {
46
+ readonly kind: "saas";
47
+ readonly identities: readonly ["user"];
48
+ readonly readOnly: true;
49
+ readonly declaredMode: false;
50
+ readonly idempotencyKey: false;
51
+ };
52
+ readonly "gmail.thread.read": {
53
+ readonly kind: "saas";
54
+ readonly identities: readonly ["user"];
55
+ readonly readOnly: true;
56
+ readonly declaredMode: false;
57
+ readonly idempotencyKey: false;
58
+ };
59
+ readonly "gmail.message.read": {
60
+ readonly kind: "saas";
61
+ readonly identities: readonly ["user"];
62
+ readonly readOnly: true;
63
+ readonly declaredMode: false;
64
+ readonly idempotencyKey: false;
65
+ };
66
+ readonly "gmail.message.send": {
67
+ readonly kind: "saas";
68
+ readonly identities: readonly ["user"];
69
+ readonly readOnly: false;
70
+ readonly declaredMode: false;
71
+ readonly idempotencyKey: true;
72
+ };
73
+ readonly "gmail.attachment.read": {
74
+ readonly kind: "saas";
75
+ readonly identities: readonly ["user"];
76
+ readonly readOnly: true;
77
+ readonly declaredMode: false;
78
+ readonly idempotencyKey: false;
79
+ };
80
+ readonly "warehouse.view.read": {
81
+ readonly kind: "database";
82
+ readonly identities: readonly ["app"];
83
+ readonly readOnly: true;
84
+ readonly declaredMode: false;
85
+ readonly idempotencyKey: false;
86
+ };
87
+ };
@@ -0,0 +1,117 @@
1
+ /* eslint-disable */
2
+ // Code generated by tools/gen/gen.mjs from schema/*.json. DO NOT EDIT.
3
+ /** Declaration and execute metadata from the contract schemas. */
4
+ export const IntegrationOperations = {
5
+ "slack.channel.history": {
6
+ "kind": "saas",
7
+ "identities": [
8
+ "user",
9
+ "app"
10
+ ],
11
+ "readOnly": true,
12
+ "declaredMode": true,
13
+ "idempotencyKey": false
14
+ },
15
+ "slack.message.post": {
16
+ "kind": "saas",
17
+ "identities": [
18
+ "app",
19
+ "user"
20
+ ],
21
+ "readOnly": false,
22
+ "declaredMode": true,
23
+ "idempotencyKey": true
24
+ },
25
+ "slack.message.update": {
26
+ "kind": "saas",
27
+ "identities": [
28
+ "app",
29
+ "user"
30
+ ],
31
+ "readOnly": false,
32
+ "declaredMode": true,
33
+ "idempotencyKey": false
34
+ },
35
+ "slack.message.delete": {
36
+ "kind": "saas",
37
+ "identities": [
38
+ "app",
39
+ "user"
40
+ ],
41
+ "readOnly": false,
42
+ "declaredMode": true,
43
+ "idempotencyKey": false
44
+ },
45
+ "slack.dm.send": {
46
+ "kind": "saas",
47
+ "identities": [
48
+ "app"
49
+ ],
50
+ "readOnly": false,
51
+ "declaredMode": false,
52
+ "idempotencyKey": true
53
+ },
54
+ "slack.user.lookup": {
55
+ "kind": "saas",
56
+ "identities": [
57
+ "app"
58
+ ],
59
+ "readOnly": true,
60
+ "declaredMode": false,
61
+ "idempotencyKey": false
62
+ },
63
+ "gmail.thread.list": {
64
+ "kind": "saas",
65
+ "identities": [
66
+ "user"
67
+ ],
68
+ "readOnly": true,
69
+ "declaredMode": false,
70
+ "idempotencyKey": false
71
+ },
72
+ "gmail.thread.read": {
73
+ "kind": "saas",
74
+ "identities": [
75
+ "user"
76
+ ],
77
+ "readOnly": true,
78
+ "declaredMode": false,
79
+ "idempotencyKey": false
80
+ },
81
+ "gmail.message.read": {
82
+ "kind": "saas",
83
+ "identities": [
84
+ "user"
85
+ ],
86
+ "readOnly": true,
87
+ "declaredMode": false,
88
+ "idempotencyKey": false
89
+ },
90
+ "gmail.message.send": {
91
+ "kind": "saas",
92
+ "identities": [
93
+ "user"
94
+ ],
95
+ "readOnly": false,
96
+ "declaredMode": false,
97
+ "idempotencyKey": true
98
+ },
99
+ "gmail.attachment.read": {
100
+ "kind": "saas",
101
+ "identities": [
102
+ "user"
103
+ ],
104
+ "readOnly": true,
105
+ "declaredMode": false,
106
+ "idempotencyKey": false
107
+ },
108
+ "warehouse.view.read": {
109
+ "kind": "database",
110
+ "identities": [
111
+ "app"
112
+ ],
113
+ "readOnly": true,
114
+ "declaredMode": false,
115
+ "idempotencyKey": false
116
+ }
117
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ // Read-only native gate adapter. No app modules or runtime server are started.
2
+ import { discoverJobs } from './jobs.js';
3
+ try {
4
+ if (process.argv[2] !== 'discover' || !process.argv[3])
5
+ throw new Error('usage: jobs-cli.js discover <app-root>');
6
+ process.stdout.write(JSON.stringify(await discoverJobs(process.argv[3])) + '\n');
7
+ }
8
+ catch (error) {
9
+ console.error(error instanceof Error ? error.message : String(error));
10
+ process.exitCode = 1;
11
+ }
package/dist/jobs.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ export declare const JOBS_DIR = "jobs";
2
+ export interface JobDefinition {
3
+ name: string;
4
+ schedule?: string;
5
+ }
6
+ export interface JobContext {
7
+ scheduledAt: string;
8
+ runId?: string;
9
+ input?: unknown;
10
+ progress(percent: number, note?: string): Promise<void>;
11
+ cancelled(): Promise<boolean>;
12
+ }
13
+ export declare function discoverJobs(root: string): Promise<JobDefinition[]>;
14
+ type Handler = (...args: unknown[]) => unknown;
15
+ export declare function loadHandler(root: string, dir: string, name: string): Promise<Handler>;
16
+ export declare function invokeJob(root: string, name: string, context: JobContext): Promise<unknown>;
17
+ export {};
package/dist/jobs.js ADDED
@@ -0,0 +1,98 @@
1
+ // Node-only: shared by runtime, local CLI and the native gate. Discovery never imports app code.
2
+ import { readdir, readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { stripTypeScriptTypes } from 'node:module';
6
+ import { parse } from 'acorn';
7
+ export const JOBS_DIR = 'jobs';
8
+ const LOGICAL_NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/;
9
+ function bindsSchedule(binding) {
10
+ switch (binding.type) {
11
+ case 'Identifier': return binding.name === 'schedule';
12
+ case 'ObjectPattern': return binding.properties.some(property => bindsSchedule(property.type === 'RestElement' ? property.argument : property.value));
13
+ case 'ArrayPattern': return binding.elements.some(element => element !== null && bindsSchedule(element));
14
+ case 'AssignmentPattern': return bindsSchedule(binding.left);
15
+ case 'RestElement': return bindsSchedule(binding.argument);
16
+ default: return false;
17
+ }
18
+ }
19
+ export async function discoverJobs(root) {
20
+ const directory = join(root, JOBS_DIR);
21
+ const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
22
+ if (error.code === 'ENOENT')
23
+ return [];
24
+ throw error;
25
+ });
26
+ const jobs = [];
27
+ for (const entry of entries) {
28
+ if (!entry.isFile() || !entry.name.endsWith('.ts'))
29
+ continue;
30
+ const name = entry.name.slice(0, -3);
31
+ const fail = (message) => { throw new Error(`jobs/${entry.name}: ${message}`); };
32
+ if (!LOGICAL_NAME.test(name))
33
+ fail('the file name must be a lowercase logical name.');
34
+ const source = await readFile(join(directory, entry.name), 'utf8');
35
+ let statements;
36
+ try {
37
+ statements = parse(stripTypeScriptTypes(source), { ecmaVersion: 'latest', sourceType: 'module' }).body;
38
+ }
39
+ catch (error) {
40
+ fail(`cannot parse the job module: ${error instanceof Error ? error.message : String(error)}`);
41
+ }
42
+ let schedule;
43
+ for (const statement of statements) {
44
+ if (statement.type === 'ExportAllDeclaration' && statement.exported && (statement.exported.type === 'Identifier' ? statement.exported.name : statement.exported.value) === 'schedule')
45
+ fail('export one literal schedule directly with export const schedule.');
46
+ if (statement.type !== 'ExportNamedDeclaration')
47
+ continue;
48
+ if (statement.specifiers.some(specifier => specifier.exported.type === 'Identifier' ? specifier.exported.name === 'schedule' : specifier.exported.value === 'schedule'))
49
+ fail('export one literal schedule directly with export const schedule.');
50
+ if (statement.declaration?.type !== 'VariableDeclaration') {
51
+ if (statement.declaration?.id?.name === 'schedule')
52
+ fail('export one literal schedule directly with export const schedule.');
53
+ continue;
54
+ }
55
+ for (const declaration of statement.declaration.declarations) {
56
+ if (!bindsSchedule(declaration.id))
57
+ continue;
58
+ if (declaration.id.type !== 'Identifier')
59
+ fail('export one literal schedule directly with export const schedule.');
60
+ const value = declaration.init;
61
+ if (statement.declaration.kind !== 'const')
62
+ fail('export one literal schedule.');
63
+ if (value?.type === 'Literal' && typeof value.value === 'string')
64
+ schedule = value.value.trim();
65
+ else if (value?.type === 'TemplateLiteral' && value.expressions.length === 0)
66
+ schedule = value.quasis[0].value.cooked?.trim();
67
+ else
68
+ fail('export one literal schedule, for example export const schedule = "0 9 * * 1-5".');
69
+ if (!schedule)
70
+ fail('export one non-empty literal schedule.');
71
+ }
72
+ }
73
+ if (!statements.some(statement => statement.type === 'ExportDefaultDeclaration'))
74
+ fail('export one default job handler.');
75
+ jobs.push({ name, ...(schedule ? { schedule } : {}) });
76
+ }
77
+ return jobs.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
78
+ }
79
+ const modules = new Map();
80
+ export function loadHandler(root, dir, name) {
81
+ const key = pathToFileURL(join(root, dir, `${name}.ts`)).href;
82
+ let loading = modules.get(key);
83
+ if (!loading) {
84
+ loading = import(key).then((module) => {
85
+ if (typeof module.default !== 'function')
86
+ throw new Error(`${dir}/${name}.ts has no default handler`);
87
+ return module.default;
88
+ });
89
+ loading.catch(() => modules.delete(key));
90
+ modules.set(key, loading);
91
+ }
92
+ return loading;
93
+ }
94
+ export async function invokeJob(root, name, context) {
95
+ if (!LOGICAL_NAME.test(name))
96
+ throw new Error('A valid job name is required.');
97
+ return (await loadHandler(root, JOBS_DIR, name))(context);
98
+ }
package/dist/runtime.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { type IncomingMessage, type ServerResponse } from 'node:http';
2
2
  import { type IsomorphClient, type IsomorphUser, type JobRun } from './index.js';
3
+ import { type JobDefinition } from './jobs.js';
4
+ export { JOBS_DIR, type JobContext } from './jobs.js';
3
5
  export declare const ACTIONS_DIR = "actions";
4
- export declare const JOBS_DIR = "jobs";
5
6
  export declare const RUNS_TABLE = "isomorph_job_runs";
6
7
  export declare const RESERVED_PREFIX = "isomorph-";
7
8
  export declare const PING_ACTION = "isomorph-ping";
@@ -21,19 +22,9 @@ export interface ActionContext {
21
22
  */
22
23
  progress(step: string | Record<string, unknown>): void;
23
24
  }
24
- export interface JobContext {
25
- scheduledAt: string;
26
- runId?: string;
27
- input?: unknown;
28
- progress(percent: number, note?: string): Promise<void>;
29
- cancelled(): Promise<boolean>;
30
- }
31
25
  export interface RuntimeInventory {
32
26
  actions: string[];
33
- jobs: {
34
- name: string;
35
- schedule?: string;
36
- }[];
27
+ jobs: JobDefinition[];
37
28
  }
38
29
  /** Every `actions/<name>.ts` and `jobs/<name>.ts` the app carries, by logical name. */
39
30
  export declare function discover(root: string): Promise<RuntimeInventory>;
package/dist/runtime.js CHANGED
@@ -58,11 +58,12 @@ import { pathToFileURL } from 'node:url';
58
58
  import { createClient, IsomorphError } from './index.js';
59
59
  import { userGroupsFromHeader } from './user-groups.js';
60
60
  import { userRole } from './user-role.js';
61
+ import { discoverJobs, invokeJob, loadHandler, JOBS_DIR } from './jobs.js';
62
+ export { JOBS_DIR } from './jobs.js';
61
63
  const actionIdentity = new AsyncLocalStorage();
62
64
  const identityKey = Symbol.for('isomorph.actionIdentity');
63
65
  globalThis[identityKey] = () => actionIdentity.getStore();
64
66
  export const ACTIONS_DIR = 'actions';
65
- export const JOBS_DIR = 'jobs';
66
67
  export const RUNS_TABLE = 'isomorph_job_runs';
67
68
  export const RESERVED_PREFIX = 'isomorph-';
68
69
  export const PING_ACTION = 'isomorph-ping';
@@ -70,9 +71,6 @@ export const ENQUEUE_ACTION = 'isomorph-jobs-enqueue';
70
71
  export const GET_RUN_ACTION = 'isomorph-jobs-get';
71
72
  export const CANCEL_RUN_ACTION = 'isomorph-jobs-cancel';
72
73
  const LOGICAL_NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/;
73
- // \x60 is the backtick: the brand guard counts template literals by
74
- // backticks, and a bare one inside this regex unbalanced its scan.
75
- const SCHEDULE_DECLARATION = /^export\s+const\s+schedule\s*=\s*["'\x60]([^"'\x60\n]+)["'\x60]/m;
76
74
  const MAX_BODY_BYTES = 1 << 20;
77
75
  const LEASE_MS = 5 * 60_000;
78
76
  const QUEUE_POLL_MS = 2_000;
@@ -95,12 +93,7 @@ export async function discover(root) {
95
93
  return entries.filter((entry) => entry.isFile() && entry.name.endsWith('.ts')).map((entry) => entry.name.slice(0, -3)).filter((name) => LOGICAL_NAME.test(name)).sort();
96
94
  };
97
95
  const actions = (await list(ACTIONS_DIR)).filter((name) => !name.startsWith(RESERVED_PREFIX));
98
- const jobs = [];
99
- for (const name of await list(JOBS_DIR)) {
100
- const body = await readFile(join(root, JOBS_DIR, `${name}.ts`), 'utf8');
101
- const match = SCHEDULE_DECLARATION.exec(body);
102
- jobs.push(match ? { name, schedule: match[1].trim() } : { name });
103
- }
96
+ const jobs = await discoverJobs(root);
104
97
  return { actions, jobs };
105
98
  }
106
99
  // ---- Cron ------------------------------------------------------------------
@@ -138,21 +131,6 @@ export function cronMatches(expression, at) {
138
131
  && fieldMatches(month, at.getUTCMonth() + 1, 1, 12)
139
132
  && (fieldMatches(weekday, at.getUTCDay(), 0, 7) || (sunday && fieldMatches(weekday, 7, 0, 7)));
140
133
  }
141
- const modules = new Map();
142
- function loadHandler(root, dir, name) {
143
- const key = `${dir}/${name}`;
144
- let loading = modules.get(key);
145
- if (!loading) {
146
- loading = import(pathToFileURL(join(root, dir, `${name}.ts`)).href).then((module) => {
147
- if (typeof module.default !== 'function')
148
- throw new Error(`${key}.ts has no default handler`);
149
- return module.default;
150
- });
151
- loading.catch(() => modules.delete(key));
152
- modules.set(key, loading);
153
- }
154
- return loading;
155
- }
156
134
  // ---- HTTP helpers ------------------------------------------------------------
157
135
  // Once an action has streamed progress the status line is gone, so its
158
136
  // answer — result or refusal — is the stream's last line, the refusal
@@ -447,8 +425,7 @@ export class Runtime {
447
425
  this.running.add(key);
448
426
  try {
449
427
  await this.prepareAI();
450
- const handler = await loadHandler(this.options.root, JOBS_DIR, name);
451
- await handler({ scheduledAt: at.toISOString(), progress: async () => undefined, cancelled: async () => false });
428
+ await invokeJob(this.options.root, name, { scheduledAt: at.toISOString(), progress: async () => undefined, cancelled: async () => false });
452
429
  this.log(`jobs/${name}.ts ran for ${at.toISOString()}`);
453
430
  return true;
454
431
  }
@@ -482,8 +459,7 @@ export class Runtime {
482
459
  }
483
460
  try {
484
461
  await this.prepareAI();
485
- const handler = await loadHandler(this.options.root, JOBS_DIR, run.job);
486
- const result = await handler({
462
+ const result = await invokeJob(this.options.root, run.job, {
487
463
  scheduledAt: now.toISOString(), runId: run.id, input: run.input,
488
464
  progress: (percent, note) => this.runs.progress(run.id, percent, note),
489
465
  cancelled: () => this.runs.cancelRequested(run.id),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isomorph.ai/app-sdk",
3
- "version": "1.10.0",
3
+ "version": "1.12.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -36,5 +36,8 @@
36
36
  "playwright-core": {
37
37
  "optional": true
38
38
  }
39
+ },
40
+ "dependencies": {
41
+ "acorn": "8.18.0"
39
42
  }
40
43
  }