diff --git a/.changeset/20281-job-pull-organization.md b/.changeset/20281-job-pull-organization.md index acca3fdf16b..c1d2969a83e 100644 --- a/.changeset/20281-job-pull-organization.md +++ b/.changeset/20281-job-pull-organization.md @@ -12,7 +12,7 @@ Clause-②: yes (widening) - **`JobSchema.organization`**. The organization a job runs as. It applies to the body's `ctx.api`, to the handler's new `executionContext`, and to the pull's reads and writes. The value shape is the scheduled flow's: a non-empty `sys_organization.id`. A near-miss spelling (`organizationId`, `orgId`, `tenantId`, …) is refused at parse and pointed at the key. - **`defineStack`, and so `os validate`**, refuses a job whose `pull` names a mapping the stack does not declare, or a mapping with no `connectorSource`. The refusal is the existing `STACK_CROSS_REFERENCE_INVALID` envelope. - **`IAutomationService.pullConnectorSource`** (`@objectstack/spec/contracts`, with `ConnectorSourcePullRequest`, `ConnectorSourcePullResult` and `ConnectorSourcePullSummary`). The connector sync executor is now on the `automation` service. `@objectstack/service-automation`'s engine serves it from the executor `AutomationServicePlugin` attaches at init (`AutomationEngine.setConnectorPullSource`). A bare engine refuses with `SERVICE_UNAVAILABLE` (503). -- **The job binder** (`@objectstack/runtime`, `scheduleAppArtifactJobs`) schedules a `pull` job on every door: the boot, and `os package install` on install and rehydrate. Each run calls `pullConnectorSource` through the service registry. A refused pull fails the run, and `retryPolicy` applies. A pull whose rows the import runner refused records the run `degraded`, with the counts. A pull naming a mapping the artifact does not carry is not scheduled, and neither is one whose mapping has no `connectorSource`, nor one on a kernel whose `automation` service cannot pull. Each case is logged at `warn` with the reason. `collectJobsWithoutBody` no longer names a `pull` job, so `os package install` does not refuse one. The result gains `pulls` and `missingOrganization`. +- **The job binder** (`@objectstack/runtime`, `scheduleAppArtifactJobs`) schedules a `pull` job on every door: the boot, and `os package install` on install and rehydrate. Each run calls `pullConnectorSource` through the service registry. A refused pull fails the run, and `retryPolicy` applies. A pull whose rows the import runner refused records the run `degraded`, with the counts. A pull naming a mapping the artifact does not carry is not scheduled, and neither is one whose mapping has no `connectorSource`, nor one on a kernel whose `automation` service cannot pull. Each case is logged at `warn` with the reason. `collectJobsWithoutBody` does not name a `pull` job that binds, so `os package install` installs one. The result gains `pulls` and `missingOrganization`. - **The organization, judged at bind** by the posture rule scheduled flows use (`resolveScheduledWorkPolicy`). Every run carries `{ isSystem: true, tenantId: }`, or `{ isSystem: true }` for a job that declares none. Under `single` the key is not required. Under `group` it is optional; an undeclared job is scheduled and named once at `warn`, because a tenant-scoped row it writes is refused. Under `isolated`, with package-authored scheduled work switched on, it is **required**. **Action on such a deployment:** declare `organization` on each packaged job, or the job is not scheduled; the error log names the job. Until now such a job was scheduled, and every tenant-scoped write it made was refused at the write. An unrecognized `OS_TENANCY_POSTURE` withholds every job (`scheduled-work-policy-unreadable`) instead of guessing whether a declaration is required. - **Texts this makes true.** The `mapping.connectorSource` description, the `connector.syncConfig` tombstone prescription and the `connector-sync-keys-retired` upgrade entry said "nothing schedules a pull yet". They now name the `job` `pull` that drives it. diff --git a/.changeset/21672-install-local-pull-refusal.md b/.changeset/21672-install-local-pull-refusal.md new file mode 100644 index 00000000000..5a0a7bd80b3 --- /dev/null +++ b/.changeset/21672-install-local-pull-refusal.md @@ -0,0 +1,19 @@ +--- +'@objectstack/runtime': minor +'@objectstack/cloud-connection': minor +--- + +fix(runtime,cloud-connection)!: install-local refuses an enabled job whose `pull` does not bind, as it refuses a job `body` that does not bind (#21672) + +Clause-②: yes (narrowing) + + + +**BREAKING**: `os package install` (the install-local door, `POST /api/v1/marketplace/install-local`) now refuses a package whose enabled job declares a `pull` that does not bind. It used to install such a package with a 200, and the job was never scheduled; only a server warn said so. + +- **What does not bind.** The `pull` names a mapping the package does not declare, or a mapping with no `connectorSource`, or the job declares `body` or `handler` beside its `pull`. The door judges this with the scheduler's own judgement, so the door and the scheduler cannot disagree. `defineStack` and `os validate` already refuse the same `pull`, so only a hand-edited package reaches the door with one. +- **The refusal.** The install answers `422` with `VALIDATION_ERROR`, the answer the door already gives an enabled job whose `body` does not bind. One answer names everything the door cannot run, and gives each such job the reason its `pull` does not bind, prefixed with the key it names (`pull.mapping: …`). Nothing is installed: nothing is registered, persisted or scheduled. `os package install` exits non-zero and prints the code beside the status. +- **Unchanged.** A pull job naming a declared mapping with a `connectorSource` installs and is scheduled as before. A disabled pull job does not block its install. A package installed by an earlier version still rehydrates after a restart, and its pull job that does not bind is not scheduled, with a warn naming the job and the reason, as before. +- **Runtime.** `collectJobsWithoutBody` now names an enabled job whose `pull` does not bind, and `JobWithoutBody` gains an optional `pullRefusal`: the reason the scheduler gives when it does not schedule the job. Such a job carries no `bodyRefusal`. + +The route for a refused package: declare the mapping the job's `pull` names in the package, with a `connectorSource` naming the `rest` or `openapi` connector it reads from, or correct the `pull` as the refusal says. `os validate` refuses the same `pull`. This ships as `minor`, under the launch-window convention for narrowings of an accept set. diff --git a/content/docs/automation/jobs.mdx b/content/docs/automation/jobs.mdx index c31edc5b3ae..f80bd10185d 100644 --- a/content/docs/automation/jobs.mdx +++ b/content/docs/automation/jobs.mdx @@ -176,7 +176,8 @@ export const CloseStaleTasksJob = defineJob({ package whose enabled job has no `body`, or a `body` that does not bind (an expression body, or one carrying `body.timeoutMs`), with `422 VALIDATION_ERROR` and the remedy: give the job a valid `body`, or boot it with `os start --artifact`. - A [`pull`](#pulling-a-mapping) job is data too, and is not refused. + A [`pull`](#pulling-a-mapping) job is data too: it installs when its `pull` + binds, and is refused with the same `422` when it does not. Uninstalling a package stops its scheduled jobs at once, and a reinstall whose new version drops a job stops that job. @@ -249,7 +250,10 @@ export const OrdersPullJob = defineJob({ - **Checked when you build.** `defineStack` — and so `os validate` — refuses a `pull` that names a mapping the stack does not declare, or one with no `connectorSource`. The binder checks the same reference against the artifact - before it schedules the job, on every door. + before it schedules the job, on every door, and `os package install` refuses a + package whose enabled job's `pull` fails that check, with `422 VALIDATION_ERROR` + naming the job and the reason. A package an earlier version installed still + loads after a restart; such a job of it is not scheduled, and a warning names it. - **Each run is one pull.** It calls the connector's read action once, reads one response, and writes the records through the import runner with the mapping's `mode` and `upsertKey` — see diff --git a/packages/cli/test/package-install-local-jobs-pull.integration.test.ts b/packages/cli/test/package-install-local-jobs-pull.integration.test.ts new file mode 100644 index 00000000000..fb14fe43a9e --- /dev/null +++ b/packages/cli/test/package-install-local-jobs-pull.integration.test.ts @@ -0,0 +1,482 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * install-local refuses an enabled job whose `pull` cannot bind, as it refuses + * a job whose `body` cannot bind; a package installed by an earlier build + * still rehydrates, its unbindable pull job withheld and warned by name. + * + * ## The defect, measured at this door before the fix + * + * `JobSchema.pull: { mapping }` is a job's declarative run form: the binder + * (`scheduleAppArtifactJobs`) schedules it when `judgeJobPull` binds it — the + * artifact declares the named mapping, with a `connectorSource`. A package + * whose enabled pull job named a mapping the package does not declare, or a + * mapping with no `connectorSource`, installed with exit 0: the binder logged a + * warn and never scheduled the job, and the install answer said nothing. The + * authoring doors (`defineStack`, `os validate`) already refuse both shapes, so + * only a hand-edited package reached this door with one. + * + * ## What each `it` reads + * + * One host runtime (`requires: ['job', 'automation']`, package scheduled work + * switched on), two boots of one home: + * + * 1. INSTALL — a pull job naming an undeclared mapping is REFUSED, with its + * code, the job, the key the refusal names and the remedy, and nothing of + * the package is installed or scheduled; a pull job whose mapping declares + * no `connectorSource` is refused the same way; the control — a pull job + * naming a declared mapping — installs and is scheduled (its `sys_job` + * row, and a `sys_job_run` row per run: every run reaches the automation + * service's pull door, which refuses it because the package declares no + * `connectors[]` entry for the connector the mapping names — the run's + * verdict, `failed`, not the install's: the install door judges the job's + * `pull` as the binder does, never the connector a run will read); a + * DISABLED unbindable pull job does not block its install; + * 2. RESTART — a ledger entry an earlier build wrote, carrying an unbindable + * pull job beside a bindable one, rehydrates: the bindable job is + * scheduled, the unbindable one is not, and a warn names it. + * + * Every reading goes through a door a user uses: the CLI's own output and exit + * code for the install, the data route for the job service's own records. + * + * ## Spawn shape + * + * Shared with `package-install-local-jobs.integration.test.ts`: the tsx source + * entry, one process group per `os start`, every workspace package — + * `@objectstack/runtime` and `@objectstack/cloud-connection` included — + * resolved through its `exports` to `dist/`, so an ablation of either + * package's source reaches this file only after that package is rebuilt. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + CLI, + childEnv, + E2E_SECRET_KEY, + portContentionError, + portDriftError, + probeThroughChild, + randomPort, + TSX, +} from './helpers/serve-process.js'; + +/** The banner's tail — every row above it has printed. */ +const READY = /Press Ctrl\+C to stop/; +const BOOT_TIMEOUT_MS = 180_000; +/** How long a phase waits for a 1-second interval job to have recorded a run. */ +const RUN_WAIT_MS = 20_000; + +/** The development dev-admin seed — see the #21321 sibling for why it is the operator on every boot. */ +const EMAIL = 'admin@objectos.ai'; +const PASSWORD = 'admin123'; + +/** The control: an enabled pull job naming a mapping its package declares, with a `connectorSource`. */ +const GOOD_APP_ID = 'com.example.pulljobs'; +const GOOD_JOB = 'pull_jobs_orders'; + +/** An enabled pull job naming a mapping its package does not declare (a one-letter typo). */ +const MISSING_APP_ID = 'com.example.pullmissing'; +const MISSING_JOB = 'pull_missing_orders'; + +/** An enabled pull job naming a declared mapping that has no `connectorSource`. */ +const NOSOURCE_APP_ID = 'com.example.pullnosource'; +const NOSOURCE_JOB = 'pull_nosource_orders'; + +/** A DISABLED pull job naming an undeclared mapping. */ +const OFF_APP_ID = 'com.example.pulloff'; +const OFF_JOB = 'pull_off_orders'; + +/** The ledger entry an earlier build wrote: one bindable pull job, one unbindable. */ +const LEGACY_APP_ID = 'com.example.pulllegacy'; +const LEGACY_BOUND = 'pull_legacy_bound'; +const LEGACY_UNBOUND = 'pull_legacy_unbound'; + +const MAPPING_NAME = 'orders_pull'; + +function orderObject(name: string) { + return { + name, + label: 'Order', + sharingModel: 'public_read_write', + fields: { external_id: { type: 'text', label: 'External id' } }, + }; +} + +/** A mapping that pulls into `target`; `withSource: false` drops its `connectorSource` (an import-only mapping). */ +function mapping(target: string, withSource = true) { + return { + name: MAPPING_NAME, + targetObject: target, + fieldMapping: [{ source: 'id', target: 'external_id' }], + mode: 'upsert', + upsertKey: ['external_id'], + ...(withSource ? { connectorSource: { connector: 'orders_api', action: 'request' } } : {}), + }; +} + +/** A pull job on a 1-second interval naming `mappingName`. */ +function pullJob(name: string, mappingName: string, extra: Record = {}) { + return { name, schedule: { type: 'interval', intervalMs: 1000 }, pull: { mapping: mappingName }, ...extra }; +} + +/** One package, as `os build` writes `dist/objectstack.json` (schema defaults trimmed). */ +function pullArtifact(id: string, ns: string, opts: { job: unknown; withSource?: boolean }) { + return { + manifest: { id, namespace: ns, version: '0.1.0', type: 'app', name: ns }, + objects: [orderObject(`${ns}_order`)], + mappings: [mapping(`${ns}_order`, opts.withSource ?? true)], + jobs: [opts.job], + }; +} + +const GOOD_ARTIFACT = pullArtifact(GOOD_APP_ID, 'pull_jobs', { job: pullJob(GOOD_JOB, MAPPING_NAME) }); +const MISSING_ARTIFACT = pullArtifact(MISSING_APP_ID, 'pull_missing', { job: pullJob(MISSING_JOB, 'orders_pul') }); +const NOSOURCE_ARTIFACT = pullArtifact(NOSOURCE_APP_ID, 'pull_nosource', { job: pullJob(NOSOURCE_JOB, MAPPING_NAME), withSource: false }); +const OFF_ARTIFACT = pullArtifact(OFF_APP_ID, 'pull_off', { job: pullJob(OFF_JOB, 'orders_pul', { enabled: false }) }); + +/** What the install route persists — the compiled bundle, flattened — in an earlier build's layout. */ +const LEGACY_ENTRY = { + packageId: LEGACY_APP_ID, + versionId: 'local', + manifestId: LEGACY_APP_ID, + version: '0.1.0', + manifest: { + id: LEGACY_APP_ID, + namespace: 'pull_legacy', + version: '0.1.0', + type: 'app', + name: 'Pull Legacy', + objects: [orderObject('pull_legacy_order')], + mappings: [mapping('pull_legacy_order')], + jobs: [pullJob(LEGACY_BOUND, MAPPING_NAME), pullJob(LEGACY_UNBOUND, 'orders_pul')], + }, + installedAt: '2026-01-01T00:00:00.000Z', + installedBy: 'admin', + withSampleData: false, +}; + +/** + * The RUNTIME the packages are installed into. `os start` composes its + * services from the boot stack's `requires`, never from a package installed + * later, so the host declares the job service and the automation service — the + * one whose `pullConnectorSource` a pull job's run calls. + */ +const HOST_ARTIFACT = { + manifest: { id: 'com.example.pullhost', namespace: 'pull_host', version: '0.1.0', type: 'app', name: 'Pull Host' }, + requires: ['job', 'automation'], + objects: [orderObject('pull_host_order')], +}; + +const groups: ChildProcess[] = []; +const dirs: string[] = []; + +interface LiveStart { + child: ChildProcess; + base: string; + output: () => string; +} + +function bootStart(cwd: string, home: string, port: string, extra: string[] = []): Promise { + return new Promise((resolveBoot, rejectBoot) => { + const child = spawn(TSX, [CLI, 'start', '-p', port, '--home', home, '--auth-secret', E2E_SECRET_KEY, '--no-ui', ...extra], { + cwd, + // `childEnv`, never a bare `...process.env` — see its header. Package + // scheduled work is OFF by default in every posture (#17396); this + // runtime is one that runs it. + env: childEnv({ + NO_COLOR: '1', + OS_CLOUD_URL: 'off', + OS_LOG_LEVEL: 'warn', + OS_SECRET_KEY: E2E_SECRET_KEY, + OS_AUTOMATION_SCHEDULED_WORK_ENABLED: 'true', + }), + stdio: ['ignore', 'pipe', 'pipe'], + // Own process group: `os start` supervises a `serve` grandchild. + detached: true, + }); + groups.push(child); + let out = ''; + let settled = false; + const settle = (err: Error | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (err) rejectBoot(err); + else resolveBoot({ child, base: `http://localhost:${port}`, output: () => out }); + }; + const timer = setTimeout( + () => settle(new Error(`os start never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)), + BOOT_TIMEOUT_MS, + ); + const onData = (d: unknown) => { + out += String(d); + // The child is the authority on the port it bound. + if (READY.test(out)) settle(portDriftError(out, 'os start', port)); + }; + child.stdout?.on('data', onData); + child.stderr?.on('data', onData); + child.on('exit', (code) => + settle(portContentionError(out, 'os start', port) + ?? new Error(`os start exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)), + ); + }); +} + +async function stopGroup(child: ChildProcess): Promise { + if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return; + await new Promise((done) => { + const give = setTimeout(() => { + try { process.kill(-child.pid!, 'SIGKILL'); } catch { /* group already gone */ } + done(); + }, 15_000); + child.once('exit', () => { clearTimeout(give); done(); }); + try { process.kill(-child.pid!, 'SIGTERM'); } catch { clearTimeout(give); done(); } + }); +} + +interface Answer { status: number; body: any } + +/** One exchange against the running `os start`, attributed to the child if the transport fails. ⛔ No assertion inside it. */ +function http(live: LiveStart, method: string, path: string, token: string, body?: unknown): Promise { + return probeThroughChild( + { + child: live.child, + transcript: () => `\n--- child output ---\n${live.output().slice(-4000)}`, + label: 'package-install-local-jobs-pull', + what: `${method} ${path}`, + }, + async () => { + const r = await fetch(`${live.base}${path}`, { + method, + headers: { + origin: live.base, + ...(body !== undefined ? { 'content-type': 'application/json' } : {}), + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + const text = await r.text(); + let parsed: any = text; + try { parsed = JSON.parse(text); } catch { /* keep the text */ } + return { status: r.status, body: parsed }; + }, + ); +} + +async function authenticate(live: LiveStart): Promise { + const res = await http(live, 'POST', '/api/v1/auth/sign-in/email', '', { email: EMAIL, password: PASSWORD }); + const token = res.body?.token; + if (res.status !== 200 || typeof token !== 'string') { + throw new Error(`auth answered ${res.status}: ${JSON.stringify(res.body)}\n--- output ---\n${live.output().slice(-3000)}`); + } + return token; +} + +/** + * `os package install ./dist/objectstack.json` against the running runtime. + * ⛔ Asynchronous on purpose — see the #21321 sibling: a `spawnSync` stops this + * process draining the server's pipes for the whole install. + */ +function packageInstall(appDir: string, live: LiveStart): Promise<{ exit: number | null; output: string }> { + return new Promise((done) => { + const child = spawn(TSX, [CLI, 'package', 'install', './dist/objectstack.json', '--runtime', live.base, '--email', EMAIL, '--password', PASSWORD], { + cwd: appDir, + env: childEnv({ NO_COLOR: '1' }), + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + child.stdout?.on('data', (d) => { output += String(d); }); + child.stderr?.on('data', (d) => { output += String(d); }); + const timer = setTimeout(() => child.kill('SIGKILL'), 120_000); + child.on('close', (code) => { clearTimeout(timer); done({ exit: code, output }); }); + }); +} + +/** The rows of a `GET /api/v1/data/:object` list answer, whichever envelope it came in. */ +function rowsOf(answer: Answer): any[] { + const b = answer.body?.data ?? answer.body; + if (Array.isArray(b)) return b; + if (Array.isArray(b?.records)) return b.records; + if (Array.isArray(b?.items)) return b.items; + return []; +} + +/** The job service's own record of `job` — the `sys_job` row `IJobService.schedule` upserts. */ +function jobRow(live: LiveStart, token: string, job: string): Promise { + return http(live, 'GET', `/api/v1/data/sys_job?name=${encodeURIComponent(job)}&limit=10`, token); +} + +/** The runs the job service recorded for `job` — one `sys_job_run` row per attempt. */ +function jobRuns(live: LiveStart, token: string, job: string): Promise { + return http(live, 'GET', `/api/v1/data/sys_job_run?job_name=${encodeURIComponent(job)}&limit=500`, token); +} + +/** Wait (bounded) until `job` has recorded a run — a 1-second interval job is read for a while, not once. */ +async function awaitRuns(live: LiveStart, token: string, job: string): Promise { + const deadline = Date.now() + RUN_WAIT_MS; + let answer = await jobRuns(live, token, job); + while (rowsOf(answer).length === 0 && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 500)); + answer = await jobRuns(live, token, job); + } + return answer; +} + +interface InstallRun { exit: number | null; output: string } +const readings: { + goodInstall?: InstallRun; + missingInstall?: InstallRun; + nosourceInstall?: InstallRun; + offInstall?: InstallRun; + installed?: Answer; + goodJob?: Answer; + goodRuns?: Answer; + missingJob?: Answer; + nosourceJob?: Answer; + offJob?: Answer; + legacyBoundJob?: Answer; + legacyBoundRuns?: Answer; + legacyUnboundJob?: Answer; + legacyUnboundRuns?: Answer; + output: Record; +} = { output: {} }; + +beforeAll(async () => { + const root = mkdtempSync(join(tmpdir(), 'install-local-jobs-pull-')); + dirs.push(root); + const write = (dir: string, artifact: unknown) => { + mkdirSync(join(dir, 'dist'), { recursive: true }); + writeFileSync(join(dir, 'dist', 'objectstack.json'), JSON.stringify(artifact, null, 2), 'utf8'); + }; + const apps = { + good: join(root, 'good-app'), + missing: join(root, 'missing-app'), + nosource: join(root, 'nosource-app'), + off: join(root, 'off-app'), + }; + write(apps.good, GOOD_ARTIFACT); + write(apps.missing, MISSING_ARTIFACT); + write(apps.nosource, NOSOURCE_ARTIFACT); + write(apps.off, OFF_ARTIFACT); + + // The runtime boots the HOST artifact — never a package — so the packages + // reach it only through the install, or through the ledger. + const runtimeDir = join(root, 'runtime'); + mkdirSync(runtimeDir, { recursive: true }); + const hostArtifact = join(runtimeDir, 'host.json'); + writeFileSync(hostArtifact, JSON.stringify(HOST_ARTIFACT, null, 2), 'utf8'); + const home = join(runtimeDir, 'home'); + const port = randomPort(); + + // ── boot 1: the host; hot installs ───────────────────────────────────── + const first = await bootStart(runtimeDir, home, port, ['--artifact', hostArtifact]); + const token = await authenticate(first); + readings.missingInstall = await packageInstall(apps.missing, first); + readings.nosourceInstall = await packageInstall(apps.nosource, first); + readings.offInstall = await packageInstall(apps.off, first); + readings.goodInstall = await packageInstall(apps.good, first); + readings.installed = await http(first, 'GET', '/api/v1/marketplace/install-local', token); + readings.goodRuns = await awaitRuns(first, token, GOOD_JOB); + readings.goodJob = await jobRow(first, token, GOOD_JOB); + readings.missingJob = await jobRow(first, token, MISSING_JOB); + readings.nosourceJob = await jobRow(first, token, NOSOURCE_JOB); + readings.offJob = await jobRow(first, token, OFF_JOB); + readings.output.install = first.output(); + await stopGroup(first.child); + + // ── boot 2: same host, home and cwd, plus a ledger entry an earlier build wrote ── + const ledger = join(runtimeDir, '.objectstack', 'installed-packages'); + mkdirSync(ledger, { recursive: true }); + writeFileSync(join(ledger, `${LEGACY_APP_ID}.json`), JSON.stringify(LEGACY_ENTRY, null, 2), 'utf8'); + const second = await bootStart(runtimeDir, home, port, ['--artifact', hostArtifact]); + const token2 = await authenticate(second); + readings.legacyBoundRuns = await awaitRuns(second, token2, LEGACY_BOUND); + readings.legacyBoundJob = await jobRow(second, token2, LEGACY_BOUND); + readings.legacyUnboundJob = await jobRow(second, token2, LEGACY_UNBOUND); + readings.legacyUnboundRuns = await jobRuns(second, token2, LEGACY_UNBOUND); + readings.output.restart = second.output(); + await stopGroup(second.child); +}, 2 * BOOT_TIMEOUT_MS + 4 * 120_000 + 2 * RUN_WAIT_MS); + +afterAll(async () => { + for (const child of groups) await stopGroup(child); + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); +}, 60_000); + +const transcript = (phase: string) => `\n--- ${phase} output ---\n${(readings.output[phase] ?? '').slice(-3000)}`; + +describe('install-local refuses an enabled job whose pull does not bind', () => { + it('a pull job naming a mapping the package does not declare is REFUSED — non-zero exit, the job, the key and the remedy', () => { + const run = readings.missingInstall!; + expect(run.exit, `${run.output}${transcript('install')}`).toBe(1); + // The CLI names the code the runtime answered with. + expect(run.output).toMatch(/Install failed \(422 VALIDATION_ERROR\)/); + expect(run.output).toContain(MISSING_JOB); + expect(run.output).toContain("pull.mapping: this artifact declares no mapping 'orders_pul'"); + expect(run.output).toContain('os validate'); + }); + + it('a pull job whose mapping declares no connectorSource is REFUSED the same way', () => { + const run = readings.nosourceInstall!; + expect(run.exit, `${run.output}${transcript('install')}`).toBe(1); + expect(run.output).toMatch(/Install failed \(422 VALIDATION_ERROR\)/); + expect(run.output).toContain(NOSOURCE_JOB); + expect(run.output).toContain(`mapping '${MAPPING_NAME}' declares no connectorSource`); + }); + + it('a refused package leaves nothing behind: not in the ledger, and its job not scheduled', () => { + const listing = readings.installed!; + expect(listing.status, JSON.stringify(listing.body)).toBe(200); + const ids = JSON.stringify(listing.body); + expect(ids, 'a refused package must leave nothing in the ledger').not.toContain(MISSING_APP_ID); + expect(ids, 'a refused package must leave nothing in the ledger').not.toContain(NOSOURCE_APP_ID); + for (const answer of [readings.missingJob!, readings.nosourceJob!]) { + expect(answer.status, JSON.stringify(answer.body)).toBe(200); + expect(rowsOf(answer)).toEqual([]); + } + }); + + it('control: a pull job naming a declared mapping installs (exit 0) and is scheduled — every run reaches the pull door', () => { + const run = readings.goodInstall!; + expect(run.exit, run.output).toBe(0); + expect(run.output).toMatch(/Package installed into the running kernel/); + expect(JSON.stringify(readings.installed!.body)).toContain(GOOD_APP_ID); + const job = readings.goodJob!; + expect(job.status, JSON.stringify(job.body)).toBe(200); + expect(rowsOf(job).map((r) => r.name), `the control pull job was not scheduled${transcript('install')}`).toEqual([GOOD_JOB]); + const runs = readings.goodRuns!; + expect(runs.status, JSON.stringify(runs.body)).toBe(200); + expect(rowsOf(runs).length, `the control pull job never ran${transcript('install')}`).toBeGreaterThan(0); + }); + + it('a DISABLED pull job naming an undeclared mapping does not block its install, and is not scheduled', () => { + const run = readings.offInstall!; + expect(run.exit, run.output).toBe(0); + expect(JSON.stringify(readings.installed!.body)).toContain(OFF_APP_ID); + expect(rowsOf(readings.offJob!)).toEqual([]); + }); +}); + +describe('rehydrate of an entry an earlier build installed, holding a pull job that does not bind', () => { + it('the bindable pull job of the entry is scheduled and runs', () => { + expect(rowsOf(readings.legacyBoundJob!).map((r) => r.name), `the rehydrated bindable pull job was not scheduled${transcript('restart')}`) + .toEqual([LEGACY_BOUND]); + expect(rowsOf(readings.legacyBoundRuns!).length, `the rehydrated bindable pull job never ran${transcript('restart')}`).toBeGreaterThan(0); + }); + + it('…the unbindable one is withheld — never scheduled, never run', () => { + expect(rowsOf(readings.legacyUnboundJob!), transcript('restart')).toEqual([]); + expect(rowsOf(readings.legacyUnboundRuns!), transcript('restart')).toEqual([]); + }); + + it('…and a warn names it, with the refusal', () => { + const out = readings.output.restart ?? ''; + const line = out.split('\n').find((l) => l.includes(LEGACY_UNBOUND) && l.includes('NOT scheduled')); + expect(line, `no warn names the withheld pull job${transcript('restart')}`).toBeDefined(); + expect(line).toContain("pull.mapping: this artifact declares no mapping 'orders_pul'"); + }); +}); diff --git a/packages/cloud-connection/src/marketplace-install-local-jobs.test.ts b/packages/cloud-connection/src/marketplace-install-local-jobs.test.ts index f883fbaac05..9559db8f6b1 100644 --- a/packages/cloud-connection/src/marketplace-install-local-jobs.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-jobs.test.ts @@ -24,6 +24,12 @@ * it was found — nothing registered, persisted or scheduled; * - #21585: so does an enabled job whose `body` the declaration refuses (an * expression body, a `body.timeoutMs`), naming the refused key; + * - so does an enabled job whose `pull` does not bind (a mapping the package + * does not declare, one with no `connectorSource`), naming the refusal the + * binder's own `judgeJobPull` gives; a pull naming a declared mapping + * installs and is scheduled, a DISABLED unbindable one installs, and an + * entry an earlier build persisted rehydrates with its unbindable pull job + * withheld and warned by name; * - a package without jobs, and one whose handler-only job is DISABLED, * install unchanged. * @@ -190,12 +196,20 @@ async function bootPlugin() { const rawApp = makeRawApp(); const hooks = new Map(); const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + // The `automation` service a pull job's run calls (`IAutomationService.pullConnectorSource`). + const automation = { + pullConnectorSource: vi.fn(async (request: { mapping: string }) => ({ + mapping: request.mapping, targetObject: 'order', connector: 'orders_api', action: 'request', pulled: 0, + summary: { total: 0, processed: 0, created: 0, updated: 0, skipped: 0, errors: 0, ok: 0, cancelled: false }, + })), + }; const services: Record = { manifest: { register }, auth: installerAuthService(), objectql: withInstallerGrants(rec.engine), job: jobs.svc, protocol: registryProtocol(), + automation, }; const ctx = { hook: (e: string, h: any) => hooks.set(e, h), @@ -214,7 +228,7 @@ async function bootPlugin() { rawApp.routes.get('POST /api/v1/marketplace/install-local')!(makeC({ manifest: bundle })); const uninstall = async (manifestId: string) => rawApp.routes.get('DELETE /api/v1/marketplace/install-local/:manifestId')!(makeDeleteC(manifestId)); - return { install, uninstall, rec, jobs, register, logger }; + return { install, uninstall, rec, jobs, register, logger, automation }; } describe('#21489: install-local schedules an installed package’s job bodies', () => { @@ -378,3 +392,109 @@ describe('#21489: install-local refuses an enabled job with no body', () => { expect(jobs.svc.schedule).not.toHaveBeenCalled(); }); }); + +describe('install-local refuses an enabled job whose pull does not bind, by the binder\'s own judgeJobPull', () => { + const MAPPING = { + name: 'orders_pull', + targetObject: TICK, + fieldMapping: [{ source: 'id', target: 'name' }], + mode: 'upsert', + upsertKey: ['name'], + connectorSource: { connector: 'orders_api', action: 'request' }, + }; + const { connectorSource: _dropped, ...IMPORT_ONLY } = MAPPING; + const PULL_JOB = { name: 'jobs_app_pull', schedule: INTERVAL, pull: { mapping: 'orders_pull' } }; + const UNDECLARED = { ...PULL_JOB, name: 'jobs_app_pull_typo', pull: { mapping: 'orders_pul' } }; + + /** The compiled-artifact shape, carrying `mappings` beside `jobs`. */ + const withMappings = (jobs: unknown[], mappings: unknown[] = [MAPPING]) => ({ ...artifact(jobs), mappings }); + + it('a pull naming a mapping the package does not declare answers 422 VALIDATION_ERROR naming the job and the refusal — and changes nothing', async () => { + const { install, jobs, register, automation } = await bootPlugin(); + + const res = await install(withMappings([BODY_JOB, UNDECLARED])); + + expect(res.status).toBe(422); + expect(res.payload.success).toBe(false); + expect(res.payload.error.code).toBe('VALIDATION_ERROR'); + const message: string = res.payload.error.message; + expect(message).toContain(`its enabled job '${UNDECLARED.name}' (pull.mapping: this artifact declares no mapping 'orders_pul'`); + expect(message).toContain('has a `pull` that does not bind'); + expect(message).toContain('os validate'); + // The pull's own clause, never the no-`body` one: a `body` beside a `pull` is refused by the declaration. + expect(message).not.toMatch(/give the job a `body`/i); + // The runtime is left exactly as it was found. + expect(registered(register), 'a refused package must not be registered').toEqual([]); + expect(new LocalManifestSource(dir).read(APP_ID).entry, 'nor persisted').toBeNull(); + expect(jobs.svc.schedule, 'nor any of its jobs scheduled — not even its body job').not.toHaveBeenCalled(); + expect(automation.pullConnectorSource).not.toHaveBeenCalled(); + }); + + it('a pull whose mapping declares no connectorSource is refused the same way', async () => { + const { install, jobs, register } = await bootPlugin(); + + const res = await install(withMappings([PULL_JOB], [IMPORT_ONLY])); + + expect(res.status).toBe(422); + expect(res.payload.error.code).toBe('VALIDATION_ERROR'); + expect(res.payload.error.message).toContain(`its enabled job '${PULL_JOB.name}' (pull.mapping: mapping 'orders_pull' declares no connectorSource`); + expect(registered(register)).toEqual([]); + expect(jobs.svc.schedule).not.toHaveBeenCalled(); + }); + + it('one answer names every kind the door cannot run — the unbindable pull beside a job with no body', async () => { + const { install } = await bootPlugin(); + + const res = await install(withMappings([HANDLER_JOB, UNDECLARED])); + + expect(res.status).toBe(422); + const message: string = res.payload.error.message; + expect(message).toContain(`'${HANDLER_JOB.name}' (handler 'tick') has no \`body\``); + expect(message).toContain(`'${UNDECLARED.name}' (pull.mapping: `); + }); + + it('a DISABLED pull job naming an undeclared mapping does not block the install, and is not scheduled', async () => { + const { install, jobs, register } = await bootPlugin(); + + const res = await install(withMappings([{ ...UNDECLARED, enabled: false }])); + + expect(res.status, JSON.stringify(res.payload)).toBe(200); + expect(registered(register)).toEqual([APP_ID]); + expect(jobs.svc.schedule).not.toHaveBeenCalled(); + }); + + it('control: a pull naming a declared mapping with a connectorSource installs, is scheduled, and a run pulls that mapping', async () => { + const { install, jobs, automation } = await bootPlugin(); + + const res = await install(withMappings([PULL_JOB])); + + expect(res.status, JSON.stringify(res.payload)).toBe(200); + expect([...jobs.scheduled.keys()]).toEqual([PULL_JOB.name]); + await jobs.scheduled.get(PULL_JOB.name)!.run({ jobId: PULL_JOB.name }); + expect(automation.pullConnectorSource).toHaveBeenCalledTimes(1); + expect(automation.pullConnectorSource.mock.calls[0][0]).toMatchObject({ mapping: 'orders_pull' }); + }); + + it('rehydrate — an entry an earlier build persisted with an unbindable pull job: the job is withheld and warned by name, the bindable one scheduled', async () => { + const { manifest: meta, ...sections } = withMappings([PULL_JOB, UNDECLARED]); + new LocalManifestSource(dir).write({ + packageId: APP_ID, + versionId: 'local', + manifestId: APP_ID, + version: '0.1.0', + manifest: { ...meta, ...sections }, + installedAt: '2026-01-01T00:00:00.000Z', + installedBy: 'admin', + withSampleData: false, + }); + + const { jobs, logger, register } = await bootPlugin(); + + expect(registered(register), 'the entry still rehydrates').toEqual([APP_ID]); + expect([...jobs.scheduled.keys()]).toEqual([PULL_JOB.name]); + const warned = logger.warn.mock.calls.find(([message, meta]) => + String(message).includes('NOT scheduled') && (meta as { job?: string } | undefined)?.job === UNDECLARED.name); + expect(warned, 'no warn names the withheld pull job').toBeDefined(); + expect(String(warned![0])).toContain("pull.mapping: this artifact declares no mapping 'orders_pul'"); + }); +}); diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 30d7300b1e5..2e0b6ddeffe 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -166,21 +166,22 @@ const INSTALL_LOCAL_CAPABILITY = 'manage_metadata'; /** * [#21489, #21585] The refusal of a package that declares code this door cannot - * run — an enabled job with no `body` or with a `body` the declaration refuses, - * a hook with no `body`: `VALIDATION_ERROR` / 422. + * run — an enabled job with no `body`, with a `body` the declaration refuses or + * with a `pull` that does not bind, a hook with no `body`: `VALIDATION_ERROR` / + * 422. * * The code is the standard catalog's input-validation member. The condition is * that the install payload fails this door's acceptance rule — every enabled - * job carries a `body` that binds, and every hook carries a `body` — and 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`, + * job carries a `body` or a `pull` that binds, and every hook carries a `body` + * — and 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"). What the author does instead is the prescription * the message carries. `PLUGIN_MANIFEST_INVALID` is deliberately not it: that * code answers the manifest's identity at this door, and a handler-form job or * hook is a valid manifest — `os validate` passes it and `os start --artifact` - * runs it. One acceptance rule answers one code, so the off-spec job `body` - * (which `os validate` does refuse) is answered by the same refusal as the rest - * of the rule rather than splitting it. + * runs it. One acceptance rule answers one code, so the off-spec job `body` and + * the unbindable `pull` (which `os validate` does refuse) are answered by the + * same refusal as the rest of the rule rather than splitting it. * * The status is 422, not the 400 / 502 split this door uses for an invalid * manifest id: the package is well-formed JSON this door cannot process, which @@ -193,7 +194,7 @@ const UNRUNNABLE_REFUSAL_STATUS = 422; /** What this door cannot run in a package, as the runtime binder judges it. */ interface UnrunnableCode { - jobs: ReadonlyArray<{ name: string; handler?: string; bodyRefusal?: string }>; + jobs: ReadonlyArray<{ name: string; handler?: string; bodyRefusal?: string; pullRefusal?: string }>; hooks: ReadonlyArray<{ name: string; handler?: string }>; } @@ -207,14 +208,16 @@ const capitalize = (text: string) => text.charAt(0).toUpperCase() + text.slice(1 /** * The refusal sentence: everything the door cannot run, why, and the remedies, * one clause per kind — a job with no `body`, a job whose `body` the - * declaration refuses, a hook with no `body` — in one answer, so the author - * fixes them all in one pass. Each names the item and the function its - * `handler` declares (or the declaration's refusal of its `body`). + * declaration refuses, a job whose `pull` does not bind, a hook with no `body` + * — in one answer, so the author fixes them all in one pass. Each names the + * item and the function its `handler` declares (or the refusal of its `body` or + * its `pull`). */ function describeUnrunnable(manifestId: string, what: UnrunnableCode): string { const clauses: string[] = []; - const jobsWithoutBody = what.jobs.filter((j) => j.bodyRefusal === undefined); - const jobsWithBadBody = what.jobs.filter((j) => j.bodyRefusal !== undefined); + const jobsWithBadPull = what.jobs.filter((j) => j.pullRefusal !== undefined); + const jobsWithoutBody = what.jobs.filter((j) => j.pullRefusal === undefined && j.bodyRefusal === undefined); + const jobsWithBadBody = what.jobs.filter((j) => j.pullRefusal === undefined && j.bodyRefusal !== undefined); if (jobsWithoutBody.length > 0) { const one = jobsWithoutBody.length === 1; clauses.push( @@ -236,6 +239,16 @@ function describeUnrunnable(manifestId: string, what: UnrunnableCode): string { + "body; the job's time limit is the job's own `timeoutMs`) — `os validate` reports the same refusal.", ); } + if (jobsWithBadPull.length > 0) { + const one = jobsWithBadPull.length === 1; + const list = jobsWithBadPull.map((j) => `'${j.name}' (${j.pullRefusal})`).join('; '); + clauses.push( + `${one ? 'its enabled job' : `${jobsWithBadPull.length} of its enabled jobs`} ${list} ` + + `${one ? 'has' : 'have'} a \`pull\` that does not bind, so this install door cannot run ${one ? 'it' : 'them'}: ` + + 'the job would be installed and never scheduled. Declare the mapping the `pull` names in the package, with ' + + 'a `connectorSource`, or correct the `pull` as the refusal says — `os validate` refuses the same `pull`.', + ); + } if (what.hooks.length > 0) { const one = what.hooks.length === 1; clauses.push( @@ -971,7 +984,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin { // - a hook with no `body`: its function-name `handler` can never // name the package's own code on this door, so it installed and // either never fired or bound by name to code the package does - // not ship. + // not ship; + // - a job whose `pull` does not bind (a mapping the package does + // not declare, one with no `connectorSource`): judged by the + // binder's own `judgeJobPull` — it used to install and never be + // scheduled. // The judgements are the runtime binder's own, so the door and the // binder cannot disagree about what this door can run. // @@ -982,8 +999,9 @@ export class MarketplaceInstallLocalPlugin implements Plugin { // a disabled one is never scheduled on any door; every hook is // judged, since a hook has no on/off switch. ⛔ Rehydrate is not // gated, for the id gate's reason: an entry an older build installed - // still rehydrates — its unrunnable job is reported, not run, and its - // hook with no `body` is warned and NOT bound + // still rehydrates — its unrunnable job is reported, not run (an + // unbindable `pull` is warned by the binder and NOT scheduled), and + // its hook with no `body` is warned and NOT bound // ({@link bindArtifactHandlers}). const unrunnable = await this.unrunnableCode(ctx, manifest, manifestId); if (unrunnable.jobs.length > 0 || unrunnable.hooks.length > 0) { @@ -1834,9 +1852,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin { * [#21489, #21585] The code in `manifest` this door cannot run, which the * install route refuses: * - * - the enabled jobs with no `body`, or with a `body` the declaration - * refuses (`collectJobsWithoutBody`, which reads the jobs the - * binder schedules and judges a body by the parse the binder binds by); + * - the enabled jobs with no `body`, with a `body` the declaration + * refuses, or with a `pull` that does not bind + * (`collectJobsWithoutBody`, which reads the jobs the binder schedules, + * judges a body by the parse the binder binds by and a pull by the + * binder's own `judgeJobPull`); * - the hooks with no `body` (`collectHooksWithoutBody`, the judgement the * binder withholds by on this door's rehydrate). * @@ -1860,7 +1880,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { if (!collectJobs) { ctx.logger?.warn?.( `[MarketplaceInstallLocal] this runtime has no collectJobsWithoutBody — the jobs of ${manifestId} are not judged, ` - + 'so a job with no runnable `body` installs and is never run. Upgrade @objectstack/runtime alongside @objectstack/cloud-connection.', + + 'so a job with no runnable `body` or `pull` installs and is never run. Upgrade @objectstack/runtime alongside @objectstack/cloud-connection.', ); } if (!collectHooks) { diff --git a/packages/runtime/src/app-artifact-handlers.job-pull.test.ts b/packages/runtime/src/app-artifact-handlers.job-pull.test.ts index 2223ac510a1..534eeb84576 100644 --- a/packages/runtime/src/app-artifact-handlers.job-pull.test.ts +++ b/packages/runtime/src/app-artifact-handlers.job-pull.test.ts @@ -17,7 +17,11 @@ * - a pull that does not bind (a mapping the artifact does not declare, one * with no `connectorSource`, code beside it) and a composition with no pull * door are NOT scheduled, and the reason is said; - * - `collectJobsWithoutBody` never names a pull job — it is data. + * - `collectJobsWithoutBody` — what the install-local door refuses by — names + * an enabled pull job exactly when the binder's `judgeJobPull` does not + * bind it, with that refusal as its `pullRefusal`, and never a pull job that + * binds: every pull job it names is one the binder does not schedule, and + * every enabled pull job it does not name the binder schedules. * * Q2-O1: a job declares the organization it runs as, judged at bind by the * scheduled flows' posture rule (`resolveScheduledWorkPolicy`). Pinned here: @@ -231,13 +235,62 @@ describe('#20281 stage ③ (Q1-B): a job pulls a mapping by declaration, through expect(h.defaultAutomation.pullConnectorSource).not.toHaveBeenCalled(); }); - it('collectJobsWithoutBody never names a pull job — the pull is data, like a body (a handler job beside it is, control)', () => { - const named = collectJobsWithoutBody({ - id: APP_ID, - jobs: [PULL_JOB, { ...PULL_JOB, name: 'unbound_pull', pull: { mapping: 'nope' } }, { name: 'fn_job', schedule: INTERVAL, handler: 'sweep' }], - mappings: [MAPPING], +}); + +describe('collectJobsWithoutBody judges a pull job by the binder\'s own judgeJobPull', () => { + withPosture(undefined); + + const { connectorSource: _dropped, ...IMPORT_ONLY } = { ...MAPPING, name: 'orders_import' }; + const BODY = { language: 'js', capabilities: ['api.write'], source: "await ctx.api.object('order').insert({});" }; + const JOBS = [ + PULL_JOB, + { ...PULL_JOB, name: 'undeclared_pull', pull: { mapping: 'orders_pul' } }, + { ...PULL_JOB, name: 'sourceless_pull', pull: { mapping: 'orders_import' } }, + { ...PULL_JOB, name: 'pull_beside_body', body: BODY }, + { ...PULL_JOB, name: 'disabled_undeclared_pull', pull: { mapping: 'orders_pul' }, enabled: false }, + { name: 'fn_job', schedule: INTERVAL, handler: 'sweep' }, + ]; + const bundle = { id: APP_ID, version: '0.1.0', type: 'app', jobs: JOBS, mappings: [MAPPING, IMPORT_ONLY] }; + + it('names each ENABLED pull job that does not bind, with its pullRefusal — never as a job with no body; a pull that binds is not named', () => { + const named = collectJobsWithoutBody(bundle); + + expect(named.map((j) => j.name)).toEqual(['undeclared_pull', 'sourceless_pull', 'pull_beside_body', 'fn_job']); + const byName = new Map(named.map((j) => [j.name, j])); + expect(byName.get('undeclared_pull')).toEqual({ + name: 'undeclared_pull', + pullRefusal: expect.stringMatching(/^pull\.mapping: this artifact declares no mapping 'orders_pul'/), + }); + expect(byName.get('sourceless_pull')).toEqual({ + name: 'sourceless_pull', + pullRefusal: expect.stringMatching(/^pull\.mapping: mapping 'orders_import' declares no connectorSource/), }); - expect(named.map((j) => j.name)).toEqual(['fn_job']); + // Code beside the `pull` is the pull's refusal — judged before the body + // beside it, as the binder judges it — never a `bodyRefusal`. + expect(byName.get('pull_beside_body')).toEqual({ + name: 'pull_beside_body', + pullRefusal: expect.stringMatching(/^pull: the job declares `body` or `handler` beside `pull`/), + }); + // Control: a job with no run form at all is still named with neither refusal. + expect(byName.get('fn_job')).toEqual({ name: 'fn_job', handler: 'sweep' }); + }); + + it('the door and the binder agree: every pull job named is NOT scheduled, with the same refusal said; every enabled pull job not named IS', async () => { + const h = harness(); + + const named = collectJobsWithoutBody(bundle).filter((j) => j.pullRefusal !== undefined); + const out = await scheduleAppArtifactJobs( + { logger: h.logger, getService: (n: string) => h.services[n] } as unknown as PluginContext, + bundle, + { appId: APP_ID, ql: undefined, source: 'Test' }, + ); + + expect(out.pulls).toEqual(['orders_pull_hourly']); + expect(named.map((j) => j.name).sort()).toEqual([...out.notScheduled].filter((n) => n !== 'fn_job').sort()); + // One judge: the refusal the door answers with is the sentence the binder logs when it withholds the job. + for (const job of named) { + expect(h.said('warn').some((m) => m.endsWith(job.pullRefusal!)), `no binder warn carries ${job.name}'s refusal`).toBe(true); + } }); }); diff --git a/packages/runtime/src/app-artifact-handlers.ts b/packages/runtime/src/app-artifact-handlers.ts index 99edff5912f..d207bdb1656 100644 --- a/packages/runtime/src/app-artifact-handlers.ts +++ b/packages/runtime/src/app-artifact-handlers.ts @@ -70,8 +70,9 @@ * code: it travels in the artifact's runtime module, which only `os start * --artifact` loads, so no JSON door can ever resolve it. * {@link collectJobsWithoutBody} names those jobs — and the ones whose `body` - * the declaration refuses (`judgeJobBody`) — and the install-local install route - * refuses a package that declares one enabled. + * the declaration refuses (`judgeJobBody`), and the ones whose `pull` does not + * bind ({@link judgeJobPull}) — and the install-local install route refuses a + * package that declares one enabled. * * ## The pull run form, and the organization a job runs as (#20281 stage ③) * @@ -83,7 +84,9 @@ * the service registry. A refused pull rejects the run (`failed`, retried per * `retryPolicy`); a pull whose rows the import runner refused resolves * `degraded` ({@link pullRunOutcomeOf}). It is data, like a `body`, so - * {@link collectJobsWithoutBody} never names it. + * {@link collectJobsWithoutBody} names it only when {@link judgeJobPull} does + * not bind it — a job the binder would never schedule, which the install-local + * install route refuses, as it refuses a `body` that does not bind. * * `JobSchema.organization` (ruling Q2-O1) is the organization a job runs as — * its `body`, its `handler` and its `pull` alike — judged here, at bind, by the @@ -332,8 +335,9 @@ export function bindAppArtifactHandlers( /** * An enabled job a JSON door cannot run: it carries no `body` — or, since * #21585, a `body` that does not BIND (the declaration refuses it: an expression - * body, or one carrying `body.timeoutMs`), which no door can run either. "Without - * body" reads as "without a body that runs". Its `handler` (deprecated) names a + * body, or one carrying `body.timeoutMs`), which no door can run either — or a + * `pull` that does not bind ({@link judgeJobPull}). "Without body" reads as + * "without a run form that runs". Its `handler` (deprecated) names a * `defineStack({ functions })` entry — code, which a JSON artifact never * carries (ADR-0088) — or it names nothing at all. */ @@ -348,36 +352,52 @@ export interface JobWithoutBody { * `body` at all. */ bodyRefusal?: string; + /** + * Set when the job declares `pull` and it does not bind: the refusal + * {@link judgeJobPull} gives, the judgement the binder schedules by. Absent + * for a job that declares no `pull`; a job carrying it carries no + * `bodyRefusal`, since a `pull` is judged before any `body` beside it. + */ + pullRefusal?: string; } /** * The enabled jobs of an artifact that no JSON door can schedule (#21489): - * those with no `body`, and (#21585) those whose `body` does not BIND — an + * those with no `body`, (#21585) those whose `body` does not BIND — an * expression (L1) body, or one carrying `body.timeoutMs`, or any other shape - * the declaration refuses. That second half is the judgement the binder's own - * {@link jobBodyRunnerFactory} makes ({@link judgeJobBody}, a parse against - * `JobSchema.body`), so the door and the binder cannot disagree; such a job is - * named with its `bodyRefusal`. The install-local install route refuses a - * package that declares one; see the module header. + * the declaration refuses — and those whose `pull` does not bind. The second + * half is the judgement the binder's own {@link jobBodyRunnerFactory} makes + * ({@link judgeJobBody}, a parse against `JobSchema.body`), and the third is + * the binder's own {@link judgeJobPull}, so the door and the binder cannot + * disagree; such a job is named with its `bodyRefusal` or its `pullRefusal`. + * The install-local install route refuses a package that declares one; see the + * module header. * * Reads the jobs the binder reads ({@link collectBundleJobs}), and calls a job * enabled exactly when the binder does: `enabled: false` is the one value that * disables it (the schema's default is `true`). * - * A job declaring `pull` is never named (#20281 stage ③): the pull run form is - * data, like a `body`, and binds on every door. Whether its pull binds is - * {@link judgeJobPull}'s question, answered by the binder at bind — named here - * it would read as "has no `body`" and send the author to write one beside the - * pull, which the declaration refuses. + * A job declaring `pull` (#20281 stage ③) is judged as the binder judges it, + * first and by the same function: {@link judgeJobPull} against this artifact. A + * pull that binds is data, like a `body`, and binds on every door, so the job is + * not named. A pull that does not bind — a mapping the artifact does not + * declare, one with no `connectorSource`, code beside the `pull` — is a job the + * binder never schedules, so it is named with its `pullRefusal` and never + * with a missing `body`, which would send the author to write one beside the + * `pull`, the shape the declaration refuses. */ export function collectJobsWithoutBody(bundle: unknown): JobWithoutBody[] { const out: JobWithoutBody[] = []; for (const job of collectBundleJobs(bundle)) { if (!job || typeof job !== 'object') continue; if (job.enabled === false) continue; - if (job.pull !== undefined) continue; let bodyRefusal: string | undefined; - if (job.body) { + let pullRefusal: string | undefined; + if (job.pull !== undefined) { + const judged = judgeJobPull(job, bundle); + if (judged.binds) continue; + pullRefusal = judged.refusal; + } else if (job.body) { const judged = judgeJobBody(job.body); if (judged.binds) continue; bodyRefusal = judged.refusal; @@ -386,6 +406,7 @@ export function collectJobsWithoutBody(bundle: unknown): JobWithoutBody[] { name: typeof job.name === 'string' ? job.name : String(job.name), ...(typeof job.handler === 'string' ? { handler: job.handler } : {}), ...(bodyRefusal !== undefined ? { bodyRefusal } : {}), + ...(pullRefusal !== undefined ? { pullRefusal } : {}), }); } return out; @@ -418,7 +439,9 @@ function collectBundleMappings(bundle: unknown): Array> /** * Does a job's `pull` bind on this artifact? The ONE judgement, read by the - * binder ({@link scheduleAppArtifactJobs}) before it schedules a pull job. + * binder ({@link scheduleAppArtifactJobs}) before it schedules a pull job, and + * by {@link collectJobsWithoutBody}, whose answer the install-local install + * route refuses by — so the door and the binder cannot disagree. * * It binds when the job declares no code beside it (the declaration refuses * `pull` + `body` / `handler`), `pull` parses against `JobSchema.pull`, and the @@ -641,7 +664,9 @@ export interface AppArtifactJobScheduling { * `automation` service serves `pullConnectorSource`; each run calls that * contract method through the service registry under the job's execution * context, and maps the result with {@link pullRunOutcomeOf}. A pull that - * does not bind schedules nothing (warn); + * does not bind schedules nothing (warn) — install-local refuses that + * shape up front ({@link collectJobsWithoutBody}), so on that door this + * fires only on the rehydrate of an entry an earlier build installed; * - a `body` → the sandboxed body (`jobBodyRunnerFactory`), and the `body` * WINS when a `handler` is declared beside it. A body that cannot be bound * (wrong shape, a `body.timeoutMs`) schedules nothing — never the handler diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 4efef8b99a7..84138defe7f 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -69,8 +69,8 @@ export { AppPlugin, collectBundleHooks, collectBundleFunctions, collectBundleFun // install-local plugin (`@objectstack/cloud-connection`) on install and rehydrate. // [#21489] …and its job half: `scheduleAppArtifactJobs` schedules a package's // jobs (a `body` runs sandboxed on every door), and `collectJobsWithoutBody` -// names the enabled jobs no JSON door can run (no `body`, or one that does not -// bind), which install-local refuses. +// names the enabled jobs no JSON door can run (no `body`, or a `body` or `pull` +// that does not bind), which install-local refuses. // [#21585] `collectHooksWithoutBody` names the hooks whose code is only a // function-name `handler`: install-local refuses them, and withholds them on a // rehydrate (`withholdHooksWithoutBody`).