Differences
This shows you the differences between two versions of the page.
| Next revision | Previous revision | ||
| iqxwebnext:technical:web_jobs [2026/10/03 16:30] – created Gareth Johnstone | iqxwebnext:technical:web_jobs [2026/10/05 20:02] (current) – Gareth Johnstone | ||
|---|---|---|---|
| Line 2: | Line 2: | ||
| # Web Jobs (JavaScript) | # Web Jobs (JavaScript) | ||
| - | A **Web Job** is a `.js` file that WebHub runs on request and hands back the result | + | A **Web Job** is a short JavaScript program |
| - | This page is about the JavaScript jobs served from `/ | + | Jobs are written and managed from the **Web Jobs** pages in iqxWEB |
| - | ## How a job runs | + | This page is about the JavaScript jobs served from `/ |
| - | 1. A request arrives at `GET` or `POST / | + | ## Writing |
| - | 2. Unless the name starts with `public_`, the caller must have a valid session (bearer token). A missing or bad token is refused before the file is even looked at. | + | |
| - | 3. WebHub reads `jobs/< | + | |
| - | 4. The file's text is wrapped in a function and called with a fixed set of globals (below). It is **not** an ES module: there is no `import`, no top-level `await`, and `require` is WebHub' | + | |
| - | 5. The job does its work and calls `finish ( result )` exactly once. WebHub polls for that call every 100 ms and gives up after the runtime limit (10 s by default) with `JOB-003`. | + | |
| - | 6. The result is streamed back as **NDJSON**: one metadata line, then the data (see *What the caller receives*). The run is recorded in history, metrics and the daily log file. | + | |
| - | The function wrapper matters in practice. Because the body is plain script, | + | A job is the **body |
| ```javascript | ```javascript | ||
| /* | /* | ||
| - | Author: IQX Limited | + | Name: Vacancies by department |
| - | Name: My Job | + | Description: |
| - | Description: | + | Runtime: 10000 |
| - | Version: 1.0.0 | + | |
| - | Last Updated: 2026-10-03 | + | |
| */ | */ | ||
| - | (async function | + | const department = String(params.department || "" |
| - | try { | + | if (!department) return response.json({ error: " |
| - | // ... work ... | + | |
| - | finish({ success: true, data }) | + | const rows = await db.exec( |
| - | } catch (error) { | + | `SELECT TOP 50 VacancyID, Position, Town, WhenCreated |
| - | | + | FROM Vacancy WHERE DepartmentID = ? AND Status = ' |
| - | | + | ORDER BY WhenCreated DESC`, |
| - | } | + | [department] |
| - | })(params, userID, finish) | + | ) |
| + | |||
| + | return | ||
| ``` | ``` | ||
| - | The leading comment block is optional but parsed: `Key: value` lines become | + | The rules that matter: |
| + | |||
| + | - **The return value is the result.** A plain value or object is sent as JSON; a `response.*` value is sent as that response. A job that returns nothing fails with `JOB-004`. | ||
| + | - **An uncaught error fails the run immediately** with `JOB-001` and the message — including a rejected promise nobody caught and a throw inside a timer. You no longer need a `try/catch` just to make failures prompt; catch only where you want to turn an error into a particular answer. | ||
| + | - **`success: false` in a JSON result marks the run as failed.** The caller gets a `JOB-001` error *instead of* your payload, the run is counted as a failure, and a scheduled run is recorded as failed. Use it to mean "this did not work". For "no rows matched" | ||
| + | - **The header comment is the job's contract.** | ||
| + | |||
| + | ### The header, in full | ||
| + | |||
| + | ``` | ||
| + | /* | ||
| + | Name: Staff PDF report | ||
| + | Description: | ||
| + | Author: IQX Limited | ||
| + | Version: 1.2.0 | ||
| + | Last Updated: 2026-10-05 | ||
| + | Runtime: 60000 ms of wall clock (default 10000) | ||
| + | Memory: 512 MB of heap (default 256) | ||
| + | CPU: 20000 ms of CPU time (default 5000) | ||
| + | Access: owner | ||
| + | Mode: page when the job renders a browser page | ||
| + | */ | ||
| + | ``` | ||
| ## What a job can see | ## What a job can see | ||
| + | |||
| + | Only this. There is no `process`, no `fs`, no `fetch`, no `import`, and `require` knows only the approved list. | ||
| | Global | What it is | | | Global | What it is | | ||
| |---|---| | |---|---| | ||
| - | | `params` | The request parameters. `GET` → the query string (**every value is a string**, so `Number ( params.limit )` yourself); `POST` → the parsed JSON body. On a scheduled run it is the schedule' | + | | `params` | The request parameters. `GET` → the query string (**every value is a string**, so `Number ( params.limit )` yourself); `POST` → the parsed JSON or form body. A checkbox group arrives as a string for one tick and an array for several. On a scheduled run it is the schedule' |
| - | | `userID` | The caller' | + | | `request` | `{ method, url, path, query, headers, ip, caller |
| - | | `isAuthenticated` | `true` when `userID` is set. | | + | | `userID`, |
| - | | `getDB ( )` | Returns `{ exec ( sql, params? ) }`. `exec` runs the SQL as the pears (business-table) user with `?` placeholders bound from the array, and resolves to an **array of row objects** for a `SELECT`. | + | | `db.exec ( sql, params? )` | Runs the SQL as the pears (business-table) user with `?` placeholders bound from the array, and resolves to an **array of row objects** for a `SELECT` |
| - | | `finish | + | | `http ( url, options? |
| - | | `rawResponse ( body, headers | + | | `require ( name )` | Loads an **approved** module |
| - | | `require ( name )` | Loads an **approved** module. It is really a dynamic `import ( )`, so it returns a *promise* of the module namespace: `const { format } = await require ( " | + | | `console` | `log`/ |
| - | | `console` | `log`/ | + | | `response` | Builders for non-JSON answers — see *Responses*. | |
| - | | `extendLimits | + | | `sign ( payload, ttlSeconds? )`, `verify ( token )` | A signed, expiring token (HMAC, keyed from the server secret). `verify` returns |
| - | | `setTimeout` / `clearTimeout` / `setInterval` / `clearInterval`, `Date`, `Math`, `JSON`, `parseInt`, `parseFloat`, `isNaN`, `isFinite` | Passed through | + | | `breakpoint |
| + | | `limits` | The resolved | ||
| + | | `Buffer`, `URL`, `URLSearchParams`, `TextEncoder`/`TextDecoder`, `atob`/`btoa`, `structuredClone`, `AbortController`, timers | ||
| - | Approved modules right now: `date-fns` and `@iqx-limited/ | + | ## Who may call a job |
| - | The wrapper | + | WebHub works out **who is calling** before anything runs: a signed-in user (from the bearer token or the iqxWEB session cookie), the hub's system token, |
| - | ## Success, failure and `success: false` | + | | `Access:` | Who may run it | |
| + | |---|---| | ||
| + | | `public` | Anyone, with no credentials. Rate limited per caller (`WEBJOB_PUBLIC_RATE`, | ||
| + | | `user` | Any signed-in user. **The default** for every other job. | | ||
| + | | `owner` | OWNER users only. | | ||
| + | | `key` | Reserved for an API key pinned to this job (keys are planned, not yet issued). Owners can run it; everyone else is refused. | | ||
| - | The HTTP status does not depend on what the job returns — with one exception. If the JSON result contains | + | The system token and the scheduler always pass. A refused call gets `AUTH-SESSION-001` (401, nobody signed in) or `SYS-403` (signed in, but not enough). Inside |
| - | A synchronous throw at the top of the file fails the run at once (`JOB-001`), but an error thrown *inside* the async IIFE with no catch is just an unhandled rejection: nothing calls `finish`, so the job sits there until the 10 s limit and fails with `JOB-003`. Always catch and `finish | + | For an external system that must call a non-public job today, |
| - | | Code | Meaning | | + | ## Limits |
| - | |---|---| | + | |
| - | | `JOB-001` | The job failed: it returned `success: false`, threw, or a query inside it failed. `detail` has the job's text. | | + | |
| - | | `JOB-002` | `require ( )` of a module that is not approved or not installed. | | + | |
| - | | `JOB-003` | Timed out (10 s). Usually a missing `finish` on some path, or an uncaught error. | | + | |
| - | | `JOB-004` | `finish ( )` was called with nothing. | | + | |
| - | Full table on **Web error codes**; every failure carries | + | Jobs are meant to be snappy. The defaults are 10 seconds of wall clock, 256 MB of heap and 5 seconds of CPU time. A job that needs more declares it in its header, which is deliberate: it is an acknowledgement that this job is slow, and it is visible in the jobs list (an *extended* badge). |
| + | |||
| + | Each value is capped by a server maximum (`WEBJOB_MAX_RUNTIME_MS` 300 000, `WEBJOB_MAX_MEMORY_MB` 2048, `WEBJOB_MAX_CPU_MS` 120 000); a higher declaration is clamped and logged. The runtime limit terminates the worker. The memory limit is enforced by V8 on the worker' | ||
| + | |||
| + | Two more guards are per node rather than per job. At most `WEBJOB_MAX_CONCURRENT` (8) workers run at once; further requests queue for up to 15 seconds and are then refused with `SYS-429`. And callers without credentials are rate limited as above. | ||
| + | |||
| + | One deployment caveat: a `--max-old-space-size` in the WebHub process' | ||
| + | |||
| + | ## How a run works | ||
| - | ## What the caller | + | 1. A request arrives at `GET` or `POST / |
| + | 2. WebHub resolves | ||
| + | 3. It reads the job's source from the job store (no such job → `SYS-404`), parses the header, resolves the limits, and starts a **worker thread** with an empty environment, | ||
| + | 4. The job runs; the host kills it on the runtime or CPU limit, V8 on the memory limit. | ||
| + | 5. The run — parameters, result, console output, outcome, caller — is recorded in history, and the answer is sent back (next section). | ||
| - | The response is `application/ | + | The worker is a containment boundary for mistakes, not a wall against a hostile author — someone who can write a job already has full SQL against the database, and `vm` contexts are escapable by design. That is why writing and uploading jobs is limited to **OWNER** users, and why a `public` job should treat every parameter as untrusted input. |
| + | |||
| + | ## Responses | ||
| + | |||
| + | `return` a plain value for JSON. For anything else use `response`: | ||
| + | |||
| + | ```javascript | ||
| + | return response.json({ status: " | ||
| + | return response.html(page) | ||
| + | return response.text(" | ||
| + | return response.csv(text, | ||
| + | return response.pdf(bytes, | ||
| + | return response.file(bytes, | ||
| + | return response.redirect("/ | ||
| + | return response.raw(body, | ||
| + | ``` | ||
| + | |||
| + | How that reaches the caller depends on who is asking: | ||
| + | |||
| + | - **A browser** (a navigation, or `Accept: text/html`) gets an ordinary HTTP response: for a `response.*` result the status, the headers and the body; for a plain JSON result just the JSON, pretty-printed as `application/ | ||
| + | - **An API client and the iqxWEB run dialog** (`Accept: */*`, or `enableLogger=1`) get the **NDJSON** form: one metadata | ||
| ``` | ``` | ||
| - | {" | + | {" |
| {" | {" | ||
| ``` | ``` | ||
| - | For a `rawResponse` whose `Content-Type` is not JSON, the metadata line is followed by the raw bytes (HTML text, a PDF…). With `enableLogger=1` | + | For a raw response |
| ```bash | ```bash | ||
| - | # public job, GET parameters | ||
| curl " | curl " | ||
| - | + | curl -X POST " | |
| - | # authenticated job, POST parameters, streamed console | + | -H " |
| - | curl -X POST " | + | curl -o report.pdf " |
| - | | + | |
| - | | + | |
| ``` | ``` | ||
| - | ## Managing jobs | + | ## Serving pages |
| - | All of these live under `/ | + | A job can be a small web application: |
| - | | Method and path | What it does | | + | ```javascript |
| - | |---|---| | + | if (request.method !== " |
| - | | `GET /available` | Every `.js` in the jobs folder with its parsed metadata, run statistics and modified time. | | + | |
| - | | `GET /< | + | } |
| - | | `POST /save` `{ name, code }` | Write (or overwrite) `jobs/< | + | if (params.step === " |
| - | | `POST / | + | if (params.dob !== record.dateofbirth) return page(T(" |
| - | | `DELETE /< | + | const token = sign({ uid, step: " |
| - | | `POST / | + | return page(T(" |
| - | | `GET /running`, `POST /< | + | } |
| - | | `GET / | + | if (params.step === " |
| - | | `GET /metrics` | Totals, success rate, average/ | + | const claim = verify(params.token) |
| - | | `POST /cron` | Re-read the schedule table (below). | | + | if (!claim |
| + | // … record | ||
| + | | ||
| + | } | ||
| + | ``` | ||
| - | History and metrics are kept in `node-persist` on each node, and every run is also appended to `logs/ | + | To chain into a *different* job, redirect to it with the token in the URL (`response.redirect`) and `verify` on arrival; |
| - | ## Scheduled jobs | + | Templates live in the database, not in files: a `WebJobTemplate ( Name, Html )` table read with `db.exec` and a few lines of `{{placeholder}}` replacement (the deep-link job shows one with escaped and raw placeholders). What you owe a public page, because the engine cannot do it for you: HTML-escape every value that came from the database or the request; check posted IDs against what you offered; keep tokens short-lived; |
| - | Schedules are rows in the database read through `NetV2GetWebJobSchedule`: an ID, a cron expression, | + | Declare a page job with `Mode: page` in its header. The engine treats it like any other job, but the Owner page does the right thing with it: *Open* opens the page in a browser tab instead of the run dialog, the list shows a *page* badge, a *Pages* filter finds them, and *Debug* attaches to the real page (see *Debugging*). A job page is a real page with its own forms and scripts, so it always opens in its own tab — it is not embedded inside iqxWEB. |
| - | A scheduled | + | To see what a page job logged while using it, add `_console=1` |
| - | ## Examples | + | ## The Owner pages |
| - | ### 1. Minimal | + | **Manage** lists every job on one line — name, badges (`public`, `page`, `extended`, and green *scheduled* / grey *schedule off*), description, |
| - | Anyone can call `GET / | + | **History** (per job, or per schedule from the Scheduler page) lists the recent runs with status, duration, |
| - | ```javascript | + | **Scheduler** lists schedules the same way: a switch to enable or disable each one, what it runs and when in plain words, the last run (click it for that schedule' |
| - | /* | + | |
| - | Author: IQX Limited | + | |
| - | Name: Ping | + | |
| - | Description: | + | |
| - | Version: 1.0.0 | + | |
| - | */ | + | |
| - | (async function (params, userID, finish) { | + | ## Debugging |
| - | finish({ | + | |
| - | success: true, | + | |
| - | message: `pong, ${params.who || " | + | |
| - | at: new Date().toISOString(), | + | |
| - | authenticated: | + | |
| - | }) | + | |
| - | })(params, userID, finish) | + | |
| - | ``` | + | |
| - | ### 2. Parameterised query with validation | + | A job can be stepped through line by line from the Owner page: *Debug* in a job's Run menu, or the *Debug* button in the editor (which runs the unsaved code). The dialog shows the source |
| - | `POST / | + | Put a breakpoint where you want to stop: |
| ```javascript | ```javascript | ||
| - | /* | + | const rows = await db.exec("SELECT TOP 5 * FROM Person" |
| - | Author: IQX Limited | + | breakpoint() // or a plain `debugger` statement |
| - | Name: Vacancies by department | + | const names = rows.map(r => r.Name) |
| - | Description: | + | |
| - | Version: 1.0.0 | + | |
| - | */ | + | |
| - | + | ||
| - | (async function (params, userID, finish) { | + | |
| - | const department = String(params.department || "" | + | |
| - | const limit = Number(params.limit) || 50 | + | |
| - | + | ||
| - | if (!department) { | + | |
| - | return finish({ success: false, error: " | + | |
| - | } | + | |
| - | if (!Number.isInteger(limit) || limit < 1 || limit > 500) { | + | |
| - | return finish({ success: false, error: "limit must be 1-500" }) | + | |
| - | } | + | |
| - | + | ||
| - | const db = getDB() | + | |
| - | try { | + | |
| - | | + | |
| - | `SELECT TOP ? VacancyID, Position, Town, Salary, WhenCreated | + | |
| - | FROM Vacancy | + | |
| - | WHERE DepartmentID = ? AND Status = ' | + | |
| - | ORDER BY WhenCreated DESC`, | + | |
| - | [limit, department] | + | |
| - | | + | |
| - | | + | |
| - | } catch (error) { | + | |
| - | console.error(" | + | |
| - | finish({ success: false, error: "Query failed", | + | |
| - | } | + | |
| - | })(params, userID, finish) | + | |
| ``` | ``` | ||
| - | ### 3. Returning | + | Without any breakpoint the run pauses before the first line. Stepping pauses before each statement in the job body and inside `async` functions (including `if`/ |
| - | `rawResponse` turns the result into a download. The same pattern | + | For a **page job**, *Debug* attaches to the real flow: the dialog opens *live*, waiting; **Open page** opens the job in a new tab with the debugger attached, and every request that tab makes — the landing |
| - | ```javascript | + | `breakpoint ( )` and `debugger` are **no-ops in a normal run** — a normal run is never instrumented and `breakpoint` is an empty function in the sandbox — so one left in a job does nothing in production; only an OWNER starting a debug session makes them pause. |
| - | /* | + | |
| - | Author: IQX Limited | + | |
| - | Name: Staff CSV | + | |
| - | Description: | + | |
| - | Version: 1.0.0 | + | |
| - | */ | + | |
| - | (async function (params, userID, finish) { | + | ## Scheduled jobs |
| - | const csvCell = (v) => { | + | |
| - | const s = v === null || v === undefined ? "" | + | |
| - | return / | + | |
| - | } | + | |
| - | try { | + | Schedules are rows of the `IQXNetWebJobSchedule` table: an ID, a cron expression, a display name, the job's name, a JSON string of parameters and an enabled flag. The Scheduler page manages them through the `NetV2…JobSchedule` procedures as the signed-in user; WebHub itself reads and updates the table directly, because those procedures are gated on the caller' |
| - | const rows = await getDB().exec( | + | |
| - | " | + | |
| - | ) | + | |
| - | const header = rows.length ? Object.keys(rows[0]) : [] | + | |
| - | const lines = [header.join(" | + | |
| - | | + | |
| - | finish( | + | Each firing runs the job as the system user with the stored parameters |
| - | rawResponse(lines.join(" | + | |
| - | | + | |
| - | " | + | |
| - | }) | + | |
| - | ) | + | |
| - | } catch (error) { | + | |
| - | finish({ | + | |
| - | } | + | |
| - | })(params, userID, finish) | + | |
| - | ``` | + | |
| - | ### 4. Using an approved module and the caller' | + | On a multi-node site every node loads every schedule today, so a schedule fires once per node. |
| - | `date-fns` is awaited out of `require`. The job also refuses to run for an anonymous caller, which is only possible because its name does *not* start with `public_` — WebHub has already checked the session, so `userID` is always set here; the check is belt and braces. | + | ## Run history |
| - | ```javascript | + | Every run — manual, scheduled, from the editor or under the debugger — is recorded with its parameters, its result (text results up to 64 KB), what it wrote to the console (up to 300 lines / 32 KB, with timings), how it ended, who or what started it, and the node it ran on. The History dialog replays this, and the per-job stats on the Manage page and the metrics cards are computed from it. |
| - | /* | + | |
| - | Author: IQX Limited | + | |
| - | Name: My week | + | |
| - | Description: | + | |
| - | Version: 1.0.0 | + | |
| - | */ | + | |
| - | (async function (params, userID, finish) { | + | History lives in **MongoDB** by default |
| - | if (!userID) { | + | |
| - | return finish({ success: false, error: " | + | |
| - | } | + | |
| - | try { | + | ## Where the jobs live |
| - | const { format, addDays } = await require(" | + | |
| - | const from = format(new Date(), " | + | |
| - | const to = format(addDays(new Date(), 7), " | + | |
| - | const rows = await getDB().exec( | + | `WEBJOB_STORE=file` (the default) keeps jobs as `jobs/< |
| - | | + | |
| - | FROM Placement p | + | |
| - | JOIN Company c ON c.CompanyID = p.CompanyID | + | |
| - | JOIN IQXNetUser u ON u.StaffID = p.StaffID | + | |
| - | WHERE u.IQXNetUserID = ? | + | |
| - | AND p.StartDate BETWEEN ? AND ? | + | |
| - | ORDER BY p.StartDate`, | + | |
| - | [userID, from, to] | + | |
| - | ) | + | |
| - | finish({ success: true, from, to, placements: rows }) | + | `WEBJOB_STORE=db` keeps them in the `IQXNetWebJob` table instead |
| - | } catch (error) { | + | |
| - | console.error(" | + | |
| - | finish({ success: false, error: error.message | + | |
| - | } | + | |
| - | })(params, userID, finish) | + | |
| - | ``` | + | |
| - | ### 5. A job written to be scheduled | + | ## Managing jobs over HTTP |
| - | Scheduled with cron `0 7 * * 1-5` and parameters `{" | + | All under `/ |
| - | ```javascript | + | | Method and path | What it does | |
| - | /* | + | |---|---| |
| - | Author: IQX Limited | + | | `GET /available` | Every job with metadata, resolved limits, |
| - | Name: Expiring compliance reminder | + | | `GET /< |
| - | Description: | + | | `POST /save` `{ name, code }` | Write (or overwrite) a job. | |
| - | Version: 1.0.0 | + | | `POST /upload` (multipart `file`, `name`) | Same, from an uploaded `.job.js`. | |
| - | */ | + | | `DELETE /< |
| + | | `POST /execute` (multipart `file`, `params`, `enableLogger`) | Run an uploaded file once without saving it — the editor' | ||
| + | | `GET /running`, `POST /< | ||
| + | | `GET / | ||
| + | | `GET /metrics` | Totals, success rate, runtimes, failures today, `workersRunning` / `workersQueued` / `workersMax`, | ||
| + | | `POST / | ||
| + | | `POST / | ||
| + | | `POST / | ||
| + | | `GET /store`, `POST / | ||
| + | | `GET / | ||
| + | | `POST /cron` | Re-read the schedule table; reports how many schedules loaded. | | ||
| - | (async function (params, userID, finish) { | + | ## Error codes |
| - | const days = Number(params.days) || 3 | + | |
| - | const db = getDB() | + | |
| - | try { | + | | Code | Meaning | |
| - | const expiring = await db.exec( | + | |---|---| |
| - | `SELECT PersonID, DocumentTypeID, ExpiryDate | + | | `JOB-001` | The job failed: it threw, returned `success: false`, a query inside it failed, it has a syntax error, or it was stopped from the debugger. `detail` has the message. | |
| - | FROM PersonDocument | + | | `JOB-002` | `require |
| - | WHERE ExpiryDate BETWEEN today() AND dateadd(day, ?, today()) | + | | `JOB-003` | A limit was hit: runtime, memory or CPU. The message says which. | |
| - | AND ReminderSent = 0`, | + | | `JOB-004` | The job finished without a result (nothing was returned). | |
| - | | + | | `AUTH-SESSION-001`, |
| - | ) | + | | `SYS-429` | Too many: the rate limit for callers without credentials, |
| - | let flagged = 0 | + | Full table on **Web error codes**; every failure carries a `ref` to search |
| - | for (const doc of expiring) { | + | |
| - | await db.exec( | + | |
| - | `UPDATE PersonDocument SET ReminderSent = 1 | + | |
| - | WHERE PersonID = ? AND DocumentTypeID = ?`, | + | |
| - | [doc.PersonID, | + | |
| - | ) | + | |
| - | flagged++ | + | |
| - | } | + | |
| - | + | ||
| - | console.log(`compliance reminder: ${flagged} document(s) flagged (${days} days)`) | + | |
| - | finish({ success: true, flagged, days }) | + | |
| - | } catch (error) { | + | |
| - | console.error(" | + | |
| - | finish({ success: false, error: error.message }) | + | |
| - | } | + | |
| - | })(params, userID, finish) | + | |
| - | ``` | + | |
| - | + | ||
| - | (Table and column names in examples 2, 4 and 5 are illustrative — check them against | + | |
| - | ## Things that bite | + | ## Trying it |
| - | - **Top-level `await` does not work.** | + | The repo ships a small test pack; run each and compare with its header. `_test-limits` returns |
| - | - **`require` is async.** | + | |
| - | - **Call | + | |
| - | - **`success: false` | + | |
| - | - **`GET` parameters are strings.** | + | |
| - | - **The 10 s limit is real** and `extendLimits` does not currently raise it. Keep jobs short; for long work, write the outcome somewhere and poll. | + | |
| - | - **Two nodes, two folders.** Save or upload | + | |
| - | - **Hot reload is free.** The file is read on every request, so a saved change | + | |
| - | - **No `process`, `fs` or arbitrary `require`** are offered, and nothing in the wrapper stops a determined author reaching them. Jobs are trusted code; keep authoring to OWNER. | + | |
| ## For developers | ## For developers | ||
| - | - Engine: | + | - `src/ |
| - | - The scheduler is started from `server.ts` (`updateSchedulesFromDb`) at boot. | + | - `src/ |
| - | - Error classification is `toJobError`; a new failure kind needs a `JOB-00n` entry in `@iqx-limited/ | + | - `src/ |
| - | - Tests: `src/ | + | - `src/routes/webJobDebug.ts` — the debugger' |
| + | - `src/routes/webJobAuth.ts` — `JobCaller` | ||
| + | - `src/ | ||
| + | - `src/ | ||
| + | - `src/ | ||
| + | - `src/ | ||
| + | - Settings are `WEBJOB_*` in `.env.sample`. A new failure kind needs a `JOB-00n` entry in `@iqx-limited/ | ||
| + | - Not done yet, by design: API keys (`Access: key`); scheduler on the cluster leader only; DNS-rebinding pinning in `http ( )`. | ||
| </ | </ | ||