You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit f1e4ae5
Browse filesBrowse the repository at this point in the historyBrowse files
spec(system): JobSchema gains a sandboxed L2 body and deprecates handler; a body job has one time limit (#21538)
Part of #21515
Clause-②: yes (widening)
This lands the **spec half** of #21515, the E ruling's spec scope
(ruling record `5964305303` on #21489, maintainer 「jobs同意」): a job
carries a sandboxed `body`, `handler` is deprecated beside it, and a
body job's time limit has one spelling. Scope item 2, `objectstack
build` lowering job handlers into `body`, is **not** in this PR.
Measured below, it cannot work on the standard authoring path, and each
route that would make it work changes a public contract, so it goes back
to the seat as a decision ("Not in this PR"). #21515 stays open for that
half, which is why the first line is `Part of`.
## What changes
- **`JobSchema.body`** (`packages/spec/src/system/job.zod.ts`) is
`ScriptBodySchema` by reference: the L2 member of the hook body union,
with no copy and no new body shape. It is strict as on hooks. Its
describe says the body runs in the QuickJS sandbox with no module scope,
reaches data only through `ctx.api` under its declared `capabilities`,
and logs through `ctx.log`. It also says the in-process
`JobHandlerContext` members (`ql`, `logger`, `bundle`) do not exist
there, and that the runtime binder has not landed yet.
- **`handler` is optional, with the hooks' wording: "DEPRECATED, prefer
`body`".** When both are present `body` wins. A job with neither key is
refused at `body`, with a message naming both keys. The rule is
`requiredOneOf(['body', 'handler'])` from the closed projection list, so
the published JSON Schema states it as `anyOf` of `required`, and
`dropped-refinements.baseline.json` does not grow. The `fn` / `function`
→ `handler` aliases stay as they are: both still point at a key that
exists and accepts a function name.
- **L1 is refused, and the message says why.** The error is set on the
`language` literal of `ScriptBodySchema` (`data/hook-body.zod.ts`): an
expression performs no I/O, so its only effect would be a returned
value, and a job runs for its effects. It is reachable only where
`ScriptBodySchema` stands alone. `HookBodySchema` routes `expression` by
its discriminator, so hook and action bodies are byte-for-byte
unchanged. Every other wrong language keeps zod's own message.
- **One time limit.** `body.timeoutMs` is refused on a job
(`bannedKeys(['timeoutMs'])` on the slot, also projected), with the
prescription to move the value to the job's `timeoutMs`. The
`JobSchema.timeoutMs` describe is **the one statement** of the relation.
The `body` describe and the refusal point to it.
- **Liveness:** `job.body` is drilled into its five children, all
`planned` and carried by #21489. `authorWarn` sits on `source`, so `os
validate` / `os lint` / `os build` warn whenever a job sets a body.
Measured with `lintLivenessProperties` on a two-job stack: one
`liveness-planned-property` finding, on the job with a body.
- **Docs:** `content/docs/automation/jobs.mdx` gains "The job body" and
"Time limit and long-running work". The new snippet is `os:check`
type-checked. The page's stale `timeout` spellings become `timeoutMs`.
The generated reference, the authorable-surface shard and the liveness
counts are regenerated.
- **Changeset:** `@objectstack/spec` `minor`. No CLI file changes, so
there is no CLI bump.
## Measurements behind the design
**A1, body levels.** The PM's premise is half right. A job does have a
return consumer: a handler may resolve `{ outcome: 'degraded', reason }`
(`JobRunOutcome`, `contracts/job-service.ts`), and all three adapters
map it. It also has an input: `trigger(name, data)` hands `data` to the
handler. But that one reader judges *work*, and an L1 expression can do
none (capabilities `[]`, no I/O). So an L1 job could only report an
outcome about nothing. Result: L2 only, by reference, and the L1 refusal
says why.
**A3, the time limit, as the code enforces it.**
- `QuickJSScriptRunner.resolveTimeout`
(`runtime/src/sandbox/quickjs-runner.ts`): the CPU budget is the smaller
of the runner's `opts.timeoutMs` and the body's `timeoutMs` when either
is set. Otherwise it is the origin default: hook 250 ms, action 5000 ms,
or the `OS_SANDBOX_*` env. The wall ceiling is
`max(OS_SANDBOX_WALL_CEILING_MS, 30000 by default, budget)`, and
over-budget runs are interrupted (killed).
- `runWithPolicy` (`service-job/src/run-with-policy.ts`):
`job.timeoutMs` is a per-attempt wall-clock race. It abandons rather
than kills, and it is uncapped.
- So the 30 s cap sits only on `body.timeoutMs`. With that key refused
on a job, the binder hands `job.timeoutMs` to the runner as
`opts.timeoutMs`, and budget and wall ceiling both rise to it.
Long-running work stays expressible, so the cap needs no product
decision. The binder has to pass the value through, and that requirement
is recorded for #21489 below.
- Omitted `timeoutMs` on a body job means the sandbox's default limits,
and the describe says so.
**A4, the build lowering: why it is not here.** It was implemented and
measured, and the code stays in this branch's history at `4893a0b48b` /
`781609d2f7`, reverted in `b7cf7f78b8`. Three readings:
1. There is no inline job handler to lower: `JobSchema.handler` is
`z.string()`, a `functions` key. The only shape the build sees is the
named function.
2. On the standard path the build cannot read that function.
`defineStack` (strict by default, the form every example and template
uses) parses `functions` through `z.function()`. That replaces each
callable, bare or declared (`{ handler, effect }`), with zod's
`implement` wrapper, which keeps no back-reference. The pipeline pin
`defineStack → normalizeStackInput → lowerCallables →
ObjectStackDefinitionSchema` went **red**: the parse succeeded and the
job came out with no `body`. `extractHookBody` had refused with free
identifiers `func, inst, parse`, which are zod's names, not the
author's. Shipped as is, every job on every standard build would print
that bogus warning.
3. Where the source *is* visible, the documented handler form (`async
function sweep({ jobId, ql, logger }: JobHandlerContext)`) extracts
cleanly into a body that reads unbound `ql` / `logger` / `jobId`. It
passes the free-identifier gate because they were parameters, and it
would throw on its first sandbox run. Because `body` wins, the build
would replace a working handler with a broken body. A guard
(`assertJobBodyContext`, in the history) refused those, which left
nothing existing to lower.
So "authored packages become portable without hand-editing" does not
hold on any route. The options are in the report on #21515.
**A5, consumers of `JobSchema` / `Job` / `JobParsed`.** No typed
consumer reads `job.handler` as required: `AppPlugin` reads jobs as
`any`, objectui's `JobPreview` reads the draft untyped, and
`metadata-type-schemas.ts` / `stack.zod.ts` only reference the schema.
There was no type fallout. Left for #21489:
- `runtime/src/app-plugin.ts#start` resolves `fnMap[job.handler]` only.
A body-only job is skipped with `job handler not found in
bundle.functions — skipping` at `warn`, and with both keys present
`body` is ignored rather than winning.
- The sandbox has no `job` origin (`ScriptOrigin.kind` is `hook |
action`), so `resolveTimeout` has no job default.
- `job.timeoutMs` must reach the runner as `opts.timeoutMs`.
- What a job body's `ctx` carries beyond `api` / `log` / `crypto`
(`jobId`, `data`) is undeclared.
**A6.** No `skills/**` file names the job `handler` form. One
pre-existing stale line is noted below.
**A8.** The open PRs were re-read at report time: 9 open, and none
touches `system/job.zod.ts`, `data/hook-body.zod.ts`,
`lower-callables.ts`, `extract-hook-body.ts`, the job docs or the job
ledger.
## Tests and gates (at `b0460f4ddc`, the merge of `origin/main`
`ad7c351898`)
- `@objectstack/spec` build: exit 0, after the merge. `check:generated`:
all 15 artifacts up to date. `pnpm --filter @objectstack/spec test`: 603
files, 17848 passed, 1 todo. `typecheck`: exit 0.
- New pins in `packages/spec/src/system/job.test.ts` (`JobSchema.body`,
10 cases): body only, handler only, both, neither (located at `body`,
naming both keys), L1 refused at `body.language` with a reason, any
other language keeps zod's message, `body.timeoutMs` refused, job
`timeoutMs` 600000 accepted, a misspelt body key refused, and `Job`
typing `handler` as optional.
- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--ran`: **111 derived, 111 run, 0 NOT-MEASURED, 0 UNRUN**, every exit
code 0. Six gates first exited 3 (formula, lint, client, client-react
and objectql unbuilt) and are green after building that closure.
- **Ablations** via `scripts/ablation-replace.mjs`. Each proved the
anchor 1 → 0 on disk and restored to `git diff HEAD` empty, with blob ==
HEAD:
1. `handler` required again (`job.zod.ts` blob `105cfb6379` →
`49869aece8`): 4 red. The red pins are the body-only pin, neither (the
refusal moves to `handler`), job `timeoutMs` with no handler, and the
`Job`-typed call.
2. `bannedKeys(['timeoutMs'])` replaced by an always-true refine: 1 red,
the `body.timeoutMs` pin.
3. The L1 message disarmed (`hook-body.zod.ts` blob `c5123b08bb` →
`279b5324d0`): 1 red, the L1 pin. The hook-body suite stayed green. The
first attempt used a replacement already present in the file, was
refused by the tool before running anything, and was redone.
- Restore leg: `job.test.ts` + `hook-body.test.ts`, 84 passed.
- The second ablation the dispatch named (removing the job branch of the
lowering) has no target here, because the lowering is not in this PR.
## Not in this PR: the build lowering (decision requested on #21515)
The options are written up with the four axes in the seat report on
#21515:
- (A) Stop `functions` entries being wrapped
(`FlowFunctionEntrySchema`'s `z.function()` replaced by an
identity-preserving check), then mint a job body from the named function
behind the job-context guard.
- (B) Give jobs an inline `handler` function form, as hooks have, that
the build lowers. That needs the runtime module to bundle it and
`AppPlugin` to bind an inline function on a config boot.
- (C) Withdraw the lowering, and author job bodies as data.
## Acceptance notes
- `objectstack build` does not lower jobs, and the docs say so: authors
write `body` as data.
- Inference, no producer found in `examples/**` (carrier: none):
`extractHookBody` peels the parameter list, so a hook handler written as
`async ({ input }) => …` or `async (context) => …` lowers to a body that
reads an unbound name. The sandbox runs every body as `(async (ctx) => {
… })`. Only the extractor was probed, not a spawned `os build`.
- The generated reference renders `Job.body`'s nested table from the
reused shape, `timeoutMs` row included, although that key is refused on
a job. The describes and the published JSON Schema (`propertyNames`) say
it is refused.
- Error text for an unknown key on a job body names "this sandboxed JS
(L2) hook body", the shared shape's surface label.
- The `metadata-plugin.zod.ts` `job` registry row still justifies
`allowRuntimeCreate: false` by `handler` alone. A `body` makes a
runtime-authored job runnable in principle once #21489 binds it. That is
noted for that seat, not changed here.
- objectui's `JobPreview` shows `handler` and nothing of `body`. This is
a cross-repo display gap, not filed.
- `skills/objectstack-platform/SKILL.md:414` still names `onEnable` as
where a job gets its data handle. That was already stale (jobs take `ql`
off their context), and this PR does not touch `skills/**`.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
A job can carry a sandboxed `body`, the same JavaScript body hooks and script actions carry, so its work travels with the metadata; `handler` is deprecated beside it (#21515).
6
+
7
+
Clause-②: yes (widening)
8
+
9
+
-**`JobSchema.body`** is `ScriptBodySchema` by reference: `{ language: 'js', source, capabilities, memoryMb }`, strict as on hooks. It runs in the QuickJS sandbox with no module scope, reaches data only through `ctx.api` under its declared `capabilities`, and logs through `ctx.log`. The in-process `JobHandlerContext` members (`ql`, `logger`, `bundle`) do not exist there.
10
+
-**`handler` is optional and DEPRECATED, "prefer `body`".** When both are present `body` wins, as for hooks. A job that declares neither is refused at parse, located at `body`, with a message naming both keys. The rule is published in the JSON Schema too (`anyOf` of one `required` per key), not only enforced by the parse.
11
+
-**Only the L2 body.** An expression (L1) body is refused on a job at `body.language`, and the message says why: an expression performs no I/O, so its only effect would be a returned value, and a job runs for its effects. The message lives on `ScriptBodySchema.language` and fires only where that shape is used on its own; hook and action bodies are unchanged.
12
+
-**One time limit.** A body job's limit is the job's own `timeoutMs`: one attempt is one sandbox run, bounded by that value. `body.timeoutMs` (capped at 30 s on hooks and actions) is refused on a job, with the prescription to move the value to `timeoutMs`. The job-level key has no cap, so long-running work states its limit there or splits into bounded runs. The `timeoutMs` describe is the one place this is stated.
13
+
-**Not yet run by the runtime.** Scheduling a job's `body` is a separate change. Until it lands a job runs through `handler`, and a body-only job is skipped at boot with a warning. The liveness ledger grades `job.body``planned`, so `os validate`, `os lint` and `os build` warn wherever a job sets a `body`. `objectstack build` does not mint a job body from the function a `handler` names; write it as data.
14
+
15
+
Nothing that parsed before is refused now: every existing job declares `handler`, and none declares `body`.
Copy file name to clipboardExpand all lines: content/docs/automation/jobs.mdx
+91-8Lines changed: 91 additions & 8 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -4,9 +4,11 @@ navTitle: Scheduled Jobs
4
4
description: Run a TypeScript function on a cron, interval, or one-off schedule — and decide when a job is the right tool instead of a schedule-triggered flow.
5
5
---
6
6
7
-
A **job** runs one named function in your bundle on a schedule. You declare the
8
-
schedule as metadata; the platform's job service owns the timing, the retries,
9
-
the per-attempt time limit, and the run history.
7
+
A **job** runs work on a schedule. You declare the schedule as metadata; the
8
+
platform's job service owns the timing, the retries, the per-attempt time limit,
9
+
and the run history. The work is either a sandboxed [`body`](#the-job-body)
10
+
that travels with the metadata — the preferred form — or, deprecated, the name of
11
+
a function in your bundle (`handler`).
10
12
11
13
{/* os:check */}
12
14
```typescript
@@ -37,10 +39,10 @@ writes carry*:
37
39
38
40
||`job`|`schedule`-type flow |
39
41
|:---|:---|:---|
40
-
| What runs | one TypeScript function from `defineStack({ functions })`| a node graph — record operations, `notify`, `http`, approvals, subflows |
42
+
| What runs |a sandboxed `body`, or (deprecated) one TypeScript function from `defineStack({ functions })`| a node graph — record operations, `notify`, `http`, approvals, subflows |
41
43
| Changeable after deploy |**No.**`job` is `allowRuntimeCreate: false` and `allowOrgOverride: false` — there is no "create job" in Studio and no per-tenant fork | Yes — a new flow can be authored through Studio / `PUT /meta` (`allowRuntimeCreate: true`) |
42
44
| Identity of its data writes | whatever the handler does with the engine it is given | declared by [`runAs`](/docs/automation/flows) — and a `user` run that resolves no trigger user has its data operations **refused**, so a scheduled flow normally declares `runAs: 'system'`|
43
-
| Retry / time limit |`retryPolicy` + `timeout` on the job, honoured by the job adapter | the flow's own error handling |
45
+
| Retry / time limit |`retryPolicy` + `timeoutMs` on the job, honoured by the job adapter | the flow's own error handling |
44
46
| Run history |`sys_job` + `sys_job_run`|`sys_automation_run`|
45
47
46
48
**Rule of thumb:** if the work is a function you ship and version with your code,
@@ -133,6 +135,85 @@ interval adapter, a cron schedule is **registered but never executed** — the
133
135
adapter says so at `warn` level on registration, because that is the difference
134
136
between "no cron engine here" and a job that silently never runs.
135
137
138
+
## The job body
139
+
140
+
A job's `body` is the same sandboxed JavaScript body hooks and script actions
141
+
carry — `{ language: 'js', source, capabilities }` — so the work travels with the
142
+
metadata instead of living in a runtime module only some boots import. When a job
143
+
declares both, `body` wins; `handler` is deprecated beside it. A job must declare
|**handler**|`string`| ✅ | Handler function name (must match a key in `defineStack({ functions })`) |
60
+
|**handler**|`string`| optional | Handler function name (must match a key in `defineStack({ functions })`) — DEPRECATED, prefer `body`. When both are present `body` wins; a job must declare one of the two. |
61
+
| **body** | `{ language: 'js'; source: string; capabilities?: Enum<'api.read' \| 'api.write' \| 'api.transaction' \| 'crypto.uuid' \| 'log'>[]; timeoutMs?: integer; … }` | optional | Job body — a sandboxed JS (L2) body, the same shape hooks and actions use; an expression (L1) body is refused, because a job runs for its effects and an expression has none. Preferred over `handler`: when both are present `body` wins. It runs in the QuickJS sandbox with no module scope (no imports, no helpers or constants from the surrounding file): it reaches data only through `ctx.api` under its declared `capabilities` (`api.read` / `api.write` / `api.transaction`) and logs through `ctx.log` (`log`); the in-process handler context (`ql`, `logger`, `bundle`) does not exist there. Its time limit is the job's `timeoutMs` (see there): long-running work declares a `timeoutMs` that covers it, or splits into bounded runs that each finish within it. The runtime binder that schedules job bodies has not landed yet: until it does a job runs through `handler`, so keep `handler` beside `body`. |
61
62
|**retryPolicy**|`{ maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; maxRetryDelayMs?: integer; … }`| optional | Retry policy: failed runs (including timeouts) are retried with exponential backoff (delay = min(backoffMs * backoffMultiplier^(retry-1), maxRetryDelayMs), optionally jittered) up to maxRetries retries after the initial attempt. Omit the block for a single attempt; declaring it without `maxRetries` also means no retry since 17.0.0 — state a count to opt in. |
62
-
|**timeoutMs**|`integer`| optional | Per-attempt time limit in milliseconds; an over-limit run is recorded with execution status "timeout". The in-flight handleris abandoned, not forcibly cancelled. Omit for no time limit. |
63
+
|**timeoutMs**|`integer`| optional | Per-attempt time limit in milliseconds; an over-limit run is recorded with execution status "timeout". A `handler` run is abandoned, not forcibly cancelled. For a job with a `body` this is the ONE time limit: one attempt is one sandbox invocation, the runtime bounds that invocation by this value, and the body shape's own `timeoutMs` (capped at 30000 for hooks and actions) is refused on a job — so this key, which has no such cap, is where long-running work states how long it needs. Omit for no per-attempt limit; a `body` run is then still bounded by the sandbox's own default invocation limits. |
63
64
|**timeout**|`never`| optional |[REMOVED]`job.timeout` was removed in @objectstack/spec 17 — its unit (milliseconds) lived only in the description while the sibling `retryPolicy.backoffMs` spells its own, so the same number read as two conventions on one surface. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
64
65
|**enabled**|`boolean`| optional (default: `true`) | Whether the job is enabled |
0 commit comments