From a9c4726622f9c6c731ed1b2ffc145ea9a14bba12 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 06:52:48 +0000 Subject: [PATCH 1/3] fix(example-showcase): take the sweep's engine off its JobHandlerContext argument The nightly `showcase_health_sweep` handler held its engine in a module-scope `let host` that `onEnable` filled via an exported `bindShowcaseJobRuntime`. That binding does not exist on the artifact path: `objectstack build` emits `functions` into a sibling runtime module exporting only `{ functions, meta }`, the artifact JSON carries no `onEnable`, and `mergeRuntimeModule` merges only `functions` -- so on an artifact-served boot the handle stayed `undefined` and the sweep recomputed nothing while reporting a clean run. Take `jobId` / `ql` / `logger` off the handler's `JobHandlerContext` argument (#14094) and delete the binding seam entirely: the module-scope `host`, the `JobHostContext` / `JobHostEngine` local interfaces, `bindShowcaseJobRuntime`, its re-export and its `onEnable` call. `effect: 'writes'` on the `functions` entry is unchanged -- that declaration is about who counts the writes, not about where the handle comes from. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- examples/app-showcase/objectstack.config.ts | 28 +++-- .../app-showcase/src/automation/jobs/index.ts | 2 +- .../automation/jobs/sweep-project-health.ts | 103 ++++++++---------- 3 files changed, 62 insertions(+), 71 deletions(-) diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index 5b4b169fd8..7e8a4e5ec0 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -30,7 +30,7 @@ import { CapabilityMapPage, StartHerePage, ComponentGalleryPage, ProjectWorkspac import { allFlows } from './src/automation/flows/index.js'; import { allWebhooks } from './src/automation/webhooks/index.js'; import { allHooks } from './src/data/hooks/index.js'; -import { allJobs, sweepProjectHealth, bindShowcaseJobRuntime } from './src/automation/jobs/index.js'; +import { allJobs, sweepProjectHealth } from './src/automation/jobs/index.js'; import { allEmails } from './src/system/emails/index.js'; import { allBooks } from './src/system/books/index.js'; import { allApis } from './src/system/apis/index.js'; @@ -220,10 +220,19 @@ export default defineStack({ // A JOB handler resolves through this same map (`collectBundleFunctions`), so // `sweepProjectHealth` — the handler `HealthSweepJob` names — lives here too. // It is the case the pure contract does not cover: a nightly sweep has no - // downstream declarative node to persist for it, so it writes over an engine - // handle captured at `onEnable`. That is why it is spelled the DECLARED way - // (#4396) — an undeclared writer is counted as having written nothing, which - // is indistinguishable from the broken sweep #4354 exists to detect. + // downstream declarative node to persist for it, so it writes over the `ql` + // handle on its own `JobHandlerContext` argument (#14094). That is why it is + // spelled the DECLARED way (#4396) — an undeclared writer is counted as + // having written nothing, which is indistinguishable from the broken sweep + // #4354 exists to detect. The declaration is about who counts the writes, not + // about where the handle comes from, so it stands unchanged now that the + // handle arrives in the argument. + // + // ⛔ It is NOT reached through an `onEnable` binding any more (#14257). This + // map is the ONLY thing `objectstack build` emits into the runtime module and + // the only thing `mergeRuntimeModule` merges back, so a handler that needed + // `onEnable` to have run first was inert on every artifact-served boot — on + // schedule, silently, reported as a clean run. // // This entry authored the bare form until #4976, not because the bare form was // right but because the declared one could not survive `objectstack build`: @@ -292,9 +301,8 @@ export const onEnable = async (ctx: unknown): Promise => { // real pending requests land in the inbox (cannot be a seed — see // seed-approval-demo.ts). registerShowcaseApprovalDemo(ctx as Parameters[0]); - // Hand the nightly health-sweep job its data handle. A job handler is invoked - // by the job service with `{ jobId, data }` and no engine (flow functions are - // pure by default, #4396), so `onEnable` — the one place the app is handed a - // live engine — is where the sweep gets one. - bindShowcaseJobRuntime(ctx as Parameters[0]); + // ⛔ Nothing here hands the nightly health-sweep job a data handle any more + // (#14257). It takes `ql` and `logger` off its own `JobHandlerContext` + // argument (#14094) — the only route that survives an artifact-served boot, + // which carries no `onEnable` at all. See `src/automation/jobs/`. }; diff --git a/examples/app-showcase/src/automation/jobs/index.ts b/examples/app-showcase/src/automation/jobs/index.ts index 5648191d98..2bef8c5117 100644 --- a/examples/app-showcase/src/automation/jobs/index.ts +++ b/examples/app-showcase/src/automation/jobs/index.ts @@ -2,7 +2,7 @@ import { defineJob } from '@objectstack/spec'; -export { sweepProjectHealth, bindShowcaseJobRuntime, healthFor } from './sweep-project-health.js'; +export { sweepProjectHealth, healthFor } from './sweep-project-health.js'; /** * Nightly job — recompute project health. diff --git a/examples/app-showcase/src/automation/jobs/sweep-project-health.ts b/examples/app-showcase/src/automation/jobs/sweep-project-health.ts index 73c11a82b8..cd757ba84d 100644 --- a/examples/app-showcase/src/automation/jobs/sweep-project-health.ts +++ b/examples/app-showcase/src/automation/jobs/sweep-project-health.ts @@ -20,22 +20,41 @@ * "never advertise a capability the runtime doesn't deliver" (AGENTS.md Prime * Directive #10) cuts both ways. * - * ## Why the engine handle is captured rather than passed in + * ## Where the engine comes from — the ARGUMENT, never a module-scope handle * * A job handler is resolved through the SAME `defineStack({ functions })` * registry as a `script` flow node (`collectBundleFunctions` in - * `@objectstack/runtime`), and the job service invokes it with - * `{ jobId, data }` — `IJobService`'s `JobHandler` context — plus the `bundle` - * the AppPlugin adds. There is deliberately no data engine in that context: a - * flow function is PURE by default, returning a value a later declarative node - * persists (#4343 / #4396). - * - * A background job is the case that contract does not cover — nothing - * downstream is going to persist for it — so it does its own I/O over a handle - * captured at `onEnable`, and DECLARES that in the `functions` map with - * `effect: 'writes'` (#4396). That declaration grants nothing; it tells the - * platform this callable's writes are not counted by the caller, so a run - * reports "cannot say" instead of silently claiming it wrote nothing. + * `@objectstack/runtime`), and a `script` node's context deliberately carries + * no data engine: a flow function is PURE, returning a value a later + * declarative node persists (#4343 / #4396). + * + * A JOB is the case that contract does not cover — it has no graph, so no node + * before it reads and none after it persists. Since #14094 the AppPlugin + * therefore invokes a job's `functions` entry with a `JobHandlerContext`: + * `{ jobId, data, bundle }` widened with `ql` (the same engine handle + * `defineStack({ onEnable })` receives) and `logger`. This handler takes both + * from that argument, which is the only route that survives the shipped + * deployment path. + * + * ⛔ The shape this file used to have — `onEnable` filling a module-scope + * `let host` the handler read later — does NOT survive a built artifact, and + * fails silently rather than loudly. `objectstack build` emits `functions` into + * a sibling runtime module exporting only `{ functions, meta }`; the artifact + * JSON carries no `onEnable`, and `mergeRuntimeModule` + * (`packages/runtime/src/load-artifact-bundle.ts`) merges only `functions`. So + * on an artifact-served boot the binding was never made, the handle stayed + * `undefined`, and `showcase_health_sweep` fired on schedule, recomputed + * nothing, and reported a clean run (#14257). The pin against a return is in + * `test/inert-wirings.test.ts`, which reaches this handler through its + * `functions` entry — the one thing an artifact carries — with no `onEnable` + * anywhere in the test. + * + * The entry still DECLARES `effect: 'writes'` in the `functions` map (#4396), + * and taking `ql` from the argument does not change that: the declaration was + * never about where the handle came from. A job's writes are counted by no + * caller — there is no downstream declarative node to count them — so + * undeclared, a run reports having written nothing instead of "cannot say", + * which is indistinguishable from the broken sweep #4354 exists to detect. * * ## What it computes * @@ -56,6 +75,8 @@ * so a steady-state sweep performs zero updates. */ +import type { JobHandlerContext } from '@objectstack/runtime'; + /** Statuses whose health is still in play. */ const SWEPT_STATUSES = ['active', 'on_hold'] as const; @@ -71,36 +92,6 @@ const SYS = { isSystem: true } as const; type Health = 'green' | 'yellow' | 'red'; -interface JobHostEngine { - find: (object: string, query: unknown, options?: unknown) => Promise; - update: (object: string, data: Record, options?: unknown) => Promise; -} - -interface JobHostContext { - ql: JobHostEngine; - logger?: { - info?: (...a: unknown[]) => void; - warn?: (...a: unknown[]) => void; - }; -} - -/** - * The engine handle the job runs over, captured from the host context at - * `onEnable`. Module scope is what makes it reachable from a `functions` entry, - * which the job service calls with no context of its own — the "closed over a - * client at module scope" shape `effect: 'writes'` exists to declare. - */ -let host: JobHostContext | undefined; - -/** - * Give `sweepProjectHealth` its data handle. Called from `onEnable` in - * `objectstack.config.ts`, which is the one place the app is handed a live - * engine. Idempotent — a re-enable simply rebinds. - */ -export function bindShowcaseJobRuntime(ctx: JobHostContext): void { - host = ctx; -} - /** Normalize the engine's list shape (array, or `{ records }`). */ function rowsOf(result: unknown): Array> { if (Array.isArray(result)) return result as Array>; @@ -120,7 +111,7 @@ function num(value: unknown): number | undefined { /** * The health verdict for one project — exported so the rule is unit-testable - * without an engine (see `test/job-health-sweep.test.ts`). + * without an engine (see `test/inert-wirings.test.ts`). */ export function healthFor(input: { budget?: unknown; @@ -148,20 +139,12 @@ export function healthFor(input: { * * Registered as `functions.sweepProjectHealth` with `effect: 'writes'` and * scheduled by `HealthSweepJob` (`0 1 * * *` UTC). + * + * `jobId`, `ql` and `logger` all come off the `JobHandlerContext` the AppPlugin + * builds per run — there is no binding step, so there is no boot path on which + * this handler can be reached without them. */ -export async function sweepProjectHealth(ctx?: { jobId?: string }): Promise { - const jobId = ctx?.jobId ?? 'showcase_health_sweep'; - if (!host) { - // Reached only if the job somehow fires before `onEnable` bound the - // handle. Functional degradation, not a durability one: nothing claimed to - // be persisted has been lost, and the next scheduled run recomputes - // everything from scratch (AGENTS.md "Degradation log levels"). - // eslint-disable-next-line no-console - console.warn(`[showcase] ${jobId}: no engine handle bound yet — skipping this run`); - return; - } - const { ql, logger } = host; - +export async function sweepProjectHealth({ jobId, ql, logger }: JobHandlerContext): Promise { const projects = rowsOf( await ql.find('showcase_project', { where: { status: { $in: [...SWEPT_STATUSES] } }, @@ -171,7 +154,7 @@ export async function sweepProjectHealth(ctx?: { jobId?: string }): Promise Date: Wed, 2 Sep 2026 07:08:57 +0000 Subject: [PATCH 2/3] test(example-showcase): pin the sweep on an artifact-shaped boot Swap the handler test's fake HOST for a fake CONTEXT: `jobRun()` builds the `JobHandlerContext` `AppPlugin` passes, with `ql` declared `satisfies` the contract's own member types so a drift in `IDataEngine.find`/`.update` reds here rather than being absorbed by a hand-written approximation. Adds the case this card is about: reach the handler through its `functions` entry -- the only thing `objectstack build` emits and `mergeRuntimeModule` merges -- with no `onEnable` called anywhere in the file, and assert it still reads and recomputes. Plus two guards that the seam cannot return: the jobs module exports no `bindShowcaseJobRuntime`, and no authored source (comments stripped) carries the binding seam or its "no engine handle bound yet" branch. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- .../app-showcase/test/inert-wirings.test.ts | 172 +++++++++++++----- 1 file changed, 128 insertions(+), 44 deletions(-) diff --git a/examples/app-showcase/test/inert-wirings.test.ts b/examples/app-showcase/test/inert-wirings.test.ts index 8c1b8f1c22..0c5488bc1f 100644 --- a/examples/app-showcase/test/inert-wirings.test.ts +++ b/examples/app-showcase/test/inert-wirings.test.ts @@ -14,7 +14,13 @@ import { stripComments } from '../../../scripts/js-comment-mask.mjs'; import stack from '../objectstack.config.js'; import { PLATFORM_CAPABILITY_NAMES } from '@objectstack/spec/security'; import { FILE_REFERENCE_TYPES, valueSchemaFor } from '@objectstack/spec/data'; -import { healthFor, sweepProjectHealth, bindShowcaseJobRuntime } from '../src/automation/jobs/index.js'; +// The job handler's argument type, from the package that BUILDS it +// (`AppPlugin`) — not a local re-description of it. A fake context typed to a +// hand-written approximation is how a double drifts looser than the contract it +// replaces; this one is checked against the real member types below. +import type { JobHandlerContext } from '@objectstack/runtime'; +import { healthFor, sweepProjectHealth } from '../src/automation/jobs/index.js'; +import * as jobsModule from '../src/automation/jobs/index.js'; import { POSITION_PERMISSION_SET_BINDINGS } from '../src/security/bind-position-sets.js'; import { ADMIN_EMAIL, @@ -126,6 +132,55 @@ describe('declarative jobs resolve their handler (#4774 ①)', () => { }); }); +/** + * One job run's argument, as `AppPlugin` builds it (`JobHandlerContext`, + * #14094), with the two engine verbs the sweep uses recorded. + * + * There is no binding step to fake — that is the whole of #14257 — so this is + * the ONLY way the handler is reachable from here, on either boot path. + * + * `ql` is declared `satisfies` the CONTRACT's own member types rather than a + * local interface, so a drift in `IDataEngine.find`/`.update` reds here instead + * of being absorbed by a hand-written approximation of them. The cast that + * follows widens only to the engine members this handler never touches. + */ +function jobRun(rows: { + projects?: Array>; + tasks?: Array>; +}) { + const reads: Array<{ object: string; query: any }> = []; + const writes: Array> = []; + const logged: Array<{ level: string; message: string }> = []; + + const ql = { + find: async (object: string, query?: any) => { + reads.push({ object, query }); + if (object === 'showcase_project') return rows.projects ?? []; + if (object === 'showcase_task') return rows.tasks ?? []; + return []; + }, + update: async (_object: string, data: any) => { + writes.push(data as Record); + return data; + }, + } satisfies Partial; + + const logger = { + debug: (message: string) => void logged.push({ level: 'debug', message }), + info: (message: string) => void logged.push({ level: 'info', message }), + warn: (message: string) => void logged.push({ level: 'warn', message }), + error: (message: string) => void logged.push({ level: 'error', message }), + } satisfies JobHandlerContext['logger']; + + const context: JobHandlerContext = { + jobId: 'showcase_health_sweep', + bundle: stack, + ql: ql as unknown as JobHandlerContext['ql'], + logger, + }; + return { context, reads, writes, logged }; +} + describe('sweepProjectHealth computes health from burn vs progress (#4774 ①)', () => { it('is green when spending tracks delivery', () => { expect(healthFor({ budget: 150_000, spent: 60_000, taskProgress: [100, 80, 45, 0, 0] })).toBe('green'); @@ -154,62 +209,91 @@ describe('sweepProjectHealth computes health from burn vs progress (#4774 ①)', }); it('sweeps only in-play projects and writes only what changed', async () => { - const projects = [ - { id: 'p_active', status: 'active', health: 'green', budget: 90_000, spent: 88_000 }, - { id: 'p_hold', status: 'on_hold', health: 'red', budget: 100_000, spent: 10_000 }, - ]; - const tasks = [ - { project: 'p_active', progress: 0 }, - { project: 'p_active', progress: 0 }, - { project: 'p_hold', progress: 90 }, - ]; - const reads: Array<{ object: string; query: any }> = []; - const writes: Array> = []; - - bindShowcaseJobRuntime({ - ql: { - find: async (object: string, query: any) => { - reads.push({ object, query }); - if (object === 'showcase_project') return projects; - if (object === 'showcase_task') return tasks; - return []; - }, - update: async (_object: string, data: Record) => { - writes.push(data); - return data; - }, - }, + const run = jobRun({ + projects: [ + { id: 'p_active', status: 'active', health: 'green', budget: 90_000, spent: 88_000 }, + { id: 'p_hold', status: 'on_hold', health: 'red', budget: 100_000, spent: 10_000 }, + ], + tasks: [ + { project: 'p_active', progress: 0 }, + { project: 'p_active', progress: 0 }, + { project: 'p_hold', progress: 90 }, + ], }); - await sweepProjectHealth({ jobId: 'showcase_health_sweep' }); + await sweepProjectHealth(run.context); // Only `active` / `on_hold` are read — settled projects are not relitigated. - expect(reads[0]?.object).toBe('showcase_project'); - expect(reads[0]?.query?.where?.status?.$in).toEqual(['active', 'on_hold']); + expect(run.reads[0]?.object).toBe('showcase_project'); + expect(run.reads[0]?.query?.where?.status?.$in).toEqual(['active', 'on_hold']); // p_active: burn 0.978 vs done 0 → red (changed from green). // p_hold: burn 0.10 vs done 0.90 → green (changed from red). - expect(writes).toEqual([ + expect(run.writes).toEqual([ { id: 'p_active', health: 'red' }, { id: 'p_hold', health: 'green' }, ]); }); it('is a no-op when nothing changed', async () => { - const writes: unknown[] = []; - bindShowcaseJobRuntime({ - ql: { - find: async (object: string) => - object === 'showcase_project' - ? [{ id: 'p1', status: 'active', health: 'green', budget: 100, spent: 10 }] - : [{ project: 'p1', progress: 100 }], - update: async (_o: string, d: Record) => { - writes.push(d); - return d; - }, - }, + const run = jobRun({ + projects: [{ id: 'p1', status: 'active', health: 'green', budget: 100, spent: 10 }], + tasks: [{ project: 'p1', progress: 100 }], }); - await sweepProjectHealth({ jobId: 'showcase_health_sweep' }); - expect(writes).toEqual([]); + await sweepProjectHealth(run.context); + expect(run.writes).toEqual([]); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// 1b. The sweep reaches its engine through its ARGUMENT (#14257) +// ─────────────────────────────────────────────────────────────────────────── +describe('the nightly sweep survives an ARTIFACT-served boot (#14257)', () => { + it('recomputes when `functions` is all that was merged and `onEnable` never ran', async () => { + // The artifact path is not a variant of the in-process boot, it is a + // strictly NARROWER one: `objectstack build` emits `functions` into a + // sibling runtime module exporting only `{ functions, meta }`, the artifact + // JSON carries no `onEnable`, and `mergeRuntimeModule` + // (`packages/runtime/src/load-artifact-bundle.ts`) merges only `functions`. + // + // So this reaches the handler exactly the way that boot does — out of the + // `functions` entry, the one thing an artifact carries — having called + // NOTHING beforehand. No test in this file calls `onEnable`, and that + // absence is the assertion: before #14257 this same call wrote nothing at + // all, because the module-scope handle `onEnable` was supposed to fill was + // `undefined`, so the handler logged "no engine handle bound yet" and + // returned — on schedule, silently, recorded as a clean run. + const entry = functionEntry('sweepProjectHealth') as { + handler: (ctx: JobHandlerContext) => Promise; + }; + const run = jobRun({ + projects: [{ id: 'p_artifact', status: 'active', health: 'green', budget: 90_000, spent: 88_000 }], + tasks: [{ project: 'p_artifact', progress: 0 }], + }); + + await entry.handler(run.context); + + expect(run.reads.map((r) => r.object)).toEqual(['showcase_project', 'showcase_task']); + expect(run.writes).toEqual([{ id: 'p_artifact', health: 'red' }]); + }); + + it('exports no binding seam for `onEnable` to fill', () => { + // Gone from the EXPORT SURFACE, not merely unused: this is the repo's only + // shipped `defineJob`, i.e. what an author copies, so an app still offering + // `bindShowcaseJobRuntime` would keep teaching the shape that fails here. + expect(Object.keys(jobsModule)).not.toContain('bindShowcaseJobRuntime'); + }); + + it('no authored source re-introduces the module-scope handle', () => { + // CODE, not comments — the handler's docblock and this file's own prose + // name the retired seam on purpose, explaining why it went. + const scanned = [...sourceFiles(), `${process.cwd()}/objectstack.config.ts`]; + const offenders = scanned + .filter((f) => /bindShowcaseJobRuntime|no engine handle bound yet/.test(codeOf(f))) + .map((f) => f.slice(process.cwd().length + 1)); + expect( + offenders, + `these sources still carry the superseded onEnable-binding seam: ${offenders.join(', ')}`, + ).toEqual([]); }); }); From 7988daded9082f7c8d25f1086f4602c437a2a716 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 07:38:54 +0000 Subject: [PATCH 3/3] test(example-showcase): stop spelling a repo path in the artifact-boot comment `check:cross-package-test-inputs` is a SOURCE SCAN: a path literal anywhere in a test file is read as one of that test's real inputs, comment or not. Naming `mergeRuntimeModule`'s file by repo path made the gate demand a turbo `inputs` declaration for a file this test never reads. Describe the module instead, and say so in the comment so the next author does not put the path back. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- examples/app-showcase/test/inert-wirings.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/examples/app-showcase/test/inert-wirings.test.ts b/examples/app-showcase/test/inert-wirings.test.ts index 0c5488bc1f..b1a58b1462 100644 --- a/examples/app-showcase/test/inert-wirings.test.ts +++ b/examples/app-showcase/test/inert-wirings.test.ts @@ -252,8 +252,11 @@ describe('the nightly sweep survives an ARTIFACT-served boot (#14257)', () => { // The artifact path is not a variant of the in-process boot, it is a // strictly NARROWER one: `objectstack build` emits `functions` into a // sibling runtime module exporting only `{ functions, meta }`, the artifact - // JSON carries no `onEnable`, and `mergeRuntimeModule` - // (`packages/runtime/src/load-artifact-bundle.ts`) merges only `functions`. + // JSON carries no `onEnable`, and `mergeRuntimeModule` (the merge step + // `@objectstack/runtime` runs on a loaded artifact bundle) merges only + // `functions`. Its file is deliberately NOT spelled as a repo path here: + // `check:cross-package-test-inputs` reads a path literal in a test as a + // real input, and this test does not read that file. // // So this reaches the handler exactly the way that boot does — out of the // `functions` entry, the one thing an artifact carries — having called