@isomorph.ai/app-sdk 1.3.1 → 1.4.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/runtime.d.ts CHANGED
@@ -56,11 +56,18 @@ export declare class Runs {
56
56
  cancelled?: boolean;
57
57
  }): Promise<void>;
58
58
  }
59
+ /**
60
+ * What the process runs beside the action server:
61
+ * full the in-process cron scheduler and the queue loop (the default)
62
+ * actions the queue loop only; the platform's CronJobs run the schedules
63
+ * gate nothing: actions only, for the kit gate
64
+ */
65
+ export type RuntimeMode = 'full' | 'actions' | 'gate';
59
66
  export interface RuntimeOptions {
60
67
  root: string;
61
68
  port: number;
62
69
  gatewayUrl: string;
63
- mode: 'full' | 'gate';
70
+ mode: RuntimeMode;
64
71
  tokenFile?: string;
65
72
  log?: (line: string) => void;
66
73
  }
@@ -75,10 +82,19 @@ export declare class Runtime {
75
82
  readonly server: import("http").Server<typeof IncomingMessage, typeof ServerResponse>;
76
83
  constructor(options: RuntimeOptions);
77
84
  start(): Promise<number>;
85
+ /**
86
+ * `--run-job <name>`: one scheduled run of one job, as a CronJob pod runs it.
87
+ * The run is the scheduler's own (`runScheduled`, the current minute as its
88
+ * tick), so it is loaded, run and recorded exactly as an in-process tick
89
+ * would have been. False when the app has no such job or the handler threw;
90
+ * the log names which.
91
+ */
92
+ runJobOnce(name: string, now?: Date): Promise<boolean>;
78
93
  stop(): Promise<void>;
79
94
  private every;
80
95
  private refreshToken;
81
96
  private scheduleTick;
97
+ /** One scheduled run; true when the handler returned, false when it threw or the last tick's run is still going. */
82
98
  private runScheduled;
83
99
  /** One pass of the queue loop: reclaim expired leases, claim one run, run it. */
84
100
  work(): Promise<void>;
@@ -87,3 +103,5 @@ export declare class Runtime {
87
103
  }
88
104
  /** The runtime's settings from the environment; exported for the entry point and its test. */
89
105
  export declare function optionsFromEnvironment(env?: NodeJS.ProcessEnv): RuntimeOptions;
106
+ /** The job named by `--run-job <name>` or `--run-job=<name>`; undefined when the runtime is a server. */
107
+ 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';
@@ -209,14 +222,28 @@ export class Runtime {
209
222
  this.log(`serving ${this.inventory.actions.length} action(s) and ${this.inventory.jobs.length} job(s) on port ${port} (${this.options.mode})`);
210
223
  if (this.options.tokenFile)
211
224
  this.every(TOKEN_REFRESH_MS, () => this.refreshToken());
212
- if (this.options.mode === 'full') {
213
- if (this.inventory.jobs.some((job) => job.schedule))
214
- this.scheduleTick();
215
- if (this.inventory.jobs.length > 0 && this.options.gatewayUrl)
216
- this.every(QUEUE_POLL_MS, () => this.work());
217
- }
225
+ if (this.options.mode === 'full' && this.inventory.jobs.some((job) => job.schedule))
226
+ this.scheduleTick();
227
+ if (this.options.mode !== 'gate' && this.inventory.jobs.length > 0 && this.options.gatewayUrl)
228
+ this.every(QUEUE_POLL_MS, () => this.work());
218
229
  return port;
219
230
  }
231
+ /**
232
+ * `--run-job <name>`: one scheduled run of one job, as a CronJob pod runs it.
233
+ * The run is the scheduler's own (`runScheduled`, the current minute as its
234
+ * tick), so it is loaded, run and recorded exactly as an in-process tick
235
+ * would have been. False when the app has no such job or the handler threw;
236
+ * the log names which.
237
+ */
238
+ async runJobOnce(name, now = new Date()) {
239
+ this.inventory = await discover(this.options.root);
240
+ await this.refreshToken();
241
+ if (!this.inventory.jobs.some((job) => job.name === name)) {
242
+ this.log(`this app has no ${JOBS_DIR}/${name}.ts`);
243
+ return false;
244
+ }
245
+ return this.runScheduled(name, new Date(Math.floor(now.getTime() / 60_000) * 60_000));
246
+ }
220
247
  async stop() {
221
248
  this.stopped = true;
222
249
  for (const timer of this.timers)
@@ -267,20 +294,23 @@ export class Runtime {
267
294
  this.scheduleTick();
268
295
  }, Math.max(0, next - now)));
269
296
  }
297
+ /** One scheduled run; true when the handler returned, false when it threw or the last tick's run is still going. */
270
298
  async runScheduled(name, at) {
271
299
  const key = `schedule:${name}`;
272
300
  if (this.running.has(key)) {
273
301
  this.log(`jobs/${name}.ts is still running; the ${at.toISOString()} tick is skipped`);
274
- return;
302
+ return false;
275
303
  }
276
304
  this.running.add(key);
277
305
  try {
278
306
  const handler = await loadHandler(this.options.root, JOBS_DIR, name);
279
307
  await handler({ scheduledAt: at.toISOString(), progress: async () => undefined, cancelled: async () => false });
280
308
  this.log(`jobs/${name}.ts ran for ${at.toISOString()}`);
309
+ return true;
281
310
  }
282
311
  catch (error) {
283
312
  this.log(`jobs/${name}.ts failed for ${at.toISOString()}: ${error instanceof Error ? error.message : String(error)}`);
313
+ return false;
284
314
  }
285
315
  finally {
286
316
  this.running.delete(key);
@@ -394,10 +424,21 @@ export function optionsFromEnvironment(env = process.env) {
394
424
  root: resolve(env.ISOMORPH_APP_ROOT || process.cwd()),
395
425
  port: Number(env.PORT || 8080),
396
426
  gatewayUrl: (env.ISOMORPH_GATEWAY_URL || env.HARBOUR_GATEWAY_URL || '').replace(/\/$/, ''),
397
- mode: env.ISOMORPH_RUNTIME_MODE === 'gate' ? 'gate' : 'full',
427
+ mode: env.ISOMORPH_RUNTIME_MODE === 'gate' ? 'gate' : env.ISOMORPH_JOBS_SCHEDULER === 'platform' ? 'actions' : 'full',
398
428
  tokenFile: env.HARBOUR_WORKLOAD_TOKEN_FILE || undefined,
399
429
  };
400
430
  }
431
+ /** The job named by `--run-job <name>` or `--run-job=<name>`; undefined when the runtime is a server. */
432
+ export function runJobFromArguments(argv = process.argv.slice(2)) {
433
+ for (let i = 0; i < argv.length; i += 1) {
434
+ const argument = argv[i];
435
+ if (argument === '--run-job')
436
+ return argv[i + 1] ?? '';
437
+ if (argument.startsWith('--run-job='))
438
+ return argument.slice('--run-job='.length);
439
+ }
440
+ return undefined;
441
+ }
401
442
  const entry = process.argv[1] ? pathToFileURL(process.argv[1]).href : '';
402
443
  if (import.meta.url === entry) {
403
444
  // Extensionless relative imports in the app's server code resolve to
@@ -412,6 +453,14 @@ if (import.meta.url === entry) {
412
453
  process.exit(2);
413
454
  }
414
455
  const runtime = new Runtime(options);
456
+ const job = runJobFromArguments();
457
+ if (job !== undefined) {
458
+ if (!job) {
459
+ console.error('isomorph runtime: --run-job needs a job name');
460
+ process.exit(2);
461
+ }
462
+ process.exit((await runtime.runJobOnce(job)) ? 0 : 1);
463
+ }
415
464
  const shutdown = () => { void runtime.stop().then(() => process.exit(0)); };
416
465
  process.on('SIGTERM', shutdown);
417
466
  process.on('SIGINT', shutdown);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isomorph.ai/app-sdk",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",