@isomorph.ai/app-sdk 1.2.1 → 1.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.
package/dist/index.d.ts CHANGED
@@ -144,6 +144,24 @@ export type IntegrationConnectResult = {
144
144
  export interface IntegrationDisconnectResult {
145
145
  status: 'disconnected';
146
146
  }
147
+ export type JobRunStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';
148
+ export interface JobRun {
149
+ id: string;
150
+ job: string;
151
+ status: JobRunStatus;
152
+ attempt: number;
153
+ max_attempts: number;
154
+ progress: number;
155
+ note: string | null;
156
+ input: unknown;
157
+ result: unknown;
158
+ error: string | null;
159
+ owner_subject: string;
160
+ cancel_requested: boolean;
161
+ lease_until: string | null;
162
+ created_at: string;
163
+ updated_at: string;
164
+ }
147
165
  export type AiRole = 'system' | 'user' | 'assistant';
148
166
  export interface AiMessage {
149
167
  role: AiRole;
@@ -334,6 +352,13 @@ export declare function createClient(options?: ClientOptions): {
334
352
  actions: {
335
353
  invoke: <T = unknown>(name: string, input: unknown) => Promise<T>;
336
354
  };
355
+ jobs: {
356
+ enqueue: (name: string, input?: unknown) => Promise<{
357
+ id: string;
358
+ }>;
359
+ get: (id: string) => Promise<JobRun>;
360
+ cancel: (id: string) => Promise<JobRun>;
361
+ };
337
362
  telemetry: {
338
363
  track: (name: string, properties?: Record<string, unknown>) => Promise<void>;
339
364
  page: (path?: string) => Promise<void>;
package/dist/index.js CHANGED
@@ -535,6 +535,11 @@ export function createClient(options = {}) {
535
535
  });
536
536
  },
537
537
  },
538
+ jobs: {
539
+ enqueue: (name, input) => transport.json('/_harbour/actions/isomorph-jobs-enqueue', { method: 'POST', body: JSON.stringify({ name, input: input ?? null }) }),
540
+ get: (id) => transport.json('/_harbour/actions/isomorph-jobs-get', { method: 'POST', body: JSON.stringify({ id }) }),
541
+ cancel: (id) => transport.json('/_harbour/actions/isomorph-jobs-cancel', { method: 'POST', body: JSON.stringify({ id }) }),
542
+ },
538
543
  telemetry: {
539
544
  track: (name, properties = {}) => transport.json('/_harbour/telemetry', {
540
545
  method: 'POST', body: JSON.stringify({ name, properties }),
@@ -0,0 +1,89 @@
1
+ import { type IncomingMessage, type ServerResponse } from 'node:http';
2
+ import { type IsomorphClient, type JobRun } from './index.js';
3
+ export declare const ACTIONS_DIR = "actions";
4
+ export declare const JOBS_DIR = "jobs";
5
+ export declare const RUNS_TABLE = "isomorph_job_runs";
6
+ export declare const RESERVED_PREFIX = "isomorph-";
7
+ export declare const PING_ACTION = "isomorph-ping";
8
+ export declare const ENQUEUE_ACTION = "isomorph-jobs-enqueue";
9
+ export declare const GET_RUN_ACTION = "isomorph-jobs-get";
10
+ export declare const CANCEL_RUN_ACTION = "isomorph-jobs-cancel";
11
+ export interface ActionContext {
12
+ user: {
13
+ id: string;
14
+ email: string;
15
+ };
16
+ tenant: string;
17
+ app: string;
18
+ requestId: string;
19
+ }
20
+ export interface JobContext {
21
+ scheduledAt: string;
22
+ runId?: string;
23
+ input?: unknown;
24
+ progress(percent: number, note?: string): Promise<void>;
25
+ cancelled(): Promise<boolean>;
26
+ }
27
+ export interface RuntimeInventory {
28
+ actions: string[];
29
+ jobs: {
30
+ name: string;
31
+ schedule?: string;
32
+ }[];
33
+ }
34
+ /** Every `actions/<name>.ts` and `jobs/<name>.ts` the app carries, by logical name. */
35
+ export declare function discover(root: string): Promise<RuntimeInventory>;
36
+ /** Whether a five-field UTC cron expression names the given minute. Sunday is 0 and 7. */
37
+ export declare function cronMatches(expression: string, at: Date): boolean;
38
+ export declare class Runs {
39
+ private readonly client;
40
+ constructor(client: IsomorphClient);
41
+ private table;
42
+ enqueue(job: string, input: unknown, owner: string): Promise<{
43
+ id: string;
44
+ }>;
45
+ get(id: string, owner: string): Promise<JobRun | null>;
46
+ cancel(id: string, owner: string): Promise<JobRun | null>;
47
+ /** Rows whose lease ran out go back to the queue while attempts remain, else fail. */
48
+ reclaim(now: Date): Promise<void>;
49
+ /** Claims the oldest queued run by a conditional update; null when the queue is empty or another worker won. */
50
+ claim(now: Date): Promise<JobRun | null>;
51
+ progress(id: string, percent: number, note?: string): Promise<void>;
52
+ cancelRequested(id: string): Promise<boolean>;
53
+ finish(run: JobRun, outcome: {
54
+ result?: unknown;
55
+ error?: string;
56
+ cancelled?: boolean;
57
+ }): Promise<void>;
58
+ }
59
+ export interface RuntimeOptions {
60
+ root: string;
61
+ port: number;
62
+ gatewayUrl: string;
63
+ mode: 'full' | 'gate';
64
+ tokenFile?: string;
65
+ log?: (line: string) => void;
66
+ }
67
+ export declare class Runtime {
68
+ private readonly options;
69
+ private inventory;
70
+ private readonly running;
71
+ private readonly timers;
72
+ private stopped;
73
+ private readonly runs;
74
+ private readonly log;
75
+ readonly server: import("http").Server<typeof IncomingMessage, typeof ServerResponse>;
76
+ constructor(options: RuntimeOptions);
77
+ start(): Promise<number>;
78
+ stop(): Promise<void>;
79
+ private every;
80
+ private refreshToken;
81
+ private scheduleTick;
82
+ private runScheduled;
83
+ /** One pass of the queue loop: reclaim expired leases, claim one run, run it. */
84
+ work(): Promise<void>;
85
+ private serve;
86
+ private reserved;
87
+ }
88
+ /** The runtime's settings from the environment; exported for the entry point and its test. */
89
+ export declare function optionsFromEnvironment(env?: NodeJS.ProcessEnv): RuntimeOptions;
@@ -0,0 +1,414 @@
1
+ // The kit runtime: the one server-side process of a kit app. It serves the
2
+ // app's `actions/<name>.ts` on the gateway's action route, runs `jobs/<name>.ts`
3
+ // on their cron in-process, and works the app's queued runs. The same file
4
+ // runs everywhere — the hosted pod, `isomorph dev` and the gate — started as
5
+ //
6
+ // node --experimental-strip-types node_modules/@isomorph.ai/app-sdk/dist/runtime.js
7
+ //
8
+ // from the app root, so Node itself strips the app's TypeScript, exactly as
9
+ // the V1 job runner and `isomorph jobs run` do. Nothing here reaches a
10
+ // database or a company system directly: every read and write goes through
11
+ // the App Gateway as the workload identity, the same door the browser uses.
12
+ //
13
+ // Environment:
14
+ // PORT the action server's port (default 8080)
15
+ // ISOMORPH_GATEWAY_URL the gateway the SDK calls; HARBOUR_GATEWAY_URL
16
+ // is the container-side spelling
17
+ // ISOMORPH_WORKLOAD_TOKEN the workload's bearer; HARBOUR_WORKLOAD_TOKEN_FILE
18
+ // names a projected token file re-read every minute
19
+ // ISOMORPH_RUNTIME_MODE=gate serve actions only: no scheduler, no queue loop
20
+ // ISOMORPH_APP_ROOT the app root (default: the working directory)
21
+ import { createServer } from 'node:http';
22
+ import { readFile, readdir, stat } from 'node:fs/promises';
23
+ import { join, resolve } from 'node:path';
24
+ import { pathToFileURL } from 'node:url';
25
+ import { createClient, IsomorphError } from './index.js';
26
+ export const ACTIONS_DIR = 'actions';
27
+ export const JOBS_DIR = 'jobs';
28
+ export const RUNS_TABLE = 'isomorph_job_runs';
29
+ export const RESERVED_PREFIX = 'isomorph-';
30
+ export const PING_ACTION = 'isomorph-ping';
31
+ export const ENQUEUE_ACTION = 'isomorph-jobs-enqueue';
32
+ export const GET_RUN_ACTION = 'isomorph-jobs-get';
33
+ export const CANCEL_RUN_ACTION = 'isomorph-jobs-cancel';
34
+ const LOGICAL_NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/;
35
+ // \x60 is the backtick: the brand guard counts template literals by
36
+ // backticks, and a bare one inside this regex unbalanced its scan.
37
+ const SCHEDULE_DECLARATION = /^export\s+const\s+schedule\s*=\s*["'\x60]([^"'\x60\n]+)["'\x60]/m;
38
+ const MAX_BODY_BYTES = 1 << 20;
39
+ const LEASE_MS = 5 * 60_000;
40
+ const QUEUE_POLL_MS = 2_000;
41
+ const TOKEN_REFRESH_MS = 60_000;
42
+ const DEFAULT_MAX_ATTEMPTS = 3;
43
+ /** Every `actions/<name>.ts` and `jobs/<name>.ts` the app carries, by logical name. */
44
+ export async function discover(root) {
45
+ const list = async (dir) => {
46
+ const entries = await readdir(join(root, dir), { withFileTypes: true }).catch(() => []);
47
+ return entries.filter((entry) => entry.isFile() && entry.name.endsWith('.ts')).map((entry) => entry.name.slice(0, -3)).filter((name) => LOGICAL_NAME.test(name)).sort();
48
+ };
49
+ const actions = (await list(ACTIONS_DIR)).filter((name) => !name.startsWith(RESERVED_PREFIX));
50
+ const jobs = [];
51
+ for (const name of await list(JOBS_DIR)) {
52
+ const body = await readFile(join(root, JOBS_DIR, `${name}.ts`), 'utf8');
53
+ const match = SCHEDULE_DECLARATION.exec(body);
54
+ jobs.push(match ? { name, schedule: match[1].trim() } : { name });
55
+ }
56
+ return { actions, jobs };
57
+ }
58
+ // ---- Cron ------------------------------------------------------------------
59
+ /** One field of a five-field cron expression: numbers, `*`, lists, ranges and steps, no names. */
60
+ function fieldMatches(field, value, min, max) {
61
+ return field.split(',').some((part) => {
62
+ const [range, stepText] = part.split('/');
63
+ const step = stepText === undefined ? 1 : Number(stepText);
64
+ if (!Number.isInteger(step) || step < 1)
65
+ throw new Error(`invalid cron step in ${part}`);
66
+ let from = min;
67
+ let to = max;
68
+ if (range !== '*') {
69
+ const [a, b] = range.split('-').map(Number);
70
+ if (!Number.isInteger(a) || (b !== undefined && !Number.isInteger(b)))
71
+ throw new Error(`invalid cron field ${part}`);
72
+ from = a;
73
+ to = b === undefined ? (stepText === undefined ? a : max) : b;
74
+ }
75
+ if (from < min || to > max || from > to)
76
+ throw new Error(`cron field ${part} is out of range ${min}-${max}`);
77
+ return value >= from && value <= to && (value - from) % step === 0;
78
+ });
79
+ }
80
+ /** Whether a five-field UTC cron expression names the given minute. Sunday is 0 and 7. */
81
+ export function cronMatches(expression, at) {
82
+ const fields = expression.trim().split(/\s+/);
83
+ if (fields.length !== 5)
84
+ throw new Error(`a schedule has five fields, not ${fields.length}: ${expression}`);
85
+ const [minute, hour, day, month, weekday] = fields;
86
+ const sunday = at.getUTCDay() === 0;
87
+ return fieldMatches(minute, at.getUTCMinutes(), 0, 59)
88
+ && fieldMatches(hour, at.getUTCHours(), 0, 23)
89
+ && fieldMatches(day, at.getUTCDate(), 1, 31)
90
+ && fieldMatches(month, at.getUTCMonth() + 1, 1, 12)
91
+ && (fieldMatches(weekday, at.getUTCDay(), 0, 7) || (sunday && fieldMatches(weekday, 7, 0, 7)));
92
+ }
93
+ const modules = new Map();
94
+ function loadHandler(root, dir, name) {
95
+ const key = `${dir}/${name}`;
96
+ let loading = modules.get(key);
97
+ if (!loading) {
98
+ loading = import(pathToFileURL(join(root, dir, `${name}.ts`)).href).then((module) => {
99
+ if (typeof module.default !== 'function')
100
+ throw new Error(`${key}.ts has no default handler`);
101
+ return module.default;
102
+ });
103
+ loading.catch(() => modules.delete(key));
104
+ modules.set(key, loading);
105
+ }
106
+ return loading;
107
+ }
108
+ // ---- HTTP helpers ------------------------------------------------------------
109
+ function answer(response, status, body, requestId) {
110
+ const encoded = JSON.stringify(body);
111
+ response.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(encoded), ...(requestId ? { 'x-request-id': requestId } : {}) });
112
+ response.end(encoded);
113
+ }
114
+ function refuse(response, status, category, message, requestId) {
115
+ answer(response, status, { error: { category, message } }, requestId);
116
+ }
117
+ async function readBody(request) {
118
+ const chunks = [];
119
+ let size = 0;
120
+ for await (const chunk of request) {
121
+ size += chunk.length;
122
+ if (size > MAX_BODY_BYTES)
123
+ return undefined;
124
+ chunks.push(chunk);
125
+ }
126
+ return Buffer.concat(chunks);
127
+ }
128
+ function header(request, name) {
129
+ const value = request.headers[name.toLowerCase()];
130
+ return (Array.isArray(value) ? value[0] : value) ?? '';
131
+ }
132
+ // ---- Runs (through the gateway, as the workload) ------------------------------
133
+ export class Runs {
134
+ client;
135
+ constructor(client) {
136
+ this.client = client;
137
+ }
138
+ table() { return this.client.data.from(RUNS_TABLE); }
139
+ async enqueue(job, input, owner) {
140
+ const { data } = await this.table().insert({ job, input: input ?? null, status: 'queued', attempt: 0, max_attempts: DEFAULT_MAX_ATTEMPTS, progress: 0, owner_subject: owner, cancel_requested: false }).select('id').single();
141
+ return { id: data.id };
142
+ }
143
+ async get(id, owner) {
144
+ const { data } = await this.table().select('*').eq('id', id).eq('owner_subject', owner).maybeSingle();
145
+ return data;
146
+ }
147
+ async cancel(id, owner) {
148
+ const { data } = await this.table().update({ cancel_requested: true, updated_at: new Date().toISOString() }).eq('id', id).eq('owner_subject', owner).select('*').maybeSingle();
149
+ if (data && data.status === 'queued') {
150
+ const { data: cancelled } = await this.table().update({ status: 'cancelled', updated_at: new Date().toISOString() }).eq('id', id).eq('status', 'queued').select('*').maybeSingle();
151
+ return cancelled ?? data;
152
+ }
153
+ return data;
154
+ }
155
+ /** Rows whose lease ran out go back to the queue while attempts remain, else fail. */
156
+ async reclaim(now) {
157
+ const { data: expired } = await this.table().select('*').eq('status', 'running').lt('lease_until', now.toISOString()).limit(20);
158
+ for (const run of expired) {
159
+ const exhausted = run.attempt >= run.max_attempts;
160
+ await this.table().update({ status: exhausted ? 'failed' : 'queued', error: exhausted ? 'the worker was interrupted and no attempts remain' : run.error, lease_until: null, updated_at: now.toISOString() }).eq('id', run.id).eq('status', 'running');
161
+ }
162
+ }
163
+ /** Claims the oldest queued run by a conditional update; null when the queue is empty or another worker won. */
164
+ async claim(now) {
165
+ const { data: queued } = await this.table().select('id, attempt').eq('status', 'queued').order('created_at').limit(1);
166
+ const next = queued[0];
167
+ if (!next)
168
+ return null;
169
+ const { data } = await this.table().update({ status: 'running', attempt: next.attempt + 1, lease_until: new Date(now.getTime() + LEASE_MS).toISOString(), updated_at: now.toISOString() }).eq('id', next.id).eq('status', 'queued').select('*').maybeSingle();
170
+ return data;
171
+ }
172
+ async progress(id, percent, note) {
173
+ await this.table().update({ progress: Math.max(0, Math.min(100, Math.round(percent))), ...(note === undefined ? {} : { note }), lease_until: new Date(Date.now() + LEASE_MS).toISOString(), updated_at: new Date().toISOString() }).eq('id', id);
174
+ }
175
+ async cancelRequested(id) {
176
+ const { data } = await this.table().select('cancel_requested').eq('id', id).maybeSingle();
177
+ return Boolean(data?.cancel_requested);
178
+ }
179
+ async finish(run, outcome) {
180
+ const retry = outcome.error !== undefined && !outcome.cancelled && run.attempt < run.max_attempts;
181
+ const status = outcome.cancelled ? 'cancelled' : outcome.error === undefined ? 'succeeded' : retry ? 'queued' : 'failed';
182
+ await this.table().update({ status, result: outcome.result ?? null, error: outcome.error ?? null, progress: status === 'succeeded' ? 100 : run.progress, lease_until: null, updated_at: new Date().toISOString() }).eq('id', run.id).eq('status', 'running');
183
+ }
184
+ }
185
+ export class Runtime {
186
+ options;
187
+ inventory = { actions: [], jobs: [] };
188
+ running = new Set();
189
+ timers = [];
190
+ stopped = false;
191
+ runs;
192
+ log;
193
+ server = createServer((request, response) => { void this.serve(request, response); });
194
+ constructor(options) {
195
+ this.options = options;
196
+ this.log = options.log ?? ((line) => console.error(`isomorph runtime: ${line}`));
197
+ this.runs = new Runs(createClient({ baseUrl: options.gatewayUrl || 'http://127.0.0.1:0' }));
198
+ }
199
+ async start() {
200
+ this.inventory = await discover(this.options.root);
201
+ await this.refreshToken();
202
+ await new Promise((done, fail) => {
203
+ this.server.once('error', fail);
204
+ this.server.listen(this.options.port, '0.0.0.0', () => done());
205
+ });
206
+ const address = this.server.address();
207
+ const port = typeof address === 'object' && address ? address.port : this.options.port;
208
+ this.log(`serving ${this.inventory.actions.length} action(s) and ${this.inventory.jobs.length} job(s) on port ${port} (${this.options.mode})`);
209
+ if (this.options.tokenFile)
210
+ this.every(TOKEN_REFRESH_MS, () => this.refreshToken());
211
+ if (this.options.mode === 'full') {
212
+ if (this.inventory.jobs.some((job) => job.schedule))
213
+ this.scheduleTick();
214
+ if (this.inventory.jobs.length > 0 && this.options.gatewayUrl)
215
+ this.every(QUEUE_POLL_MS, () => this.work());
216
+ }
217
+ return port;
218
+ }
219
+ async stop() {
220
+ this.stopped = true;
221
+ for (const timer of this.timers)
222
+ clearTimeout(timer);
223
+ await new Promise((done) => this.server.close(() => done()));
224
+ }
225
+ every(ms, task) {
226
+ const loop = () => {
227
+ if (this.stopped)
228
+ return;
229
+ Promise.resolve().then(task).catch((error) => this.log(`${error instanceof Error ? error.message : String(error)}`)).finally(() => {
230
+ if (!this.stopped)
231
+ this.timers.push(setTimeout(loop, ms));
232
+ });
233
+ };
234
+ this.timers.push(setTimeout(loop, ms));
235
+ }
236
+ async refreshToken() {
237
+ if (!this.options.tokenFile)
238
+ return;
239
+ const token = (await readFile(this.options.tokenFile, 'utf8')).trim();
240
+ if (token)
241
+ process.env.ISOMORPH_WORKLOAD_TOKEN = token;
242
+ }
243
+ // The scheduler ticks at the top of every minute and runs each job whose
244
+ // cron names that minute; a job still running from its last tick is
245
+ // skipped, the V1 CronJob's Forbid policy.
246
+ scheduleTick() {
247
+ const now = Date.now();
248
+ const next = Math.floor(now / 60_000) * 60_000 + 60_000;
249
+ this.timers.push(setTimeout(() => {
250
+ if (this.stopped)
251
+ return;
252
+ const at = new Date(Math.round(Date.now() / 60_000) * 60_000);
253
+ for (const job of this.inventory.jobs) {
254
+ if (!job.schedule)
255
+ continue;
256
+ let due = false;
257
+ try {
258
+ due = cronMatches(job.schedule, at);
259
+ }
260
+ catch (error) {
261
+ this.log(`jobs/${job.name}.ts: ${error.message}`);
262
+ }
263
+ if (due)
264
+ void this.runScheduled(job.name, at);
265
+ }
266
+ this.scheduleTick();
267
+ }, Math.max(0, next - now)));
268
+ }
269
+ async runScheduled(name, at) {
270
+ const key = `schedule:${name}`;
271
+ if (this.running.has(key)) {
272
+ this.log(`jobs/${name}.ts is still running; the ${at.toISOString()} tick is skipped`);
273
+ return;
274
+ }
275
+ this.running.add(key);
276
+ try {
277
+ const handler = await loadHandler(this.options.root, JOBS_DIR, name);
278
+ await handler({ scheduledAt: at.toISOString(), progress: async () => undefined, cancelled: async () => false });
279
+ this.log(`jobs/${name}.ts ran for ${at.toISOString()}`);
280
+ }
281
+ catch (error) {
282
+ this.log(`jobs/${name}.ts failed for ${at.toISOString()}: ${error instanceof Error ? error.message : String(error)}`);
283
+ }
284
+ finally {
285
+ this.running.delete(key);
286
+ }
287
+ }
288
+ /** One pass of the queue loop: reclaim expired leases, claim one run, run it. */
289
+ async work() {
290
+ if (this.running.has('queue'))
291
+ return;
292
+ this.running.add('queue');
293
+ try {
294
+ const now = new Date();
295
+ await this.runs.reclaim(now);
296
+ const run = await this.runs.claim(now);
297
+ if (!run)
298
+ return;
299
+ if (!this.inventory.jobs.some((job) => job.name === run.job)) {
300
+ await this.runs.finish({ ...run, attempt: run.max_attempts }, { error: `no jobs/${run.job}.ts in this app` });
301
+ return;
302
+ }
303
+ try {
304
+ const handler = await loadHandler(this.options.root, JOBS_DIR, run.job);
305
+ const result = await handler({
306
+ scheduledAt: now.toISOString(), runId: run.id, input: run.input,
307
+ progress: (percent, note) => this.runs.progress(run.id, percent, note),
308
+ cancelled: () => this.runs.cancelRequested(run.id),
309
+ });
310
+ const cancelled = await this.runs.cancelRequested(run.id);
311
+ await this.runs.finish(run, cancelled ? { cancelled: true } : { result: result === undefined ? null : result });
312
+ }
313
+ catch (error) {
314
+ await this.runs.finish(run, { error: error instanceof Error ? error.message : String(error) });
315
+ }
316
+ }
317
+ finally {
318
+ this.running.delete('queue');
319
+ }
320
+ }
321
+ async serve(request, response) {
322
+ const requestId = header(request, 'x-request-id') || undefined;
323
+ const name = decodeURIComponent((request.url ?? '/').split('?')[0].replace(/^\/+/, ''));
324
+ if (request.method !== 'POST')
325
+ return refuse(response, 405, 'VALIDATION_FAILED', 'actions are invoked with POST', requestId);
326
+ const tenant = header(request, 'X-Harbour-Verified-Tenant');
327
+ const app = header(request, 'X-Harbour-Verified-App');
328
+ if (!tenant || !app)
329
+ return refuse(response, 401, 'AUTH_REQUIRED', 'the request did not come through the Isomorph gateway', requestId);
330
+ const context = { user: { id: header(request, 'X-Harbour-Verified-User-ID'), email: header(request, 'X-Harbour-Verified-User-Email') }, tenant, app, requestId: requestId ?? '' };
331
+ const raw = await readBody(request);
332
+ if (raw === undefined)
333
+ return refuse(response, 413, 'VALIDATION_FAILED', 'the request body is larger than 1 MiB', requestId);
334
+ let input = undefined;
335
+ if (raw.length > 0) {
336
+ try {
337
+ input = JSON.parse(raw.toString('utf8'));
338
+ }
339
+ catch {
340
+ return refuse(response, 400, 'VALIDATION_FAILED', 'the request body is not JSON', requestId);
341
+ }
342
+ }
343
+ try {
344
+ if (name.startsWith(RESERVED_PREFIX))
345
+ return answer(response, 200, { data: await this.reserved(name, input, context) }, requestId);
346
+ if (!this.inventory.actions.includes(name))
347
+ return refuse(response, 404, 'NOT_FOUND', `this app has no ${ACTIONS_DIR}/${name}.ts`, requestId);
348
+ const handler = await loadHandler(this.options.root, ACTIONS_DIR, name);
349
+ const result = await handler(input, context);
350
+ return answer(response, 200, { data: result === undefined ? null : result }, requestId);
351
+ }
352
+ catch (error) {
353
+ if (error instanceof IsomorphError)
354
+ return refuse(response, error.status ?? 400, error.category, error.message, requestId);
355
+ this.log(`${name}: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
356
+ return refuse(response, 500, 'INTERNAL', `the action ${name} failed; its log names the cause`, requestId);
357
+ }
358
+ }
359
+ async reserved(name, input, context) {
360
+ const body = (input && typeof input === 'object' ? input : {});
361
+ const owner = context.user.id;
362
+ switch (name) {
363
+ case PING_ACTION:
364
+ return { ok: true, mode: this.options.mode, actions: this.inventory.actions, jobs: this.inventory.jobs.map((job) => job.name) };
365
+ case ENQUEUE_ACTION: {
366
+ const job = typeof body.name === 'string' ? body.name : '';
367
+ if (!this.inventory.jobs.some((entry) => entry.name === job))
368
+ throw new IsomorphError('VALIDATION_FAILED', `this app has no ${JOBS_DIR}/${job || '<name>'}.ts`, undefined, undefined, 400);
369
+ if (!owner)
370
+ throw new IsomorphError('AUTH_REQUIRED', 'a run is started by a signed-in person', undefined, undefined, 401);
371
+ return this.runs.enqueue(job, body.input, owner);
372
+ }
373
+ case GET_RUN_ACTION: {
374
+ const run = typeof body.id === 'string' ? await this.runs.get(body.id, owner) : null;
375
+ if (!run)
376
+ throw new IsomorphError('NOT_FOUND', 'no run of yours has that id', undefined, undefined, 404);
377
+ return run;
378
+ }
379
+ case CANCEL_RUN_ACTION: {
380
+ const run = typeof body.id === 'string' ? await this.runs.cancel(body.id, owner) : null;
381
+ if (!run)
382
+ throw new IsomorphError('NOT_FOUND', 'no run of yours has that id', undefined, undefined, 404);
383
+ return run;
384
+ }
385
+ default:
386
+ throw new IsomorphError('NOT_FOUND', `${name} is not an Isomorph runtime action`, undefined, undefined, 404);
387
+ }
388
+ }
389
+ }
390
+ /** The runtime's settings from the environment; exported for the entry point and its test. */
391
+ export function optionsFromEnvironment(env = process.env) {
392
+ return {
393
+ root: resolve(env.ISOMORPH_APP_ROOT || process.cwd()),
394
+ port: Number(env.PORT || 8080),
395
+ gatewayUrl: (env.ISOMORPH_GATEWAY_URL || env.HARBOUR_GATEWAY_URL || '').replace(/\/$/, ''),
396
+ mode: env.ISOMORPH_RUNTIME_MODE === 'gate' ? 'gate' : 'full',
397
+ tokenFile: env.HARBOUR_WORKLOAD_TOKEN_FILE || undefined,
398
+ };
399
+ }
400
+ const entry = process.argv[1] ? pathToFileURL(process.argv[1]).href : '';
401
+ if (import.meta.url === entry) {
402
+ const options = optionsFromEnvironment();
403
+ if (options.gatewayUrl && !process.env.ISOMORPH_GATEWAY_URL)
404
+ process.env.ISOMORPH_GATEWAY_URL = options.gatewayUrl;
405
+ if (!(await stat(options.root).catch(() => undefined))?.isDirectory()) {
406
+ console.error(`isomorph runtime: ${options.root} is not a directory`);
407
+ process.exit(2);
408
+ }
409
+ const runtime = new Runtime(options);
410
+ const shutdown = () => { void runtime.stop().then(() => process.exit(0)); };
411
+ process.on('SIGTERM', shutdown);
412
+ process.on('SIGINT', shutdown);
413
+ await runtime.start();
414
+ }
package/package.json CHANGED
@@ -1,15 +1,26 @@
1
1
  {
2
2
  "name": "@isomorph.ai/app-sdk",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/Fourier-Labs-AI/harbour-deployment-data-plane.git",
8
+ "directory": "packages/app-sdk"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
5
13
  "main": "dist/index.js",
6
14
  "types": "dist/index.d.ts",
7
- "files": ["dist"],
15
+ "files": [
16
+ "dist"
17
+ ],
8
18
  "scripts": {
9
19
  "build": "tsc -p tsconfig.json",
10
20
  "test": "npm run build && node --test test/*.test.mjs"
11
21
  },
12
22
  "devDependencies": {
13
- "typescript": "5.9.2"
23
+ "typescript": "5.9.2",
24
+ "@types/node": "24.7.0"
14
25
  }
15
26
  }