@isomorph.ai/app-sdk 1.4.1 → 1.4.3

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
@@ -46,17 +46,18 @@ export declare class Runs {
46
46
  }>;
47
47
  get(id: string, owner: string): Promise<JobRun | null>;
48
48
  cancel(id: string, owner: string): Promise<JobRun | null>;
49
- /** Rows whose lease ran out go back to the queue while attempts remain, else fail. */
50
- reclaim(now: Date): Promise<void>;
49
+ /** Rows whose lease ran out go back to the queue while attempts remain, else fail; how many there were. */
50
+ reclaim(now: Date): Promise<number>;
51
51
  /** Claims the oldest queued run by a conditional update; null when the queue is empty or another worker won. */
52
52
  claim(now: Date): Promise<JobRun | null>;
53
53
  progress(id: string, percent: number, note?: string): Promise<void>;
54
54
  cancelRequested(id: string): Promise<boolean>;
55
+ /** Records the outcome; the status written, `queued` when the failure left a retry for the next pass. */
55
56
  finish(run: JobRun, outcome: {
56
57
  result?: unknown;
57
58
  error?: string;
58
59
  cancelled?: boolean;
59
- }): Promise<void>;
60
+ }): Promise<JobRun['status']>;
60
61
  }
61
62
  /**
62
63
  * What the process runs beside the action server:
@@ -71,7 +72,11 @@ export interface RuntimeOptions {
71
72
  gatewayUrl: string;
72
73
  mode: RuntimeMode;
73
74
  tokenFile?: string;
75
+ /** False when the app has no runs table (ISOMORPH_JOB_RUNS=none): nothing reads or works it. */
76
+ jobRuns?: boolean;
74
77
  log?: (line: string) => void;
78
+ /** The pause between queue passes while work is found; the tests shorten it. */
79
+ queuePollMs?: number;
75
80
  }
76
81
  export declare class Runtime {
77
82
  private readonly options;
@@ -79,11 +84,23 @@ export declare class Runtime {
79
84
  private readonly running;
80
85
  private readonly timers;
81
86
  private stopped;
87
+ private queueLive;
88
+ private queueWake;
82
89
  private readonly runs;
83
90
  private readonly log;
84
91
  readonly server: import("http").Server<typeof IncomingMessage, typeof ServerResponse>;
85
92
  constructor(options: RuntimeOptions);
86
93
  start(): Promise<number>;
94
+ /** Whether this process works the queue at all: not the gate, a runs table, an app with jobs, a gateway to reach. */
95
+ private worksQueue;
96
+ /**
97
+ * Starts the queue loop unless it is already live. The first pass runs at
98
+ * once; each further pass follows QUEUE_POLL_MS after one that found work
99
+ * (or threw: a gateway that is waking answers 5xx, and the row that woke
100
+ * the loop is still there). A pass that finds nothing ends the loop with no
101
+ * timer armed, so an idle runtime makes no data calls at all.
102
+ */
103
+ wakeQueue(): void;
87
104
  /**
88
105
  * `--run-job <name>`: one scheduled run of one job, as a CronJob pod runs it.
89
106
  * The run is the scheduler's own (`runScheduled`, the current minute as its
@@ -99,8 +116,13 @@ export declare class Runtime {
99
116
  private scheduleTick;
100
117
  /** One scheduled run; true when the handler returned, false when it threw or the last tick's run is still going. */
101
118
  private runScheduled;
102
- /** One pass of the queue loop: reclaim expired leases, claim one run, run it. */
103
- work(): Promise<void>;
119
+ /**
120
+ * One pass of the queue loop: reclaim expired leases, claim one run, run it.
121
+ * True when the pass found work — a lease reclaimed, a run claimed, or the
122
+ * run it finished left a retry queued — so the loop should look again; false
123
+ * when the queue is empty (or another pass is already going).
124
+ */
125
+ work(): Promise<boolean>;
104
126
  private serve;
105
127
  private reserved;
106
128
  }
package/dist/runtime.js CHANGED
@@ -16,6 +16,20 @@
16
16
  // when it threw or the app has no such job. No port is opened, no scheduler
17
17
  // or queue loop starts.
18
18
  //
19
+ // The queue is worked on demand, not on a clock. An idle kit app makes no
20
+ // database calls: the runtime is the process that serves the enqueue action
21
+ // and the one that finishes every run, so it knows when work exists. It runs
22
+ // one pass at start (reclaiming whatever a crashed predecessor left running
23
+ // and draining any backlog), then loops every QUEUE_POLL_MS only while a pass
24
+ // found something — a claimed run, a reclaimed lease, a run that finished with
25
+ // retries queued — and stops with no timer armed once the queue is empty. An
26
+ // enqueue starts the loop again. This holds because kit-runtime is pinned to
27
+ // one replica; a multi-replica runtime would need a slow periodic poll as well,
28
+ // since a row inserted through another replica is invisible to this one until
29
+ // it looks (out of scope here). The V2 idle controller reads pg_stat_database
30
+ // counters to decide a database is quiet, so a poll that issued a transaction
31
+ // every 2 s kept the shared Postgres awake for every app that had a job.
32
+ //
19
33
  // Environment:
20
34
  // PORT the action server's port (default 8080)
21
35
  // ISOMORPH_GATEWAY_URL the gateway the SDK calls; HARBOUR_GATEWAY_URL
@@ -30,6 +44,10 @@
30
44
  // in-process scheduler. Set by Provision on the
31
45
  // hosted kit-runtime; `isomorph dev` leaves it
32
46
  // unset and keeps cron in-process
47
+ // ISOMORPH_JOB_RUNS=none the app has no runs table (it never calls
48
+ // isomorph.jobs): no queue loop, and --run-job
49
+ // runs without waiting on the data path. Set by
50
+ // Provision; the table exists otherwise
33
51
  // ISOMORPH_APP_ROOT the app root (default: the working directory)
34
52
  import { createServer } from 'node:http';
35
53
  import { readFile, readdir, stat } from 'node:fs/promises';
@@ -174,13 +192,14 @@ export class Runs {
174
192
  }
175
193
  return data;
176
194
  }
177
- /** Rows whose lease ran out go back to the queue while attempts remain, else fail. */
195
+ /** Rows whose lease ran out go back to the queue while attempts remain, else fail; how many there were. */
178
196
  async reclaim(now) {
179
197
  const { data: expired } = await this.table().select('*').eq('status', 'running').lt('lease_until', now.toISOString()).limit(20);
180
198
  for (const run of expired) {
181
199
  const exhausted = run.attempt >= run.max_attempts;
182
200
  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');
183
201
  }
202
+ return expired.length;
184
203
  }
185
204
  /** Claims the oldest queued run by a conditional update; null when the queue is empty or another worker won. */
186
205
  async claim(now) {
@@ -198,10 +217,12 @@ export class Runs {
198
217
  const { data } = await this.table().select('cancel_requested').eq('id', id).maybeSingle();
199
218
  return Boolean(data?.cancel_requested);
200
219
  }
220
+ /** Records the outcome; the status written, `queued` when the failure left a retry for the next pass. */
201
221
  async finish(run, outcome) {
202
222
  const retry = outcome.error !== undefined && !outcome.cancelled && run.attempt < run.max_attempts;
203
223
  const status = outcome.cancelled ? 'cancelled' : outcome.error === undefined ? 'succeeded' : retry ? 'queued' : 'failed';
204
224
  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');
225
+ return status;
205
226
  }
206
227
  }
207
228
  export class Runtime {
@@ -210,6 +231,10 @@ export class Runtime {
210
231
  running = new Set();
211
232
  timers = [];
212
233
  stopped = false;
234
+ // The queue loop is live while a pass runs or its next pass is timed; a
235
+ // wake that arrives mid-pass is remembered so the pass after it looks again.
236
+ queueLive = false;
237
+ queueWake = false;
213
238
  runs;
214
239
  log;
215
240
  server = createServer((request, response) => { void this.serve(request, response); });
@@ -232,10 +257,52 @@ export class Runtime {
232
257
  this.every(TOKEN_REFRESH_MS, () => this.refreshToken());
233
258
  if (this.options.mode === 'full' && this.inventory.jobs.some((job) => job.schedule))
234
259
  this.scheduleTick();
235
- if (this.options.mode !== 'gate' && this.inventory.jobs.length > 0 && this.options.gatewayUrl)
236
- this.every(QUEUE_POLL_MS, () => this.work());
260
+ this.wakeQueue();
237
261
  return port;
238
262
  }
263
+ /** Whether this process works the queue at all: not the gate, a runs table, an app with jobs, a gateway to reach. */
264
+ worksQueue() {
265
+ return this.options.mode !== 'gate' && this.options.jobRuns !== false && this.inventory.jobs.length > 0 && Boolean(this.options.gatewayUrl);
266
+ }
267
+ /**
268
+ * Starts the queue loop unless it is already live. The first pass runs at
269
+ * once; each further pass follows QUEUE_POLL_MS after one that found work
270
+ * (or threw: a gateway that is waking answers 5xx, and the row that woke
271
+ * the loop is still there). A pass that finds nothing ends the loop with no
272
+ * timer armed, so an idle runtime makes no data calls at all.
273
+ */
274
+ wakeQueue() {
275
+ if (this.stopped || !this.worksQueue())
276
+ return;
277
+ if (this.queueLive) {
278
+ this.queueWake = true;
279
+ return;
280
+ }
281
+ this.queueLive = true;
282
+ const pass = async () => {
283
+ if (this.stopped) {
284
+ this.queueLive = false;
285
+ return;
286
+ }
287
+ this.queueWake = false;
288
+ let again = true;
289
+ try {
290
+ again = await this.work();
291
+ }
292
+ catch (error) {
293
+ this.log(`queue pass failed: ${error instanceof Error ? error.message : String(error)}`);
294
+ }
295
+ if (this.stopped) {
296
+ this.queueLive = false;
297
+ return;
298
+ }
299
+ if (again || this.queueWake)
300
+ this.timers.push(setTimeout(() => void pass(), this.options.queuePollMs ?? QUEUE_POLL_MS));
301
+ else
302
+ this.queueLive = false;
303
+ };
304
+ void pass();
305
+ }
239
306
  /**
240
307
  * `--run-job <name>`: one scheduled run of one job, as a CronJob pod runs it.
241
308
  * The run is the scheduler's own (`runScheduled`, the current minute as its
@@ -250,7 +317,7 @@ export class Runtime {
250
317
  this.log(`this app has no ${JOBS_DIR}/${name}.ts`);
251
318
  return false;
252
319
  }
253
- if (this.options.gatewayUrl && !(await this.awaitDataPath()))
320
+ if (this.options.gatewayUrl && this.options.jobRuns !== false && !(await this.awaitDataPath()))
254
321
  return false;
255
322
  return this.runScheduled(name, new Date(Math.floor(now.getTime() / 60_000) * 60_000));
256
323
  }
@@ -348,20 +415,25 @@ export class Runtime {
348
415
  this.running.delete(key);
349
416
  }
350
417
  }
351
- /** One pass of the queue loop: reclaim expired leases, claim one run, run it. */
418
+ /**
419
+ * One pass of the queue loop: reclaim expired leases, claim one run, run it.
420
+ * True when the pass found work — a lease reclaimed, a run claimed, or the
421
+ * run it finished left a retry queued — so the loop should look again; false
422
+ * when the queue is empty (or another pass is already going).
423
+ */
352
424
  async work() {
353
425
  if (this.running.has('queue'))
354
- return;
426
+ return false;
355
427
  this.running.add('queue');
356
428
  try {
357
429
  const now = new Date();
358
- await this.runs.reclaim(now);
430
+ const reclaimed = await this.runs.reclaim(now);
359
431
  const run = await this.runs.claim(now);
360
432
  if (!run)
361
- return;
433
+ return reclaimed > 0;
362
434
  if (!this.inventory.jobs.some((job) => job.name === run.job)) {
363
435
  await this.runs.finish({ ...run, attempt: run.max_attempts }, { error: `no jobs/${run.job}.ts in this app` });
364
- return;
436
+ return true;
365
437
  }
366
438
  try {
367
439
  const handler = await loadHandler(this.options.root, JOBS_DIR, run.job);
@@ -376,6 +448,7 @@ export class Runtime {
376
448
  catch (error) {
377
449
  await this.runs.finish(run, { error: error instanceof Error ? error.message : String(error) });
378
450
  }
451
+ return true;
379
452
  }
380
453
  finally {
381
454
  this.running.delete('queue');
@@ -431,7 +504,9 @@ export class Runtime {
431
504
  throw new IsomorphError('VALIDATION_FAILED', `this app has no ${JOBS_DIR}/${job || '<name>'}.ts`, undefined, undefined, 400);
432
505
  if (!owner)
433
506
  throw new IsomorphError('AUTH_REQUIRED', 'a run is started by a signed-in person', undefined, undefined, 401);
434
- return this.runs.enqueue(job, body.input, owner);
507
+ const queued = await this.runs.enqueue(job, body.input, owner);
508
+ this.wakeQueue();
509
+ return queued;
435
510
  }
436
511
  case GET_RUN_ACTION: {
437
512
  const run = typeof body.id === 'string' ? await this.runs.get(body.id, owner) : null;
@@ -458,6 +533,7 @@ export function optionsFromEnvironment(env = process.env) {
458
533
  gatewayUrl: (env.ISOMORPH_GATEWAY_URL || env.HARBOUR_GATEWAY_URL || '').replace(/\/$/, ''),
459
534
  mode: env.ISOMORPH_RUNTIME_MODE === 'gate' ? 'gate' : env.ISOMORPH_JOBS_SCHEDULER === 'platform' ? 'actions' : 'full',
460
535
  tokenFile: env.HARBOUR_WORKLOAD_TOKEN_FILE || undefined,
536
+ jobRuns: env.ISOMORPH_JOB_RUNS !== 'none',
461
537
  };
462
538
  }
463
539
  /** The job named by `--run-job <name>` or `--run-job=<name>`; undefined when the runtime is a server. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isomorph.ai/app-sdk",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",