NorthlightSDK
Guides

Background runs

Learn when a run your app started and did not wait on has ended, with RunWatcher or run notices.

A run your app starts and does not wait on (a draft, a scheduled job) ends while nobody is holding a request. Two tools tell your app. Use RunWatcher while your server is running. Add run notices when the person's runtime sleeps and your app must wake on output.

Follow runs with RunWatcher

oauth.watchRuns(...) returns a RunWatcher that follows those runs and hands each ending to onEnded, so you write no poll loop:

const watcher = oauth.watchRuns({
  pending: () => db.drafts.findMany({where: {status: 'drafting'}, select: {userId: true, runId: true}}),
  onEnded: async ({userId, runId, ended, run}) => {
    // Idempotent: only a row still waiting on this run moves.
    if (ended === 'gone') await db.drafts.updateMany({where: {runId, status: 'drafting'}, data: {status: 'failed'}});
    else await db.drafts.updateMany({where: {runId, status: 'drafting'}, data: {status: 'ready', result: run.result}});
  },
  onError: (error, run) => log.warn({error, run}, 'run watcher'),
});
await watcher.start();
// After starting a run the app will watch:
watcher.nudge();
// On shutdown:
await watcher.stop();
  • pending() returns {userId, runId} for every run the app still waits on, read from your own rows. The rows are the durable record: a restarted watcher re-derives everything from them. It is called at start(), every sweepMs (default 30 000), after each delivery and on nudge(). Stop returning a run once onEnded has landed it.
  • Each run is followed with runs.get(runId, {wait}) on oauth.client(userId) until its status is settled, idle or waiting_on_user. Then onEnded({userId, runId, ended, run}) receives that status as ended and the RunObject. At most concurrency (default 50) long-polls are open at once.
  • Delivery is at least once per (runId, status, latest_sequence). The key is held in memory, so a restart may deliver an ending again: guard the write on the row still waiting on that run.
  • gone (run: null) is delivered once when the runtime answers 404 not_found with origin: 'runtime' (the run was deleted). A 404 with origin: 'router' is a transient failure, not gone.
  • If onEnded throws, the same ending is retried after 5 s, doubling to 5 min, and the error goes to onError. A missing connection (not_connected) or a refused one (which onRefusal already handled) drops that run's loop and reports to onError; the next sweep decides again from pending().
  • stop() aborts every open long-poll and resolves once every loop has ended.

Live progress

To show what a run is doing without a second runs.get per page, pass onActivity: async ({userId, runId, activity, run}) => …. It is called with each read of a still-running run whose activity differs from the last one handed over, and never for a run at rest. While it is set, running runs are read with wait: activityWait (integer 1–25 s, default 2) instead of 25 s. The hook is best effort and never awaited: a throw goes to onError and is not retried, and it never delays or changes onEnded. A call can finish after that run's onEnded, so guard the write on the row still waiting. The phases are listed in Typed results.

Run notices

A hosted runtime that sleeps cannot be polled, so it can tell your app when a run has output nobody is reading. If your app has a run notice URL, the runtime sends POST <run notice URL> with Authorization: Bearer <directory token> and body {run_id, latest_sequence}. It sends one when events are appended to one of your runs and no event stream is open, or a stream ended with events unread. A notice carries no content and never keeps the runtime awake. If it cannot be delivered after three quick attempts the runtime drops it; a lost notice delays output until your app next reads from its cursor, and never loses output.

Check each notice with verifyRunNotice:

import {RunNoticeError, verifyRunNotice} from '@north-light/sdk';

app.post('/run-notices', async (req, res) => {
  try {
    const notice = await verifyRunNotice(toFetchRequest(req), {
      runNoticeUrl: 'https://app.example.com/run-notices', // exactly as registered
      issuer: 'https://account.northlight.ai',
      grantee: 'app:<your client id>',
    });
    await storeAndReadFrom(notice.userId, notice.runId, notice.latestSequence); // then read events from your cursor
    res.sendStatus(204);
  } catch (error) {
    if (error instanceof RunNoticeError) return res.sendStatus(401); // store nothing
    throw error;
  }
});

verifyRunNotice checks the token's signature, issuer, audience (your run notice URL), expiry, scope crtr:run_notice and grantee, and returns {userId, runtime, runId, latestSequence}. runtime comes from the token, never the body. Answer 2xx once the notice is stored. The same or a lower latest_sequence is a no-op. In tests, pass fetch: cloud.fetch from FakeCloud (Test your app).

Set the run notice URL

The console has no field for this yet. Until it does, set it with crtr on a machine joined to your Northlight account (crtr sys join; see joining a machine in the crouter docs):

crtr cloud apps set-run-notice-url --app-id <client id> --url https://app.example.com/run-notices
crtr cloud apps set-run-notice-url --app-id <client id> --clear   # stop notices

The URL must be https (plain http only on localhost in development) and at most 512 characters. crtr cloud apps get --app-id <client id> shows it as run_notice_url.

On this page