Skip to content

Commit 53c5916

Browse files
committed
fix(runtime): keep the landed names collectJobsWithoutBody and JobWithoutBody
The shipped job liveness ledger anchors job/enabled's evidence on collectJobsWithoutBody, so the export keeps its name; its TSDoc now states that the judgement also covers a body that does not bind (an expression body, or one carrying body.timeoutMs). Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
1 parent 1b41b79 commit 53c5916

6 files changed

Lines changed: 34 additions & 30 deletions

File tree

‎.changeset/21585-hook-refusal-install-local.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Clause-②: yes (narrowing)
1616

1717
- **The refusal.** The install answers `422` with `VALIDATION_ERROR`, the answer the door already gives an enabled job with no `body`. One answer names everything the door cannot run: each hook and the function its `handler` names, each job and its handler, and each refused job `body` with the key the declaration refuses. Nothing is installed: nothing is registered, persisted, bound or scheduled. `os package install` exits non-zero and prints the code beside the status.
1818
- **Rehydrate.** A package installed by an earlier version keeps rehydrating after a restart. Its body hooks bind as before. A hook of it with no `body` is reported at `warn` by name and is **not bound**: this door carries no runtime module, so the hook's `handler` can never name the package's own code. Its job with no runnable `body` is reported and not run, as before.
19-
- **Runtime.** The binder exports the two judgements the door reads: `collectHooksWithoutBody`, and `collectJobsWithoutRunnableBody`, which also names a job whose `body` does not bind. `bindAppArtifactHandlers` takes `withholdHooksWithoutBody`, which a door that carries no runtime module sets, and reports the hooks it withheld as `withheldHooks`.
19+
- **Runtime.** The binder exports the two judgements the door reads: `collectHooksWithoutBody`, and `collectJobsWithoutBody`, which also names a job whose `body` does not bind. `bindAppArtifactHandlers` takes `withholdHooksWithoutBody`, which a door that carries no runtime module sets, and reports the hooks it withheld as `withheldHooks`.
2020
- **Unchanged:** a boot that loads the artifact's runtime module (`os start --artifact`, a `defineStack` config) binds an app's handler hooks to its own functions exactly as before. Hooks authored through the metadata API are unchanged too. A package whose hooks carry a `body` and whose enabled jobs carry a valid `body` installs exactly as before.
2121

2222
The route for a refused package: give each hook a `body` (sandboxed JS, the form actions and jobs use), and correct each job `body` to the declared shape. That shape is a sandboxed JS body whose time limit is the job's own `timeoutMs`, and `os validate` reports the same refusal. Alternatively, boot the artifact with `os start --artifact`, which loads its runtime module. This ships as `minor`, under the launch-window convention for narrowings of an accept set.

‎packages/cloud-connection/src/marketplace-install-local-plugin.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1835,7 +1835,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
18351835
* install route refuses:
18361836
*
18371837
* - the enabled jobs with no `body`, or with a `body` the declaration
1838-
* refuses (`collectJobsWithoutRunnableBody`, which reads the jobs the
1838+
* refuses (`collectJobsWithoutBody`, which reads the jobs the
18391839
* binder schedules and judges a body by the parse the binder binds by);
18401840
* - the hooks with no `body` (`collectHooksWithoutBody`, the judgement the
18411841
* binder withholds by on this door's rehydrate).
@@ -1850,16 +1850,16 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
18501850
manifest: unknown,
18511851
manifestId: string,
18521852
): Promise<UnrunnableCode> => {
1853-
let collectJobs: typeof import('@objectstack/runtime')['collectJobsWithoutRunnableBody'] | undefined;
1853+
let collectJobs: typeof import('@objectstack/runtime')['collectJobsWithoutBody'] | undefined;
18541854
let collectHooks: typeof import('@objectstack/runtime')['collectHooksWithoutBody'] | undefined;
18551855
try {
18561856
const mod: any = await import('@objectstack/runtime');
1857-
if (typeof mod?.collectJobsWithoutRunnableBody === 'function') collectJobs = mod.collectJobsWithoutRunnableBody;
1857+
if (typeof mod?.collectJobsWithoutBody === 'function') collectJobs = mod.collectJobsWithoutBody;
18581858
if (typeof mod?.collectHooksWithoutBody === 'function') collectHooks = mod.collectHooksWithoutBody;
18591859
} catch { /* reported below */ }
18601860
if (!collectJobs) {
18611861
ctx.logger?.warn?.(
1862-
`[MarketplaceInstallLocal] this runtime has no collectJobsWithoutRunnableBody — the jobs of ${manifestId} are not judged, `
1862+
`[MarketplaceInstallLocal] this runtime has no collectJobsWithoutBody — the jobs of ${manifestId} are not judged, `
18631863
+ 'so a job with no runnable `body` installs and is never run. Upgrade @objectstack/runtime alongside @objectstack/cloud-connection.',
18641864
);
18651865
}

‎packages/runtime/src/app-artifact-handlers.jobs.test.ts‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
/**
44
* #21489 — the binder's job half: `scheduleAppArtifactJobs`, the ONE place a
5-
* declared job becomes a scheduled one, and `collectJobsWithoutRunnableBody`,
5+
* declared job becomes a scheduled one, and `collectJobsWithoutBody`,
66
* the judgement the install-local door refuses on (#21585: no `body`, or a
77
* `body` the declaration refuses — the same judgement the binder binds by).
88
*
@@ -26,7 +26,7 @@
2626

2727
import { describe, it, expect, vi } from 'vitest';
2828
import type { PluginContext } from '@objectstack/core';
29-
import { scheduleAppArtifactJobs, collectJobsWithoutRunnableBody, PACKAGE_JOBS_UNINSTALL_CLEANUP } from './app-artifact-handlers.js';
29+
import { scheduleAppArtifactJobs, collectJobsWithoutBody, PACKAGE_JOBS_UNINSTALL_CLEANUP } from './app-artifact-handlers.js';
3030
import { jobBodyRunnerFactory } from './sandbox/body-runner.js';
3131
import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js';
3232
import { AppPlugin } from './app-plugin.js';
@@ -392,9 +392,9 @@ describe('#21489: the sandbox job origin', () => {
392392
});
393393
});
394394

395-
describe('#21489: collectJobsWithoutRunnableBody — what no JSON door can run', () => {
395+
describe('#21489: collectJobsWithoutBody — what no JSON door can run', () => {
396396
it('names each ENABLED job without a body, with the function its handler declares', () => {
397-
expect(collectJobsWithoutRunnableBody(pkg([
397+
expect(collectJobsWithoutBody(pkg([
398398
{ name: 'handler_only', schedule: INTERVAL, handler: 'tick' },
399399
{ name: 'neither', schedule: INTERVAL },
400400
{ name: 'body_job', schedule: INTERVAL, body: WRITE_BODY },
@@ -407,8 +407,8 @@ describe('#21489: collectJobsWithoutRunnableBody — what no JSON door can run',
407407
});
408408

409409
it('a package without jobs has nothing to refuse', () => {
410-
expect(collectJobsWithoutRunnableBody(pkg([]))).toEqual([]);
411-
expect(collectJobsWithoutRunnableBody({ id: APP_ID })).toEqual([]);
410+
expect(collectJobsWithoutBody(pkg([]))).toEqual([]);
411+
expect(collectJobsWithoutBody({ id: APP_ID })).toEqual([]);
412412
});
413413
});
414414

@@ -418,7 +418,7 @@ describe('#21585: a job body the declaration refuses is judged as unrunnable —
418418
const GOOD = { name: 'good_job', schedule: INTERVAL, body: WRITE_BODY };
419419

420420
it('names an L1 expression body and a body carrying timeoutMs, each with the declaration\'s refusal', () => {
421-
const named = collectJobsWithoutRunnableBody(pkg([L1, TWO_LIMITS, GOOD, { ...L1, name: 'l1_disabled', enabled: false }]));
421+
const named = collectJobsWithoutBody(pkg([L1, TWO_LIMITS, GOOD, { ...L1, name: 'l1_disabled', enabled: false }]));
422422

423423
expect(named.map((j) => j.name)).toEqual(['l1_job', 'two_limits']);
424424
// The key the refusal names, so the author knows where to look.
@@ -429,7 +429,7 @@ describe('#21585: a job body the declaration refuses is judged as unrunnable —
429429
});
430430

431431
it('a body with a handler beside it is judged by its body — the body wins, as it does in the binder', () => {
432-
const named = collectJobsWithoutRunnableBody(pkg([{ ...L1, handler: 'tick' }, { ...GOOD, name: 'good_both', handler: 'tick' }]));
432+
const named = collectJobsWithoutBody(pkg([{ ...L1, handler: 'tick' }, { ...GOOD, name: 'good_both', handler: 'tick' }]));
433433

434434
expect(named).toEqual([{ name: 'l1_job', handler: 'tick', bodyRefusal: expect.stringMatching(/^body\.language: /) }]);
435435
});
@@ -438,7 +438,7 @@ describe('#21585: a job body the declaration refuses is judged as unrunnable —
438438
const h = harness();
439439
const jobs = [L1, TWO_LIMITS, GOOD, { name: 'handler_only', schedule: INTERVAL, handler: 'tick' }];
440440

441-
const named = new Set(collectJobsWithoutRunnableBody(pkg(jobs)).map((j) => j.name));
441+
const named = new Set(collectJobsWithoutBody(pkg(jobs)).map((j) => j.name));
442442
await h.schedule(pkg(jobs));
443443

444444
expect([...named].sort()).toEqual(['handler_only', 'l1_job', 'two_limits']);

‎packages/runtime/src/app-artifact-handlers.ts‎

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@
6868
* A job runs on a JSON door only through a `body` that binds. Its deprecated
6969
* `handler` names a `defineStack({ functions })` entry, which is code: it
7070
* travels in the artifact's runtime module, which only `os start --artifact`
71-
* loads, so no JSON door can ever resolve it. {@link collectJobsWithoutRunnableBody}
71+
* loads, so no JSON door can ever resolve it. {@link collectJobsWithoutBody}
7272
* names those jobs — and the ones whose `body` the declaration refuses
7373
* (`judgeJobBody`) — and the install-local install route refuses a package that
7474
* declares one enabled.
@@ -285,12 +285,14 @@ export function bindAppArtifactHandlers(
285285
// ─── The job half (#21489) ─────────────────────────────────────────────
286286

287287
/**
288-
* An enabled job a JSON door cannot run: it carries no `body`, or a `body` the
289-
* declaration refuses. Its `handler` (deprecated) names a
288+
* An enabled job a JSON door cannot run: it carries no `body` — or, since
289+
* #21585, a `body` that does not BIND (the declaration refuses it: an expression
290+
* body, or one carrying `body.timeoutMs`), which no door can run either. "Without
291+
* body" reads as "without a body that runs". Its `handler` (deprecated) names a
290292
* `defineStack({ functions })` entry — code, which a JSON artifact never
291293
* carries (ADR-0088) — or it names nothing at all.
292294
*/
293-
export interface JobWithoutRunnableBody {
295+
export interface JobWithoutBody {
294296
/** The job's `name`. */
295297
name: string;
296298
/** The function name the job's `handler` declares, when it declares one. */
@@ -305,18 +307,20 @@ export interface JobWithoutRunnableBody {
305307

306308
/**
307309
* The enabled jobs of an artifact that no JSON door can schedule (#21489):
308-
* those with no `body`, and (#21585) those whose `body` does not bind — the
309-
* judgement the binder's own {@link jobBodyRunnerFactory} makes
310-
* ({@link judgeJobBody}, a parse against `JobSchema.body`), so the door and the
311-
* binder cannot disagree. The install-local install route refuses a package
312-
* that declares one; see the module header.
310+
* those with no `body`, and (#21585) those whose `body` does not BIND — an
311+
* expression (L1) body, or one carrying `body.timeoutMs`, or any other shape
312+
* the declaration refuses. That second half is the judgement the binder's own
313+
* {@link jobBodyRunnerFactory} makes ({@link judgeJobBody}, a parse against
314+
* `JobSchema.body`), so the door and the binder cannot disagree; such a job is
315+
* named with its `bodyRefusal`. The install-local install route refuses a
316+
* package that declares one; see the module header.
313317
*
314318
* Reads the jobs the binder reads ({@link collectBundleJobs}), and calls a job
315319
* enabled exactly when the binder does: `enabled: false` is the one value that
316320
* disables it (the schema's default is `true`).
317321
*/
318-
export function collectJobsWithoutRunnableBody(bundle: unknown): JobWithoutRunnableBody[] {
319-
const out: JobWithoutRunnableBody[] = [];
322+
export function collectJobsWithoutBody(bundle: unknown): JobWithoutBody[] {
323+
const out: JobWithoutBody[] = [];
320324
for (const job of collectBundleJobs(bundle)) {
321325
if (!job || typeof job !== 'object') continue;
322326
if (job.enabled === false) continue;
@@ -440,7 +444,7 @@ export interface AppArtifactJobScheduling {
440444
* - else a `handler` → the `functions` entry it names, invoked with the
441445
* in-process `JobHandlerContext` (#14094). A JSON artifact carries no
442446
* functions, so on install-local this resolves nothing — which is why that
443-
* door refuses the shape up front ({@link collectJobsWithoutRunnableBody});
447+
* door refuses the shape up front ({@link collectJobsWithoutBody});
444448
* - else → not scheduled (warn).
445449
*
446450
* The schedule is lowered to the boundary tier (`toBoundaryJobSchedule`), and

‎packages/runtime/src/index.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export { AppPlugin, collectBundleHooks, collectBundleFunctions, collectBundleFun
6868
// hooks, under the owner `app:<appId>` — called by `AppPlugin.start` and by the
6969
// install-local plugin (`@objectstack/cloud-connection`) on install and rehydrate.
7070
// [#21489] …and its job half: `scheduleAppArtifactJobs` schedules a package's
71-
// jobs (a `body` runs sandboxed on every door), and `collectJobsWithoutRunnableBody`
71+
// jobs (a `body` runs sandboxed on every door), and `collectJobsWithoutBody`
7272
// names the enabled jobs no JSON door can run (no `body`, or one that does not
7373
// bind), which install-local refuses.
7474
// [#21585] `collectHooksWithoutBody` names the hooks whose code is only a
@@ -78,15 +78,15 @@ export {
7878
bindAppArtifactHandlers,
7979
appArtifactHandlerOwner,
8080
scheduleAppArtifactJobs,
81-
collectJobsWithoutRunnableBody,
81+
collectJobsWithoutBody,
8282
collectHooksWithoutBody,
8383
} from './app-artifact-handlers.js';
8484
export type {
8585
AppArtifactHandlerBinding,
8686
AppArtifactHandlerBindingOptions,
8787
AppArtifactJobScheduling,
8888
AppArtifactJobSchedulingOptions,
89-
JobWithoutRunnableBody,
89+
JobWithoutBody,
9090
HookWithoutBody,
9191
} from './app-artifact-handlers.js';
9292
// #14094 — what a DECLARATIVE job's handler is invoked with. A job has no graph,

‎packages/runtime/src/sandbox/body-runner.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -491,7 +491,7 @@ export type JobBodyJudgement =
491491
*
492492
* Two readers, so they cannot disagree: {@link jobBodyRunnerFactory} binds
493493
* nothing for a body this refuses, and the install-local door refuses a
494-
* package whose enabled job carries one (`collectJobsWithoutRunnableBody` in
494+
* package whose enabled job carries one (`collectJobsWithoutBody` in
495495
* `../app-artifact-handlers.ts`). Before the door asked, it judged only that a
496496
* `body` was PRESENT — an L1 expression body, or one carrying `timeoutMs`,
497497
* installed with a 200 and the job was never scheduled, with only a server

0 commit comments

Comments
 (0)