Skip to content

Commit 6c5697d

Browse files
fix(runtime,cloud-connection)!: a job's sandboxed body is scheduled on every door, and install-local refuses an enabled job with no body (#21489) (#21584)
Fixes #21489 Clause-②: yes (narrowing) This executes ruling E + C (record `5964305303`, maintainer 「jobs同意」), as the card's runtime half and C, with the scope the erratum `5968972501` restates: a job `body` is authored as data and `os build` never mints one (#21540 ruled C, record `5968961157`). Nothing here adds a build or lowering route. - **E, runtime half.** A job's sandboxed `body` (`JobSchema.body`, the hook body shape) is scheduled on every door that brings an artifact in: the boot (a config, or `os start --artifact`), and install-local on install and on every rehydrate. With both `body` and `handler` declared, the `body` wins. - **C.** install-local refuses a package whose enabled job has no `body`. The answer is `422` with `VALIDATION_ERROR`, it names each job and its handler, and it gives the remedy: give the job a `body`, or boot it with `os start --artifact`. Nothing is registered, persisted or scheduled. - **CLI.** `os package install` prints a refusal's code beside its status, for every refusal alike. - A job still on `handler` keeps working on a config or `--artifact` boot (the control pin). - **Patch round 1 (REWORK `5969239835`).** A package's jobs stop with it. Uninstalling a package cancels its scheduled jobs, on install-local's `DELETE` and on the protocol's package uninstall alike, and a reinstall cancels the jobs its new version drops. - **Amendment `5969471197` (seat-directed).** The texts this landing makes false ride this PR: `JobSchema.body`'s describe, `defineJob`'s TSDoc example, the regenerated `content/docs/references/system/job.mdx`, and the callout and example comment in `content/docs/automation/jobs.mdx`. ## What changes **The one binder's job half** (`packages/runtime/src/app-artifact-handlers.ts`): - `scheduleAppArtifactJobs(ctx, bundle, { appId, ql, source })` is the one place a declared job becomes a scheduled one. It holds the loop that used to sit inline in `AppPlugin.start`: the deployment switch (#17396), the job-service probe, the `enabled` skip, `toBoundaryJobSchedule`, the `retryPolicy` / `timeoutMs` threading, and the failure posture (error level plus `jobScheduleFailuresTotal`). - Per job, a `body` is bound through `jobBodyRunnerFactory`. Otherwise the `handler` resolves against `functions`, with the in-process `JobHandlerContext` as before. A body that cannot be bound (an L1 expression, or a `body.timeoutMs`) schedules nothing, and never the handler beside it. - Callers: `AppPlugin` on `kernel:ready`, and install-local's `bindArtifactHandlers`, which runs on the install route and on the rehydrate. - It is a second entry point of the same module rather than a block inside `bindAppArtifactHandlers` for one reason, timing. The boot binds hooks and actions in `start()` but schedules jobs once the kernel is ready, while install-local's doors are already past that point. One implementation, two moments, no per-door copy. - `collectJobsWithoutBody(bundle)` names the enabled jobs with no `body`. It is the judgement C refuses on, read from the jobs the binder schedules. **A package's jobs stop with it** (`app-artifact-handlers.ts`, patch round 1): - The job half keeps a record of which job names each app scheduled, per job service instance (one per kernel). The last app to schedule a name owns it, so cancelling one app's jobs never stops a job another app scheduled under that name. - **Re-scheduling replaces.** Every job an app scheduled before and does not schedule now is cancelled through `IJobService.cancel`, the verb every adapter implements: the cron adapter stops its timer, and the DB adapter also marks the `sys_job` row inactive. That covers a job the new version drops, disables or can no longer run. A version with no jobs cancels them all. A cancel that throws is logged at `error` and the name stays on the record for the next attempt. - **Uninstall.** The first time a package's jobs are scheduled on a kernel, the job half registers ONE uninstall cleanup, `runtime.package-jobs`, through the protocol's existing `registerUninstallCleanup` (#21490). It cancels the package's recorded jobs. The protocol's `deletePackage` and install-local's `DELETE` both run every registered cleanup with the package id, so both stop the jobs with no per-door copy, and the outcome rides the response's `cleanups`. A job it could not cancel is an outcome (`success: false`, naming the job), never a throw. No `metadata-protocol` file is edited. - The DELETE path keeps PR #21581's behaviour: the registry withdrawal runs first, then the cleanups. This PR does not edit that path. **The sandbox job origin** (`sandbox/script-runner.ts`, `sandbox/quickjs-runner.ts`, `sandbox/body-runner.ts`): - `ScriptOrigin.kind` gains `'job'`. - `QuickJSScriptRunner` gains `jobTimeoutMs`, default 5000 ms of CPU, like an action body. `resolveTimeout` now picks a default per kind instead of hook-or-else. There is no env override: a job's own `timeoutMs` (uncapped) is the declared place to raise it. - A job body runs in the `(ctx)` wrapper hooks use; a job has no input. - `jobBodyRunnerFactory` passes the job's `timeoutMs` as `opts.timeoutMs`, the one limit `JobSchema.timeoutMs` states. - `jobBodyRunnerFactory` reads the body's return as a `JobRunOutcome`, in the declared shape only. - `jobBodyRunnerFactory` serves `ctx.api` through `buildSandboxApi`, like every body's, under `{ isSystem: true }`. A job has no caller: an action body with no caller gets the same envelope, and a `handler` job's raw `ql` amounts to it. The stored-metadata write refusal still applies. **install-local** (`packages/cloud-connection/src/marketplace-install-local-plugin.ts`): the refusal sits as step 1c, beside the id gate. That is ahead of the conflict check, the posture gate, the hot-register and the ledger write. Rehydrate is not gated, for the id gate's reason. An entry an older build installed still rehydrates; its handler-only job is reported at `warn` and not run. **CLI** (`packages/cli/src/commands/package/install.ts`): the generic refusal branch prints `Install failed (STATUS CODE): MESSAGE`. It was `Install failed (STATUS): MESSAGE`, which dropped the code. **Spec ledger** (`packages/spec/liveness/job.json`, `state-counts/job.md` regenerated): see the deviations below. The `job.body` children `language`, `source`, `capabilities` and `memoryMb` flip `planned` to `live`. `authorWarn` / `authorHint` are dropped, as the row's own carrier note prescribed for this card's commit. `body.timeoutMs` stays `planned` (refused). The five rows that cited `app-plugin.ts#start` for the moved loop are repointed to `scheduleAppArtifactJobs`. ## Measurements **A4, reach at the public door, before the fix** (base `bd70706713`). The composed pin `packages/cli/test/package-install-local-jobs.integration.test.ts` ran unchanged against the base build: 4 red, 2 green. - The body-job package installed with exit 0, and its job wrote 0 rows hot and 0 after a restart. - The handler-only package installed: exit 0, `Package installed into the running kernel`. It was never scheduled. - Control, `os start --artifact` of one artifact carrying both forms plus its runtime module: the handler job ran (rows written) and the body job wrote 0 rows. **After the fix:** 6 of 6 green, at `f99d6dcd39` and again at head `c866c5ac9d`. **A1, the pointer's facts, re-measured at `bd70706713`:** 1. `AppPlugin#start` resolved `fnMap[job.handler]` only (`app-plugin.ts:1178`). A body-only job was skipped at warn, and with both keys present `body` was ignored. Now the body binds, and wins (pins below). 2. `ScriptOrigin.kind` was `hook | action` (`script-runner.ts:118`; `body-runner.ts:120` and `:228`), and `resolveTimeout` defaulted everything non-hook to the action budget. Now `'job'` has its own default. 3. `job.timeoutMs` reaches the runner as `opts.timeoutMs`. Pinned: a spinning body with `timeoutMs: 40` rejects with `job 'spin_job' exceeded CPU budget of 40ms`, and the adapter receives `{ timeoutMs: 40 }`. 4. What a job body receives, measured with `Object.keys(ctx)` inside the VM: `api`, `log` and `crypto`, each behind its capability token. `input`, `previous`, `user` and `session` are present and `null`; the shared `installCtx` installs them for every body. There is no `jobId` and no trigger `data`, even when a manual trigger passes data. `ctx` is not widened. **A2, the one binder:** see above. **Patch round 1, measured at the public door before the cancellation** (the extended pin at `37c472764f`, whose code was `c866c5ac9d`): 2 red, 9 green. - After an install-local `DELETE` (200), the uninstalled package's body job kept writing: 38 to 42 rows in 4 s. - After a reinstall whose new version dropped one of two jobs, the dropped job kept writing: 17 to 21 rows in 4 s. - After a restart, neither ran, because neither is rehydrated. Another installed package's job ran throughout. **After the cancellation:** 11 of 11 green. After PR #21581 landed, an uninstalled package's own object stops answering, so the pin's packages write into an object the host artifact owns; a run that should have stopped still shows there. **A3, codes.** `VALIDATION_ERROR` / 422 is an existing member of the `ErrorCode` union (the standard catalog), so this is **not** `PENDING LEDGER CODE` and nothing under the error-code ledger is edited. - The condition is generic: the install payload fails this door's acceptance rule, which is that every enabled job carries a `body`. - The ledger's admission rule sends a generic validation condition to the standard member rather than to a registered synonym (`error-code-ledger.zod.ts`, "Registering a new code"). This follows PR #21563's `PERMISSION_DENIED` reasoning. - `PLUGIN_MANIFEST_INVALID` was rejected because it would be untrue: the manifest is valid, since `os validate` passes it and `os start --artifact` runs it. - 422 rather than this door's 400/502 split: a catalog package that declares a handler job is no upstream fault. 422 derives `VALIDATION_ERROR` (`standardErrorCodeForHttpStatus`), so code and status agree. - If the contract review prefers a dedicated code, it is a one-constant change here (`JOB_WITHOUT_BODY_REFUSAL_CODE`) plus a spec-lane ledger row. **A5, CLI rendering.** Before: `Install failed (422): MESSAGE`, with the code dropped. That was a rendering gap, so the generic branch now names the code for every refusal. There is no case per code, and an envelope with no code prints the status alone. Pinned by unit and integration tests. **A6, the sibling (hooks).** Measured once at the public door. A package with a hook in the deprecated `handler` form and no function installs with exit 0 (`Package installed into the running kernel`), and the hook never fires: an inserted row keeps `legacy: null`, while a body-hook control on the same object stamped `bodied: yes`. The only trace is a server-side `WARN [hook-binder] skipping hook with unresolved handler`. That is silent at the door. Reported for the seat to file; not fixed here. ## Pins - `packages/runtime/src/app-artifact-handlers.jobs.test.ts` (23, real QuickJS) covers: - a body job is scheduled, and a run writes through `ctx.api` as `{ isSystem: true }`; - with both keys the body wins, and the handler is never called; - an L1 body and a `body.timeoutMs` are not scheduled, and never the handler beside them; - `timeoutMs` reaches the adapter and bounds the run; - with no `timeoutMs`, the runner's JOB default applies (not the hook's or the action's); - the `JobRunOutcome` shape; - the `ctx` surface has no `jobId` or `data`; - the handler control, the handler-not-found warn naming the body remedy, the disabled skip, the deployment switch, and `collectJobsWithoutBody`; - the boot door: `AppPlugin` schedules a body-only job on `kernel:ready`; - patch round: a reinstall that drops a job cancels it and keeps the other; a disabled job and a version with no jobs cancel; another app's jobs are never cancelled, even one that took over a name; a cancel that throws is said at `error`; - patch round: `runtime.package-jobs` is registered once per protocol, cancels every job of the uninstalled package and none of another's, is a no-op for a package that scheduled nothing, and reports an uncancellable job as `success: false`. - `packages/cloud-connection/src/marketplace-install-local-jobs.test.ts` (8, real runtime `dist`): - install schedules and runs the body; - rehydrate schedules it; - the refusal answers 422 `VALIDATION_ERROR`, names the job, the handler and both remedies, and leaves nothing registered, persisted or scheduled; - the plural message; - a disabled handler-only job installs; - a package without jobs installs with the same response keys; - patch round: `DELETE` cancels the uninstalled package's job through the cleanup, with `runtime.package-jobs` on the response's `cleanups`, and a control package's job stays scheduled; - patch round: a reinstall that drops a job cancels it. - `packages/cli/test/package-install-refusal-rendering.test.ts` (3, unit). - `packages/cli/test/package-install-local-jobs.integration.test.ts` (11, integration): install, restart, refusal, the control on both forms, and, in the patch round: after the `DELETE`, no further row hot and none after a restart; after a dropping reinstall, the same for the dropped job while the kept job runs on; and another package's job running throughout. ## Reverse verification (ablation), fix committed first Every leg ran through `scripts/ablation-replace.mjs` in wrap mode. In each, the anchor went from 1 to 0 on disk and the blob changed. The marker was proven in `dist/` by `ablation-dist-preflight.mjs` (present on the mutate leg, `--absent` plus a clean tree after the rebuild on the restore leg). The restore was proven by blob equal to HEAD and an empty `git diff HEAD`. - **Leg A, body scheduling disabled** (`if (job.body) {` in `scheduleAppArtifactJobs`, runtime rebuilt): - runtime unit: 8 red / 7 green (the handler, `collectJobsWithoutBody` and runner-default cases stayed green); - cloud-connection: 2 red (install, rehydrate) / 4 green; - CLI integration: 3 red (hot, restart, control body) / 3 green. - **Leg B, the refusal disabled** (the `withoutBody.length` guard of step 1c in install-local, cloud-connection rebuilt): - runtime: 15 green; - cloud-connection: 2 red (both refusals) / 4 green; - CLI integration: 1 red (the refusal) / 5 green. - **Leg C, the cancellation disabled** (`await svc.cancel(name);` in `retireAppJobs`, runtime rebuilt), at `bb25c4992e`: - runtime: 6 red (every replace and uninstall-cleanup case) / 17 green; - cloud-connection: 2 red (`DELETE`, reinstall) / 6 green; - CLI integration: 2 red (uninstall hot 37 to 41 rows, dropped job 16 to 20) / 9 green. - **Leg D, the cleanup registration disabled** (`ensureJobUninstallCleanup(ctx, jobService);`, runtime rebuilt): only the uninstall pins went red. Runtime 4 red / 19 green, cloud-connection 1 red / 7 green, CLI integration 1 red (uninstall hot) / 10 green. The reinstall pins stayed green, so the two mechanisms are pinned apart. - **Leg C repeated on the final head `650ff1e486`** (after PR #21581's withdrawal landed, as `ABLATION_21489_E`): runtime 6 red, cloud-connection 2 red, CLI integration 2 red (uninstall hot 38 to 42 rows, dropped job 16 to 20). The withdrawal alone does not stop the job. ## Tests and gates Final head `650ff1e486`, which merges `origin/main` after PR #21581 landed (no conflict): - `@objectstack/runtime` `pnpm test`: 317 files, 4467 passed, 19 skipped. - `@objectstack/cloud-connection` `pnpm test`: 35 files, 428 passed. - `@objectstack/cli` `--project unit`: 254 files, 3721 passed. - `@objectstack/cli` `--project integration`, the four install-local pins (jobs, handlers, boot-steps, uninstall-cleanups): 4 files, 51 passed. The rest of the integration tier is declared to CI. - `@objectstack/spec` `pnpm test`: 606 files, 17952 passed. - `typecheck` green for runtime, cloud-connection and cli. - `check:generated` after the describe edit: only `check:docs` was stale. `--fix` regenerated `content/docs/references/system/job.mdx` alone, and only the describe sentence moved. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 118 families (the docs and spec families joined with the amendment), all run at `650ff1e486`, every one exit 0. The `--ran` reconciliation reads 118 run, 0 NOT-MEASURED, with exit codes recorded. - `pnpm lint` (full repo): exit 0 at `650ff1e486`. ## Deviations and file surface - **`packages/spec/liveness/job.json` and `state-counts/job.md` were edited, although the dispatch keeps this lane out of `packages/spec`.** Moving the job loop out of `AppPlugin.start` turned `check:liveness`, a required gate, red: `job/retryPolicy` and `job/enabled` cited `app-plugin.ts`, which no longer names them. The gate's prescription is to repoint. The ledger's own `job.body` carrier note designates this card's commit for the `planned` to `live` flip. Left `planned`, the published `authorHint` makes `os validate` print a false warning. Measured with a config declaring a body job, `os validate` printed `job 'vj_tick_body': sets body.source but this job property is planned ... (not read YET)` before this edit, and prints no such warning after it. No Zod schema, no error-code ledger and no generated docs were touched. The spec package rides the changeset as `minor`, because `liveness/` is in its `files[]`. - `docs/qa/platform-checklist/areas/integration-system.json`: one source anchor repointed (`app-plugin.ts#handler` to `app-artifact-handlers.ts#scheduleAppArtifactJobs`). `check:platform-checklist` went red on the move. - `packages/runtime/src/sandbox/quickjs-runner.ts` (the per-kind default and the wrapper) and `packages/runtime/src/index.ts` (exports) were outside the expected list. - **Seat-directed, amendment `5969471197`:** `packages/spec/src/system/job.zod.ts` (the `JobSchema.body` describe sentence and the `defineJob` TSDoc example comment, text only, no shape change), the regenerated `content/docs/references/system/job.mdx`, and `content/docs/automation/jobs.mdx` (the callout and the example comment, plus one sentence on uninstall and reinstall). No wording names a build or lowering route. ## Acceptance notes - **Pointer fact 5**, reported only: `allowRuntimeCreate: false` for `job` is justified by `handler` alone. A runtime-authored job with a `body` would now be runnable in principle, but no door schedules a runtime-authored job; the binder schedules artifact jobs. - `body-runner.ts`'s job factory is not exported from `@objectstack/runtime`'s root, unlike the hook and action factories; it has no consumer outside the binder. - Code-read, unmeasured: `os package install` renders every 404 as "install-local endpoint not found", including a catalog 404 (`CLOUD_FETCH_FAILED`, a package missing from the catalog). - Code-read, unmeasured: a handler-form hook falls back to `engine.resolveFunction`, so it could bind to a same-named function another app registered. The silent drop of a handler-form hook is filed as #21585. - `packages/qa/dogfood/test/expression-conformance.ledger.ts` prose still names `runtime/app-plugin.ts start` as the `toBoundaryJobSchedule` call site. - #21540 is not addressed here (ruled C; see the erratum above). --- _Generated by [Claude Code](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent eb9ef79 commit 6c5697d

19 files changed

Lines changed: 2298 additions & 228 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/runtime': minor
3+
'@objectstack/cloud-connection': minor
4+
'@objectstack/cli': minor
5+
'@objectstack/spec': minor
6+
---
7+
8+
fix(runtime,cloud-connection)!: a job's sandboxed `body` is scheduled on every door that brings an artifact in, and install-local refuses an enabled job with no `body` (#21489)
9+
10+
Clause-②: yes (narrowing)
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) no authorable key, spelling, export or stored shape moves: `JobSchema` is unchanged by this release (its `body` landed earlier), so `objectstack migrate meta` has nothing to rewrite. What changes is which packages one install door accepts, and that job bodies now run. The other categories are closed on facts: the packages publish (not `unpublished`); no ADR-0087 id covers a refused install or a scheduled body (not `registered` / `already-registered`); and the change is runtime behaviour, not a declaration (not `runtime-interface-only` / `type-surface-only`). -->
13+
14+
**BREAKING**: `os package install` (the install-local door, `POST /api/v1/marketplace/install-local`) now refuses a package that declares an **enabled job with no `body`**. Such a job names its code only through `handler` — a `defineStack({ functions })` entry, which travels in the artifact's runtime module and never in the package JSON this door installs — so it used to install with a 200 and never run, hot or after a restart, with nothing saying so.
15+
16+
- **Job bodies run.** A job's sandboxed `body` (`JobSchema.body`, the hook body shape) is now scheduled on every door that brings an artifact in: the boot (`os start --artifact`, a `defineStack` config) and install-local, on install and on every rehydrate after a restart. One binder does it for all of them. With both `body` and `handler` declared, the `body` wins. The body runs in the QuickJS sandbox with `ctx.api` (as system: a job has no caller), `ctx.log` and `ctx.crypto` behind its declared `capabilities`. The job's `timeoutMs` is its one time limit; with none, a job body gets a 5000 ms CPU budget. A body may return `{ outcome: 'degraded', reason }` to report a run that did not do its work.
17+
- **A package's jobs stop with it.** Re-scheduling a package's jobs replaces its set: a reinstall whose new version drops, disables or can no longer run a job cancels that job, and a version with no jobs cancels them all. Uninstalling a package cancels its scheduled jobs through a new uninstall cleanup, `runtime.package-jobs`, on the protocol's uninstall-cleanup registry, so install-local's `DELETE` and the protocol's package uninstall both stop them and report it in `cleanups`. Another package's jobs are never touched.
18+
- **The refusal.** The install answers `422` with `VALIDATION_ERROR`, names each refused job and the function its `handler` declares, and installs nothing: nothing is registered, persisted or scheduled. A disabled job (`enabled: false`) is not judged. A package installed by an earlier version keeps rehydrating; its handler-only job is reported at `warn` and does not run.
19+
- **CLI.** `os package install` prints a refusal's code beside its status (`Install failed (422 VALIDATION_ERROR): …`), for every refusal alike.
20+
- **Spec.** The shipped liveness ledger records `job.body` (`language`, `source`, `capabilities`, `memoryMb`) as live, so `os validate` / `os build` no longer warn that a job's `body` is planned and not read yet. `body.timeoutMs` stays refused on a job. `JobSchema.body`'s description and the `defineJob` example no longer say to keep a `handler` until the runtime runs job bodies.
21+
- **Unchanged:** a `handler` job on a boot that loads the artifact's runtime module (`os start --artifact`, a `defineStack` config) still runs its `functions` entry; a package without jobs installs exactly as before.
22+
23+
The route for a refused package: give each enabled job a `body` (sandboxed JS that reaches data through `ctx.api`), or boot the artifact with `os start --artifact`, which loads its runtime module. It ships as `minor` under the launch-window convention for accept-set narrowings.

‎content/docs/automation/jobs.mdx‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -159,18 +159,21 @@ export const CloseStaleTasksJob = defineJob({
159159
`,
160160
capabilities: ['api.read', 'api.write', 'log'],
161161
},
162-
handler: 'closeStaleTasks', // deprecated — kept until the runtime runs job bodies (see below)
162+
handler: 'closeStaleTasks', // deprecated and optional — `body` wins when both are present
163163
timeoutMs: 120000,
164164
});
165165
```
166166

167-
<Callout type="warn">
168-
**The runtime does not run a job `body` yet.** Scheduling a job's body is a
169-
separate change that has not landed: until it does, a job is scheduled through
170-
its `handler`, and a job with a `body` and no `handler` is skipped at boot with
171-
a `warn`. `os validate` and `os build` say so wherever a `body` is set. Keep
172-
`handler` beside `body` for now; the `body` is validated today, so it is ready
173-
the day the runtime starts honouring it.
167+
<Callout>
168+
**Every door runs a job `body`.** The boot (a config, or `os start --artifact`)
169+
and `os package install` (on install, and again on every restart) schedule a
170+
job's `body` through the same binder. A `handler` is code: it travels only in the
171+
artifact's runtime module, so it runs only on a boot that loads that module (a
172+
config, or `os start --artifact`). `os package install` therefore refuses a
173+
package whose enabled job has no `body`, with `422 VALIDATION_ERROR` and the
174+
remedy: give the job a `body`, or boot it with `os start --artifact`.
175+
Uninstalling a package stops its scheduled jobs at once, and a reinstall whose
176+
new version drops a job stops that job.
174177
</Callout>
175178

176179
What running in the sandbox means for the code in `source`:

‎content/docs/references/system/job.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ const result = CronScheduleSchema.parse(data);
5858
| **description** | `string` | optional | Job description / purpose |
5959
| **schedule** | `{ type: 'cron'; expression: string \| object; timezone?: string } \| { type: 'interval'; intervalMs: integer } \| { type: 'once'; at: string }` | ✅ | Job schedule configuration |
6060
| **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+
| **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. Every door that brings an artifact in schedules a job's `body` — the boot, and `os package install` on install and on every restart — while a `handler` is code that travels only in the artifact's runtime module and runs only on a boot that loads it (a config, or `os start --artifact`); `os package install` therefore refuses an enabled job with no `body`. |
6262
| **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. |
6363
| **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. |
6464
| **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. |

‎docs/qa/platform-checklist/areas/integration-system.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -730,7 +730,7 @@
730730
"source": [
731731
"packages/spec/src/system/job.zod.ts#ScheduleSchema (ScheduleSchema discriminated union; JOB_ID_RETIRED; retryPolicy/timeoutMs docs incl. the 17.0.0 maxRetries default flip #4661; JobExecutionStatus)",
732732
"packages/spec/liveness/job.json (per-prop verdicts + the #4509 closed-door rationale)",
733-
"packages/runtime/src/app-plugin.ts#handler (registration, enabled/handler skip lines)",
733+
"packages/runtime/src/app-artifact-handlers.ts#scheduleAppArtifactJobs (registration, enabled/handler skip lines — the binder's job half, which every door that brings an artifact in calls)",
734734
"packages/services/service-job/src/cron-job-adapter.ts + db-job-adapter.ts (all three schedule shapes; sys_job/sys_job_run persistence) + run-with-policy.ts (retry/timeout enforcement, #3494)",
735735
"examples/app-showcase/src/automation/jobs/index.ts#showcase_health_sweep (showcase_health_sweep fixture + its #4774/#4888 history)"
736736
],

‎packages/cli/src/commands/package/install.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,15 @@ export default class PackageInstall extends Command {
205205
'MarketplaceInstallLocalPlugin (see @objectstack/cloud-connection).',
206206
);
207207
} else {
208-
printError(`Install failed (${res.status}): ${res.error}`);
208+
// [#21489] The runtime's refusal is printed with its CODE beside the
209+
// status, for every refusal alike — the code is the machine-readable
210+
// half an installer (human or AI) branches on, and the message carries
211+
// the remedy (e.g. a package whose enabled job has no `body`:
212+
// `422 VALIDATION_ERROR`, "give the job a `body`, or boot it with
213+
// `os start --artifact`"). No case per code: a refusal this command
214+
// has never heard of renders the same way.
215+
const code = typeof res.body?.error?.code === 'string' ? ` ${res.body.error.code}` : '';
216+
printError(`Install failed (${res.status}${code}): ${res.error}`);
209217
}
210218
this.exit(1);
211219
return;

0 commit comments

Comments
 (0)