@isomorph.ai/app-sdk 1.3.1 → 1.4.1
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/runtime.d.ts +22 -1
- package/dist/runtime.js +89 -8
- package/package.json +1 -1
package/dist/runtime.d.ts
CHANGED
|
@@ -39,6 +39,8 @@ export declare class Runs {
|
|
|
39
39
|
private readonly client;
|
|
40
40
|
constructor(client: IsomorphClient);
|
|
41
41
|
private table;
|
|
42
|
+
/** The cheapest read the runs table answers; rejects while the gateway's data path is down. */
|
|
43
|
+
ready(): Promise<void>;
|
|
42
44
|
enqueue(job: string, input: unknown, owner: string): Promise<{
|
|
43
45
|
id: string;
|
|
44
46
|
}>;
|
|
@@ -56,11 +58,18 @@ export declare class Runs {
|
|
|
56
58
|
cancelled?: boolean;
|
|
57
59
|
}): Promise<void>;
|
|
58
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* What the process runs beside the action server:
|
|
63
|
+
* full the in-process cron scheduler and the queue loop (the default)
|
|
64
|
+
* actions the queue loop only; the platform's CronJobs run the schedules
|
|
65
|
+
* gate nothing: actions only, for the kit gate
|
|
66
|
+
*/
|
|
67
|
+
export type RuntimeMode = 'full' | 'actions' | 'gate';
|
|
59
68
|
export interface RuntimeOptions {
|
|
60
69
|
root: string;
|
|
61
70
|
port: number;
|
|
62
71
|
gatewayUrl: string;
|
|
63
|
-
mode:
|
|
72
|
+
mode: RuntimeMode;
|
|
64
73
|
tokenFile?: string;
|
|
65
74
|
log?: (line: string) => void;
|
|
66
75
|
}
|
|
@@ -75,10 +84,20 @@ export declare class Runtime {
|
|
|
75
84
|
readonly server: import("http").Server<typeof IncomingMessage, typeof ServerResponse>;
|
|
76
85
|
constructor(options: RuntimeOptions);
|
|
77
86
|
start(): Promise<number>;
|
|
87
|
+
/**
|
|
88
|
+
* `--run-job <name>`: one scheduled run of one job, as a CronJob pod runs it.
|
|
89
|
+
* The run is the scheduler's own (`runScheduled`, the current minute as its
|
|
90
|
+
* tick), so it is loaded, run and recorded exactly as an in-process tick
|
|
91
|
+
* would have been. False when the app has no such job, the gateway's data
|
|
92
|
+
* path never answered, or the handler threw; the log names which.
|
|
93
|
+
*/
|
|
94
|
+
runJobOnce(name: string, now?: Date): Promise<boolean>;
|
|
95
|
+
private awaitDataPath;
|
|
78
96
|
stop(): Promise<void>;
|
|
79
97
|
private every;
|
|
80
98
|
private refreshToken;
|
|
81
99
|
private scheduleTick;
|
|
100
|
+
/** One scheduled run; true when the handler returned, false when it threw or the last tick's run is still going. */
|
|
82
101
|
private runScheduled;
|
|
83
102
|
/** One pass of the queue loop: reclaim expired leases, claim one run, run it. */
|
|
84
103
|
work(): Promise<void>;
|
|
@@ -87,3 +106,5 @@ export declare class Runtime {
|
|
|
87
106
|
}
|
|
88
107
|
/** The runtime's settings from the environment; exported for the entry point and its test. */
|
|
89
108
|
export declare function optionsFromEnvironment(env?: NodeJS.ProcessEnv): RuntimeOptions;
|
|
109
|
+
/** The job named by `--run-job <name>` or `--run-job=<name>`; undefined when the runtime is a server. */
|
|
110
|
+
export declare function runJobFromArguments(argv?: string[]): string | undefined;
|
package/dist/runtime.js
CHANGED
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
// database or a company system directly: every read and write goes through
|
|
11
11
|
// the App Gateway as the workload identity, the same door the browser uses.
|
|
12
12
|
//
|
|
13
|
+
// Started with `--run-job <name>` it is a CronJob pod instead of a server: it
|
|
14
|
+
// runs that one `jobs/<name>.ts` once for the current minute, the way the
|
|
15
|
+
// in-process scheduler would have, and exits 0 when the handler returned, 1
|
|
16
|
+
// when it threw or the app has no such job. No port is opened, no scheduler
|
|
17
|
+
// or queue loop starts.
|
|
18
|
+
//
|
|
13
19
|
// Environment:
|
|
14
20
|
// PORT the action server's port (default 8080)
|
|
15
21
|
// ISOMORPH_GATEWAY_URL the gateway the SDK calls; HARBOUR_GATEWAY_URL
|
|
@@ -17,6 +23,13 @@
|
|
|
17
23
|
// ISOMORPH_WORKLOAD_TOKEN the workload's bearer; HARBOUR_WORKLOAD_TOKEN_FILE
|
|
18
24
|
// names a projected token file re-read every minute
|
|
19
25
|
// ISOMORPH_RUNTIME_MODE=gate serve actions only: no scheduler, no queue loop
|
|
26
|
+
// ISOMORPH_JOBS_SCHEDULER=platform
|
|
27
|
+
// the platform runs the cron (one CronJob pod per
|
|
28
|
+
// scheduled job, each `--run-job <name>`): serve
|
|
29
|
+
// actions and work the queue, never tick the
|
|
30
|
+
// in-process scheduler. Set by Provision on the
|
|
31
|
+
// hosted kit-runtime; `isomorph dev` leaves it
|
|
32
|
+
// unset and keeps cron in-process
|
|
20
33
|
// ISOMORPH_APP_ROOT the app root (default: the working directory)
|
|
21
34
|
import { createServer } from 'node:http';
|
|
22
35
|
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
@@ -41,6 +54,10 @@ const LEASE_MS = 5 * 60_000;
|
|
|
41
54
|
const QUEUE_POLL_MS = 2_000;
|
|
42
55
|
const TOKEN_REFRESH_MS = 60_000;
|
|
43
56
|
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
57
|
+
// A CronJob pod may start while the app's database is still waking: --run-job
|
|
58
|
+
// waits this long for the gateway's data path, probing every retry interval.
|
|
59
|
+
const DATA_PATH_WAIT_MS = 90_000;
|
|
60
|
+
const DATA_PATH_RETRY_MS = 3_000;
|
|
44
61
|
/** Every `actions/<name>.ts` and `jobs/<name>.ts` the app carries, by logical name. */
|
|
45
62
|
export async function discover(root) {
|
|
46
63
|
const list = async (dir) => {
|
|
@@ -137,6 +154,10 @@ export class Runs {
|
|
|
137
154
|
this.client = client;
|
|
138
155
|
}
|
|
139
156
|
table() { return this.client.data.from(RUNS_TABLE); }
|
|
157
|
+
/** The cheapest read the runs table answers; rejects while the gateway's data path is down. */
|
|
158
|
+
async ready() {
|
|
159
|
+
await this.table().select('id').limit(1);
|
|
160
|
+
}
|
|
140
161
|
async enqueue(job, input, owner) {
|
|
141
162
|
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();
|
|
142
163
|
return { id: data.id };
|
|
@@ -209,14 +230,52 @@ export class Runtime {
|
|
|
209
230
|
this.log(`serving ${this.inventory.actions.length} action(s) and ${this.inventory.jobs.length} job(s) on port ${port} (${this.options.mode})`);
|
|
210
231
|
if (this.options.tokenFile)
|
|
211
232
|
this.every(TOKEN_REFRESH_MS, () => this.refreshToken());
|
|
212
|
-
if (this.options.mode === 'full')
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
this.every(QUEUE_POLL_MS, () => this.work());
|
|
217
|
-
}
|
|
233
|
+
if (this.options.mode === 'full' && this.inventory.jobs.some((job) => job.schedule))
|
|
234
|
+
this.scheduleTick();
|
|
235
|
+
if (this.options.mode !== 'gate' && this.inventory.jobs.length > 0 && this.options.gatewayUrl)
|
|
236
|
+
this.every(QUEUE_POLL_MS, () => this.work());
|
|
218
237
|
return port;
|
|
219
238
|
}
|
|
239
|
+
/**
|
|
240
|
+
* `--run-job <name>`: one scheduled run of one job, as a CronJob pod runs it.
|
|
241
|
+
* The run is the scheduler's own (`runScheduled`, the current minute as its
|
|
242
|
+
* tick), so it is loaded, run and recorded exactly as an in-process tick
|
|
243
|
+
* would have been. False when the app has no such job, the gateway's data
|
|
244
|
+
* path never answered, or the handler threw; the log names which.
|
|
245
|
+
*/
|
|
246
|
+
async runJobOnce(name, now = new Date()) {
|
|
247
|
+
this.inventory = await discover(this.options.root);
|
|
248
|
+
await this.refreshToken();
|
|
249
|
+
if (!this.inventory.jobs.some((job) => job.name === name)) {
|
|
250
|
+
this.log(`this app has no ${JOBS_DIR}/${name}.ts`);
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
if (this.options.gatewayUrl && !(await this.awaitDataPath()))
|
|
254
|
+
return false;
|
|
255
|
+
return this.runScheduled(name, new Date(Math.floor(now.getTime() / 60_000) * 60_000));
|
|
256
|
+
}
|
|
257
|
+
// The pod is scheduled by the cluster clock, not by the app's readiness: its
|
|
258
|
+
// database may be paused or waking (plan §5.3) and the gateway answers 5xx
|
|
259
|
+
// until it is up. The handler runs once the cheapest read answers; when it
|
|
260
|
+
// never does within the wait, the run fails with one line naming the last error.
|
|
261
|
+
async awaitDataPath() {
|
|
262
|
+
const deadline = Date.now() + DATA_PATH_WAIT_MS;
|
|
263
|
+
for (;;) {
|
|
264
|
+
try {
|
|
265
|
+
await this.runs.ready();
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
270
|
+
if (Date.now() + DATA_PATH_RETRY_MS > deadline) {
|
|
271
|
+
this.log(`the data path did not answer within ${DATA_PATH_WAIT_MS / 1000} s: ${reason}`);
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
this.log(`waiting for the data path: ${reason}`);
|
|
275
|
+
await new Promise((wake) => setTimeout(wake, DATA_PATH_RETRY_MS));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
220
279
|
async stop() {
|
|
221
280
|
this.stopped = true;
|
|
222
281
|
for (const timer of this.timers)
|
|
@@ -267,20 +326,23 @@ export class Runtime {
|
|
|
267
326
|
this.scheduleTick();
|
|
268
327
|
}, Math.max(0, next - now)));
|
|
269
328
|
}
|
|
329
|
+
/** One scheduled run; true when the handler returned, false when it threw or the last tick's run is still going. */
|
|
270
330
|
async runScheduled(name, at) {
|
|
271
331
|
const key = `schedule:${name}`;
|
|
272
332
|
if (this.running.has(key)) {
|
|
273
333
|
this.log(`jobs/${name}.ts is still running; the ${at.toISOString()} tick is skipped`);
|
|
274
|
-
return;
|
|
334
|
+
return false;
|
|
275
335
|
}
|
|
276
336
|
this.running.add(key);
|
|
277
337
|
try {
|
|
278
338
|
const handler = await loadHandler(this.options.root, JOBS_DIR, name);
|
|
279
339
|
await handler({ scheduledAt: at.toISOString(), progress: async () => undefined, cancelled: async () => false });
|
|
280
340
|
this.log(`jobs/${name}.ts ran for ${at.toISOString()}`);
|
|
341
|
+
return true;
|
|
281
342
|
}
|
|
282
343
|
catch (error) {
|
|
283
344
|
this.log(`jobs/${name}.ts failed for ${at.toISOString()}: ${error instanceof Error ? error.message : String(error)}`);
|
|
345
|
+
return false;
|
|
284
346
|
}
|
|
285
347
|
finally {
|
|
286
348
|
this.running.delete(key);
|
|
@@ -394,10 +456,21 @@ export function optionsFromEnvironment(env = process.env) {
|
|
|
394
456
|
root: resolve(env.ISOMORPH_APP_ROOT || process.cwd()),
|
|
395
457
|
port: Number(env.PORT || 8080),
|
|
396
458
|
gatewayUrl: (env.ISOMORPH_GATEWAY_URL || env.HARBOUR_GATEWAY_URL || '').replace(/\/$/, ''),
|
|
397
|
-
mode: env.ISOMORPH_RUNTIME_MODE === 'gate' ? 'gate' : 'full',
|
|
459
|
+
mode: env.ISOMORPH_RUNTIME_MODE === 'gate' ? 'gate' : env.ISOMORPH_JOBS_SCHEDULER === 'platform' ? 'actions' : 'full',
|
|
398
460
|
tokenFile: env.HARBOUR_WORKLOAD_TOKEN_FILE || undefined,
|
|
399
461
|
};
|
|
400
462
|
}
|
|
463
|
+
/** The job named by `--run-job <name>` or `--run-job=<name>`; undefined when the runtime is a server. */
|
|
464
|
+
export function runJobFromArguments(argv = process.argv.slice(2)) {
|
|
465
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
466
|
+
const argument = argv[i];
|
|
467
|
+
if (argument === '--run-job')
|
|
468
|
+
return argv[i + 1] ?? '';
|
|
469
|
+
if (argument.startsWith('--run-job='))
|
|
470
|
+
return argument.slice('--run-job='.length);
|
|
471
|
+
}
|
|
472
|
+
return undefined;
|
|
473
|
+
}
|
|
401
474
|
const entry = process.argv[1] ? pathToFileURL(process.argv[1]).href : '';
|
|
402
475
|
if (import.meta.url === entry) {
|
|
403
476
|
// Extensionless relative imports in the app's server code resolve to
|
|
@@ -412,6 +485,14 @@ if (import.meta.url === entry) {
|
|
|
412
485
|
process.exit(2);
|
|
413
486
|
}
|
|
414
487
|
const runtime = new Runtime(options);
|
|
488
|
+
const job = runJobFromArguments();
|
|
489
|
+
if (job !== undefined) {
|
|
490
|
+
if (!job) {
|
|
491
|
+
console.error('isomorph runtime: --run-job needs a job name');
|
|
492
|
+
process.exit(2);
|
|
493
|
+
}
|
|
494
|
+
process.exit((await runtime.runJobOnce(job)) ? 0 : 1);
|
|
495
|
+
}
|
|
415
496
|
const shutdown = () => { void runtime.stop().then(() => process.exit(0)); };
|
|
416
497
|
process.on('SIGTERM', shutdown);
|
|
417
498
|
process.on('SIGINT', shutdown);
|