@isomorph.ai/app-sdk 1.9.0 → 1.11.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.d.ts +10 -0
- package/dist/index.js +9 -13
- package/dist/integration-operations.d.ts +87 -0
- package/dist/integration-operations.js +117 -0
- package/dist/jobs-cli.d.ts +1 -0
- package/dist/jobs-cli.js +11 -0
- package/dist/jobs.d.ts +17 -0
- package/dist/jobs.js +98 -0
- package/dist/runtime.d.ts +4 -13
- package/dist/runtime.js +7 -29
- package/dist/user-role.d.ts +2 -0
- package/dist/user-role.js +3 -0
- package/package.json +4 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type IsomorphUserRole } from './user-role.js';
|
|
2
|
+
export type { IsomorphUserRole } from './user-role.js';
|
|
1
3
|
export type IsomorphErrorCategory = 'AUTH_REQUIRED' | 'FORBIDDEN' | 'NOT_FOUND' | 'CONFLICT' | 'VALIDATION_FAILED' | 'RATE_LIMITED' | 'UNAVAILABLE' | 'INTERNAL';
|
|
2
4
|
export declare class IsomorphError extends Error {
|
|
3
5
|
readonly category: IsomorphErrorCategory;
|
|
@@ -18,6 +20,14 @@ export interface IsomorphUser {
|
|
|
18
20
|
* enforced before your code runs.
|
|
19
21
|
*/
|
|
20
22
|
groups: string[];
|
|
23
|
+
/**
|
|
24
|
+
* What the person may do on the company's app platform: a `'builder'`
|
|
25
|
+
* builds and deploys apps, a `'member'` uses them. Show builders what only
|
|
26
|
+
* they need (a debug panel, a "deploy" hint); never gate data on it — that
|
|
27
|
+
* is a row rule's job, where `current_setting('harbour.user_role', true)`
|
|
28
|
+
* carries the same value.
|
|
29
|
+
*/
|
|
30
|
+
role: IsomorphUserRole;
|
|
21
31
|
appUser?: unknown;
|
|
22
32
|
}
|
|
23
33
|
export interface IsomorphResult<T> {
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
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';
|
|
4
|
+
import { userRole } from './user-role.js';
|
|
3
5
|
export class IsomorphError extends Error {
|
|
4
6
|
category;
|
|
5
7
|
requestId;
|
|
@@ -28,13 +30,6 @@ function warehouseReadProblem({ columns, aggregates }) {
|
|
|
28
30
|
return { field: 'columns', message: 'columns may use "*" only as the sole entry' };
|
|
29
31
|
return undefined;
|
|
30
32
|
}
|
|
31
|
-
// The operations whose request carries a `mode`, and the only ones it is sent
|
|
32
|
-
// for (every other operation's mode is fixed, and the platform refuses the
|
|
33
|
-
// field there). The key type is derived from the union, so adding or removing
|
|
34
|
-
// `mode` on a request without changing this table does not compile.
|
|
35
|
-
const declaredModeOperations = {
|
|
36
|
-
'slack.channel.history': true, 'slack.message.post': true, 'slack.message.update': true, 'slack.message.delete': true,
|
|
37
|
-
};
|
|
38
33
|
/** The AI reason code carried by an error, when it is a governed-AI refusal. */
|
|
39
34
|
export function aiErrorCode(error) {
|
|
40
35
|
const code = error instanceof IsomorphError ? error.details?.code : undefined;
|
|
@@ -417,8 +412,9 @@ export function createClient(options = {}) {
|
|
|
417
412
|
};
|
|
418
413
|
// The gateway's answer becomes an IsomorphUser in place: the same object
|
|
419
414
|
// must reach every caller (the API-host refresh below relies on it).
|
|
420
|
-
const
|
|
415
|
+
const normalizeUser = (user) => {
|
|
421
416
|
user.groups = userGroups(user.groups);
|
|
417
|
+
user.role = userRole(user.role);
|
|
422
418
|
return user;
|
|
423
419
|
};
|
|
424
420
|
const transport = new Transport(options, () => notifyIdentity(null));
|
|
@@ -429,7 +425,7 @@ export function createClient(options = {}) {
|
|
|
429
425
|
refreshesStarted += 1;
|
|
430
426
|
refreshInFlight = transport.json(IDENTITY_PATH)
|
|
431
427
|
.then((answer) => {
|
|
432
|
-
const user = answer === null ? null :
|
|
428
|
+
const user = answer === null ? null : normalizeUser(answer);
|
|
433
429
|
// A user answered by the API host's context refresh was announced by
|
|
434
430
|
// that refresh already (it is the same object): once is enough.
|
|
435
431
|
if (user === null || user !== currentUser)
|
|
@@ -480,7 +476,7 @@ export function createClient(options = {}) {
|
|
|
480
476
|
const user = (await response.json())?.data;
|
|
481
477
|
if (typeof user?.id !== 'string')
|
|
482
478
|
return false;
|
|
483
|
-
notifyIdentity(
|
|
479
|
+
notifyIdentity(normalizeUser(user), true);
|
|
484
480
|
return user;
|
|
485
481
|
})
|
|
486
482
|
.catch(() => false)
|
|
@@ -724,11 +720,11 @@ export function createClient(options = {}) {
|
|
|
724
720
|
if (problem) {
|
|
725
721
|
return Promise.reject(new IsomorphError('VALIDATION_FAILED', problem.message, undefined, { code: 'INPUT_INVALID', field: problem.field }));
|
|
726
722
|
}
|
|
727
|
-
// Exactly the contract's fields go on the wire:
|
|
728
|
-
//
|
|
723
|
+
// Exactly the contract's fields go on the wire: metadata declares which
|
|
724
|
+
// operations accept `mode`; identity and stray fields are not sent.
|
|
729
725
|
const { operation, resource, input, idempotencyKey, mode } = request;
|
|
730
726
|
return transport.json('/_harbour/integrations/execute', {
|
|
731
|
-
method: 'POST', body: JSON.stringify({ connection, operation, resource, ...(input !== undefined ? { input } : {}), ...(idempotencyKey !== undefined ? { idempotencyKey } : {}), ...(operation
|
|
727
|
+
method: 'POST', body: JSON.stringify({ connection, operation, resource, ...(input !== undefined ? { input } : {}), ...(idempotencyKey !== undefined ? { idempotencyKey } : {}), ...(IntegrationOperations[operation]?.declaredMode && mode !== undefined ? { mode } : {}) }),
|
|
732
728
|
});
|
|
733
729
|
},
|
|
734
730
|
// 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 {};
|
package/dist/jobs-cli.js
ADDED
|
@@ -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";
|
|
@@ -10,7 +11,7 @@ export declare const GET_RUN_ACTION = "isomorph-jobs-get";
|
|
|
10
11
|
export declare const CANCEL_RUN_ACTION = "isomorph-jobs-cancel";
|
|
11
12
|
export interface ActionContext {
|
|
12
13
|
signal: AbortSignal;
|
|
13
|
-
user: Pick<IsomorphUser, 'id' | 'email' | 'groups'>;
|
|
14
|
+
user: Pick<IsomorphUser, 'id' | 'email' | 'groups' | 'role'>;
|
|
14
15
|
tenant: string;
|
|
15
16
|
app: string;
|
|
16
17
|
requestId: string;
|
|
@@ -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
|
@@ -57,11 +57,13 @@ import { join, resolve } from 'node:path';
|
|
|
57
57
|
import { pathToFileURL } from 'node:url';
|
|
58
58
|
import { createClient, IsomorphError } from './index.js';
|
|
59
59
|
import { userGroupsFromHeader } from './user-groups.js';
|
|
60
|
+
import { userRole } from './user-role.js';
|
|
61
|
+
import { discoverJobs, invokeJob, loadHandler, JOBS_DIR } from './jobs.js';
|
|
62
|
+
export { JOBS_DIR } from './jobs.js';
|
|
60
63
|
const actionIdentity = new AsyncLocalStorage();
|
|
61
64
|
const identityKey = Symbol.for('isomorph.actionIdentity');
|
|
62
65
|
globalThis[identityKey] = () => actionIdentity.getStore();
|
|
63
66
|
export const ACTIONS_DIR = 'actions';
|
|
64
|
-
export const JOBS_DIR = 'jobs';
|
|
65
67
|
export const RUNS_TABLE = 'isomorph_job_runs';
|
|
66
68
|
export const RESERVED_PREFIX = 'isomorph-';
|
|
67
69
|
export const PING_ACTION = 'isomorph-ping';
|
|
@@ -69,9 +71,6 @@ export const ENQUEUE_ACTION = 'isomorph-jobs-enqueue';
|
|
|
69
71
|
export const GET_RUN_ACTION = 'isomorph-jobs-get';
|
|
70
72
|
export const CANCEL_RUN_ACTION = 'isomorph-jobs-cancel';
|
|
71
73
|
const LOGICAL_NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
72
|
-
// \x60 is the backtick: the brand guard counts template literals by
|
|
73
|
-
// backticks, and a bare one inside this regex unbalanced its scan.
|
|
74
|
-
const SCHEDULE_DECLARATION = /^export\s+const\s+schedule\s*=\s*["'\x60]([^"'\x60\n]+)["'\x60]/m;
|
|
75
74
|
const MAX_BODY_BYTES = 1 << 20;
|
|
76
75
|
const LEASE_MS = 5 * 60_000;
|
|
77
76
|
const QUEUE_POLL_MS = 2_000;
|
|
@@ -94,12 +93,7 @@ export async function discover(root) {
|
|
|
94
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();
|
|
95
94
|
};
|
|
96
95
|
const actions = (await list(ACTIONS_DIR)).filter((name) => !name.startsWith(RESERVED_PREFIX));
|
|
97
|
-
const jobs =
|
|
98
|
-
for (const name of await list(JOBS_DIR)) {
|
|
99
|
-
const body = await readFile(join(root, JOBS_DIR, `${name}.ts`), 'utf8');
|
|
100
|
-
const match = SCHEDULE_DECLARATION.exec(body);
|
|
101
|
-
jobs.push(match ? { name, schedule: match[1].trim() } : { name });
|
|
102
|
-
}
|
|
96
|
+
const jobs = await discoverJobs(root);
|
|
103
97
|
return { actions, jobs };
|
|
104
98
|
}
|
|
105
99
|
// ---- Cron ------------------------------------------------------------------
|
|
@@ -137,21 +131,6 @@ export function cronMatches(expression, at) {
|
|
|
137
131
|
&& fieldMatches(month, at.getUTCMonth() + 1, 1, 12)
|
|
138
132
|
&& (fieldMatches(weekday, at.getUTCDay(), 0, 7) || (sunday && fieldMatches(weekday, 7, 0, 7)));
|
|
139
133
|
}
|
|
140
|
-
const modules = new Map();
|
|
141
|
-
function loadHandler(root, dir, name) {
|
|
142
|
-
const key = `${dir}/${name}`;
|
|
143
|
-
let loading = modules.get(key);
|
|
144
|
-
if (!loading) {
|
|
145
|
-
loading = import(pathToFileURL(join(root, dir, `${name}.ts`)).href).then((module) => {
|
|
146
|
-
if (typeof module.default !== 'function')
|
|
147
|
-
throw new Error(`${key}.ts has no default handler`);
|
|
148
|
-
return module.default;
|
|
149
|
-
});
|
|
150
|
-
loading.catch(() => modules.delete(key));
|
|
151
|
-
modules.set(key, loading);
|
|
152
|
-
}
|
|
153
|
-
return loading;
|
|
154
|
-
}
|
|
155
134
|
// ---- HTTP helpers ------------------------------------------------------------
|
|
156
135
|
// Once an action has streamed progress the status line is gone, so its
|
|
157
136
|
// answer — result or refusal — is the stream's last line, the refusal
|
|
@@ -446,8 +425,7 @@ export class Runtime {
|
|
|
446
425
|
this.running.add(key);
|
|
447
426
|
try {
|
|
448
427
|
await this.prepareAI();
|
|
449
|
-
|
|
450
|
-
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 });
|
|
451
429
|
this.log(`jobs/${name}.ts ran for ${at.toISOString()}`);
|
|
452
430
|
return true;
|
|
453
431
|
}
|
|
@@ -481,8 +459,7 @@ export class Runtime {
|
|
|
481
459
|
}
|
|
482
460
|
try {
|
|
483
461
|
await this.prepareAI();
|
|
484
|
-
const
|
|
485
|
-
const result = await handler({
|
|
462
|
+
const result = await invokeJob(this.options.root, run.job, {
|
|
486
463
|
scheduledAt: now.toISOString(), runId: run.id, input: run.input,
|
|
487
464
|
progress: (percent, note) => this.runs.progress(run.id, percent, note),
|
|
488
465
|
cancelled: () => this.runs.cancelRequested(run.id),
|
|
@@ -515,6 +492,7 @@ export class Runtime {
|
|
|
515
492
|
id: header(request, 'X-Harbour-Verified-User-ID'),
|
|
516
493
|
email: header(request, 'X-Harbour-Verified-User-Email'),
|
|
517
494
|
groups: userGroupsFromHeader(header(request, 'X-Harbour-Verified-User-Groups')),
|
|
495
|
+
role: userRole(header(request, 'X-Harbour-Verified-User-Role')),
|
|
518
496
|
};
|
|
519
497
|
const streams = header(request, 'accept').toLowerCase().includes(PROGRESS_MEDIA_TYPE);
|
|
520
498
|
let steps = 0;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@isomorph.ai/app-sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.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
|
}
|