Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/21489-job-bodies-install-local.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@objectstack/runtime': minor
'@objectstack/cloud-connection': minor
'@objectstack/cli': minor
'@objectstack/spec': minor
---

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)

Clause-②: yes (narrowing)

<!-- 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`). -->

**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.

- **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.
- **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.
- **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.
- **CLI.** `os package install` prints a refusal's code beside its status (`Install failed (422 VALIDATION_ERROR): …`), for every refusal alike.
- **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.
- **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.

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.
19 changes: 11 additions & 8 deletions content/docs/automation/jobs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -159,18 +159,21 @@ export const CloseStaleTasksJob = defineJob({
`,
capabilities: ['api.read', 'api.write', 'log'],
},
handler: 'closeStaleTasks', // deprecated — kept until the runtime runs job bodies (see below)
handler: 'closeStaleTasks', // deprecated and optional — `body` wins when both are present
timeoutMs: 120000,
});
```

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

What running in the sandbox means for the code in `source`:
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/system/job.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ const result = CronScheduleSchema.parse(data);
| **description** | `string` | optional | Job description / purpose |
| **schedule** | `{ type: 'cron'; expression: string \| object; timezone?: string } \| { type: 'interval'; intervalMs: integer } \| { type: 'once'; at: string }` | ✅ | Job schedule configuration |
| **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. |
| **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`. |
| **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`. |
| **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. |
| **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. |
| **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. |
Expand Down
2 changes: 1 addition & 1 deletion docs/qa/platform-checklist/areas/integration-system.json
Original file line number Diff line number Diff line change
Expand Up @@ -730,7 +730,7 @@
"source": [
"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)",
"packages/spec/liveness/job.json (per-prop verdicts + the #4509 closed-door rationale)",
"packages/runtime/src/app-plugin.ts#handler (registration, enabled/handler skip lines)",
"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)",
"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)",
"examples/app-showcase/src/automation/jobs/index.ts#showcase_health_sweep (showcase_health_sweep fixture + its #4774/#4888 history)"
],
Expand Down
10 changes: 9 additions & 1 deletion packages/cli/src/commands/package/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,15 @@ export default class PackageInstall extends Command {
'MarketplaceInstallLocalPlugin (see @objectstack/cloud-connection).',
);
} else {
printError(`Install failed (${res.status}): ${res.error}`);
// [#21489] The runtime's refusal is printed with its CODE beside the
// status, for every refusal alike — the code is the machine-readable
// half an installer (human or AI) branches on, and the message carries
// the remedy (e.g. a package whose enabled job has no `body`:
// `422 VALIDATION_ERROR`, "give the job a `body`, or boot it with
// `os start --artifact`"). No case per code: a refusal this command
// has never heard of renders the same way.
const code = typeof res.body?.error?.code === 'string' ? ` ${res.body.error.code}` : '';
printError(`Install failed (${res.status}${code}): ${res.error}`);
}
this.exit(1);
return;
Expand Down
Loading
Loading