From 913d8e827fac068a625f4aead33903a0f50235ec Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 19:48:34 +0000 Subject: [PATCH 1/4] =?UTF-8?q?test(cli):=20install-local=20pin=20?= =?UTF-8?q?=E2=80=94=20two=20packages=20declaring=20the=20same=20job=20nam?= =?UTF-8?q?e=20(#21602=20reach=20probe)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz --- ...local-jobs-shared-name.integration.test.ts | 473 ++++++++++++++++++ 1 file changed, 473 insertions(+) create mode 100644 packages/cli/test/package-install-local-jobs-shared-name.integration.test.ts diff --git a/packages/cli/test/package-install-local-jobs-shared-name.integration.test.ts b/packages/cli/test/package-install-local-jobs-shared-name.integration.test.ts new file mode 100644 index 00000000000..afda51a8de0 --- /dev/null +++ b/packages/cli/test/package-install-local-jobs-shared-name.integration.test.ts @@ -0,0 +1,473 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #21602 — two install-local packages that declare the SAME job name both keep + * running, and uninstalling one stops only its own job. + * + * ## The defect, measured at this door before the fix + * + * The metadata registry keys a packaged item by `:`, so two + * packages may each declare a job `shared_tick`. The job service keyed by the + * bare name and replaced: the second install scheduled its `shared_tick` over + * the first package's, whose job stopped — its rows held flat over several + * intervals — while both installs answered success and nothing at the door + * said so. + * + * ## What each `it` reads + * + * Three packages, ALPHA, BETA and GAMMA, each declare a body job named + * `shared_tick`; each body writes a row carrying ITS OWN marker into the + * host's object, so a run is attributable to a package even though the three + * jobs share a name. One host runtime (`requires: ['job']`, package scheduled + * work on), two boots on one home: + * + * 1. HOT — ALPHA installs alone: a single-package runtime, whose job and run + * history read the AUTHORED name in `sys_job` / `sys_job_run`. BETA and + * GAMMA install: all three run, ALPHA's included, and ALPHA's catalogue + * row and run history still read the authored name; the later two are + * catalogued under the registry's package-scoped identity. BETA (a + * scoped holder) uninstalls: BETA stops, ALPHA and GAMMA run on. + * 2. RESTART — the ledger rehydrates ALPHA and GAMMA: both run. ALPHA (the + * holder of the bare name) uninstalls: ALPHA stops, GAMMA runs on. + * + * Every reading goes through a door a user uses: the CLI's output and exit + * code for an install, the install-local DELETE for an uninstall, the data + * route for the rows a job wrote and for the `sys_job` / `sys_job_run` + * catalogue an operator reads in Setup. + * + * ## Spawn shape + * + * Shared with `package-install-local-jobs.integration.test.ts` (#21489): the + * tsx source entry, one process group per `os start`, every workspace package + * — `@objectstack/runtime` included — resolved through its `exports` to + * `dist/`, so an ablation of the runtime'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 written a row. */ +const RUN_WAIT_MS = 20_000; +/** "Writes no further row": let an in-flight run land, take the floor, then read again this much later. */ +const SETTLE_MS = 1_500; +const QUIET_WAIT_MS = 4_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 HOST's own object, where every package's job writes its rows — it + * outlives every package, so a job still running after its uninstall shows. + */ +const HOST_TICK = 'host_tick'; + +/** The ONE job name all three packages declare. */ +const SHARED_JOB = 'shared_tick'; + +/** + * The three packages. Their ids sort ALPHA < GAMMA, which is the order the + * ledger rehydrates them in — the same order they were installed in, so the + * holder of the bare name is the same package on both boots. + */ +const ALPHA = { id: 'com.example.sharedalpha', namespace: 'shared_alpha', marker: 'shared_tick_alpha' }; +const BETA = { id: 'com.example.sharedbeta', namespace: 'shared_beta', marker: 'shared_tick_beta' }; +const GAMMA = { id: 'com.example.sharedgamma', namespace: 'shared_gamma', marker: 'shared_tick_gamma' }; +type Pkg = typeof ALPHA; + +/** The package-scoped identity the metadata registry keys a packaged item by. */ +const scopedKey = (pkg: Pkg) => `${pkg.id}:${SHARED_JOB}`; + +/** A package declaring `shared_tick`, whose body writes one row per run carrying the package's marker. */ +function sharedJobArtifact(pkg: Pkg) { + return { + manifest: { id: pkg.id, namespace: pkg.namespace, version: '0.1.0', type: 'app', name: pkg.namespace }, + objects: [{ + name: `${pkg.namespace}_note`, + label: 'Note', + sharingModel: 'public_read_write', + fields: { name: { type: 'text', label: 'Name' } }, + }], + jobs: [{ + name: SHARED_JOB, + schedule: { type: 'interval', intervalMs: 1000 }, + body: { + language: 'js', + capabilities: ['api.write'], + source: `await ctx.api.object('${HOST_TICK}').insert({ name: '${pkg.marker}' });`, + }, + timeoutMs: 10_000, + enabled: true, + }], + }; +} + +/** The RUNTIME the packages are installed into: the job service and the host's object, nothing else. */ +const HOST_ARTIFACT = { + manifest: { id: 'com.example.host', namespace: 'host', version: '0.1.0', type: 'app', name: 'Host' }, + requires: ['job'], + objects: [{ + name: HOST_TICK, + label: 'Tick', + sharingModel: 'public_read_write', + fields: { name: { type: 'text', label: 'Name' } }, + }], +}; + +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-shared-name', + 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 rows `pkg`'s job has written into the host's object, read through the data route. */ +async function packageRows(live: LiveStart, token: string, pkg: Pkg): Promise { + return http(live, 'GET', `/api/v1/data/${HOST_TICK}?name=${encodeURIComponent(pkg.marker)}&limit=500`, token); +} + +const countOf = async (live: LiveStart, token: string, pkg: Pkg) => rowsOf(await packageRows(live, token, pkg)).length; + +/** + * Wait (bounded) until `pkg`'s job has written MORE than `floor` rows — a + * 1-second interval job is read for a while rather than once, so a run a beat + * after an install is not misread as one that never happens. + */ +async function awaitRuns(live: LiveStart, token: string, pkg: Pkg, floor: number): Promise<{ answer: Answer; floor: number }> { + const deadline = Date.now() + RUN_WAIT_MS; + let answer = await packageRows(live, token, pkg); + while (rowsOf(answer).length <= floor && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 500)); + answer = await packageRows(live, token, pkg); + } + return { answer, floor }; +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** + * The reading for an act that must STOP `pkg`'s job: let a run already in + * flight land, take the row count as the floor, wait, and count again. A job + * that was stopped leaves `after === floor`; one still scheduled (every + * second) adds rows in between. + */ +async function quietAfter(live: LiveStart, token: string, pkg: Pkg): Promise<{ floor: number; after: number }> { + await sleep(SETTLE_MS); + const floor = await countOf(live, token, pkg); + await sleep(QUIET_WAIT_MS); + const after = await countOf(live, token, pkg); + return { floor, after }; +} + +/** The job catalogue an operator reads in Setup: `sys_job` names, and the `job_name` of every `sys_job_run` row. */ +async function catalogue(live: LiveStart, token: string): Promise<{ jobs: Answer; runs: Answer }> { + return { + jobs: await http(live, 'GET', '/api/v1/data/sys_job?limit=500', token), + runs: await http(live, 'GET', '/api/v1/data/sys_job_run?limit=1000', token), + }; +} + +/** The distinct names a catalogue reading carries for the shared job, sorted. */ +function sharedNames(rows: any[], key: 'name' | 'job_name'): string[] { + return [...new Set(rows.map((r) => String(r?.[key])).filter((n) => n.endsWith(SHARED_JOB)))].sort(); +} + +interface InstallRun { exit: number | null; output: string } +const readings: { + installs: Record; + alphaAlone?: { answer: Answer; floor: number }; + alphaAloneCatalogue?: { jobs: Answer; runs: Answer }; + alphaAfterOthers?: { answer: Answer; floor: number }; + betaRuns?: { answer: Answer; floor: number }; + gammaRuns?: { answer: Answer; floor: number }; + sharedCatalogue?: { jobs: Answer; runs: Answer }; + betaUninstall?: Answer; + betaGone?: { floor: number; after: number }; + alphaAfterBetaGone?: { answer: Answer; floor: number }; + gammaAfterBetaGone?: { answer: Answer; floor: number }; + alphaRestart?: { answer: Answer; floor: number }; + gammaRestart?: { answer: Answer; floor: number }; + alphaUninstall?: Answer; + alphaGone?: { floor: number; after: number }; + gammaAfterAlphaGone?: { answer: Answer; floor: number }; + output: Record; +} = { installs: {}, output: {} }; + +beforeAll(async () => { + const root = mkdtempSync(join(tmpdir(), 'install-local-jobs-shared-')); + dirs.push(root); + const appDir = (pkg: Pkg) => { + const dir = join(root, pkg.namespace); + mkdirSync(join(dir, 'dist'), { recursive: true }); + writeFileSync(join(dir, 'dist', 'objectstack.json'), JSON.stringify(sharedJobArtifact(pkg), null, 2), 'utf8'); + return dir; + }; + const dirOf = { alpha: appDir(ALPHA), beta: appDir(BETA), gamma: appDir(GAMMA) }; + + // The runtime boots the HOST artifact — never a package — so the packages + // reach it only through the install. + 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: hot installs ───────────────────────────────────────────── + const first = await bootStart(runtimeDir, home, port, ['--artifact', hostArtifact]); + const token = await authenticate(first); + + // ALPHA alone: a single-package runtime. + readings.installs.alpha = await packageInstall(dirOf.alpha, first); + readings.alphaAlone = await awaitRuns(first, token, ALPHA, 0); + readings.alphaAloneCatalogue = await catalogue(first, token); + + // BETA and GAMMA declare the same job name. The floor for ALPHA is taken + // AFTER both installs answered, so only a run that happens once they are in + // lifts it. + readings.installs.beta = await packageInstall(dirOf.beta, first); + readings.installs.gamma = await packageInstall(dirOf.gamma, first); + await sleep(SETTLE_MS); + readings.alphaAfterOthers = await awaitRuns(first, token, ALPHA, await countOf(first, token, ALPHA)); + readings.betaRuns = await awaitRuns(first, token, BETA, 0); + readings.gammaRuns = await awaitRuns(first, token, GAMMA, 0); + readings.sharedCatalogue = await catalogue(first, token); + + // Uninstall BETA — a holder of a package-scoped identity. + readings.betaUninstall = await http(first, 'DELETE', `/api/v1/marketplace/install-local/${BETA.id}`, token); + readings.betaGone = await quietAfter(first, token, BETA); + readings.alphaAfterBetaGone = await awaitRuns(first, token, ALPHA, await countOf(first, token, ALPHA)); + readings.gammaAfterBetaGone = await awaitRuns(first, token, GAMMA, await countOf(first, token, GAMMA)); + readings.output.hot = first.output(); + await stopGroup(first.child); + + // ── boot 2: same host, home and cwd — the ledger rehydrates ALPHA and GAMMA ── + const second = await bootStart(runtimeDir, home, port, ['--artifact', hostArtifact]); + const token2 = await authenticate(second); + readings.alphaRestart = await awaitRuns(second, token2, ALPHA, await countOf(second, token2, ALPHA)); + readings.gammaRestart = await awaitRuns(second, token2, GAMMA, await countOf(second, token2, GAMMA)); + + // Uninstall ALPHA — the holder of the bare name. + readings.alphaUninstall = await http(second, 'DELETE', `/api/v1/marketplace/install-local/${ALPHA.id}`, token2); + readings.alphaGone = await quietAfter(second, token2, ALPHA); + readings.gammaAfterAlphaGone = await awaitRuns(second, token2, GAMMA, await countOf(second, token2, GAMMA)); + readings.output.restart = second.output(); + await stopGroup(second.child); +}, 2 * BOOT_TIMEOUT_MS + 12 * 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('#21602: two packages declaring the same job name both run, and an uninstall stops only its own', () => { + it('all three packages install (exit 0)', () => { + for (const [name, run] of Object.entries(readings.installs)) { + expect(run.exit, `${name}: ${run.output}`).toBe(0); + expect(run.output, name).toMatch(/Package installed into the running kernel/); + } + }); + + it('a single-package runtime: the job runs, and sys_job / sys_job_run read the AUTHORED name', () => { + const { answer } = readings.alphaAlone!; + expect(rowsOf(answer).length, `ALPHA's job never ran${transcript('hot')}`).toBeGreaterThan(0); + const { jobs, runs } = readings.alphaAloneCatalogue!; + expect(jobs.status, JSON.stringify(jobs.body)).toBe(200); + expect(runs.status, JSON.stringify(runs.body)).toBe(200); + expect(sharedNames(rowsOf(jobs), 'name')).toEqual([SHARED_JOB]); + expect(sharedNames(rowsOf(runs), 'job_name')).toEqual([SHARED_JOB]); + }); + + it("after the same-named installs, the FIRST package's job keeps running — it is not displaced", () => { + const { answer, floor } = readings.alphaAfterOthers!; + expect(rowsOf(answer).length, `ALPHA's job stopped once BETA and GAMMA installed${transcript('hot')}`).toBeGreaterThan(floor); + }); + + it("… and each later package's job runs too", () => { + expect(rowsOf(readings.betaRuns!.answer).length, `BETA's job never ran${transcript('hot')}`).toBeGreaterThan(0); + expect(rowsOf(readings.gammaRuns!.answer).length, `GAMMA's job never ran${transcript('hot')}`).toBeGreaterThan(0); + }); + + it("the catalogue: the first package keeps the authored name; the later ones read the registry's package-scoped identity", () => { + const { jobs, runs } = readings.sharedCatalogue!; + const expected = [SHARED_JOB, scopedKey(BETA), scopedKey(GAMMA)].sort(); + expect(sharedNames(rowsOf(jobs), 'name')).toEqual(expected); + expect(sharedNames(rowsOf(runs), 'job_name')).toEqual(expected); + }); + + it('uninstalling a scoped holder (BETA) stops its job — hot', () => { + expect(readings.betaUninstall!.status, JSON.stringify(readings.betaUninstall!.body)).toBe(200); + const { floor, after } = readings.betaGone!; + expect(floor, 'precondition: the job had run before the uninstall').toBeGreaterThan(0); + expect(after, `BETA's job kept running after its uninstall${transcript('hot')}`).toBe(floor); + }); + + it('… and only its own: ALPHA and GAMMA run on', () => { + const alpha = readings.alphaAfterBetaGone!; + expect(rowsOf(alpha.answer).length, `ALPHA's job stopped at BETA's uninstall${transcript('hot')}`).toBeGreaterThan(alpha.floor); + const gamma = readings.gammaAfterBetaGone!; + expect(rowsOf(gamma.answer).length, `GAMMA's job stopped at BETA's uninstall${transcript('hot')}`).toBeGreaterThan(gamma.floor); + }); + + it('after a restart, both rehydrated packages run', () => { + const alpha = readings.alphaRestart!; + expect(rowsOf(alpha.answer).length, `ALPHA's job did not run after the restart${transcript('restart')}`).toBeGreaterThan(alpha.floor); + const gamma = readings.gammaRestart!; + expect(rowsOf(gamma.answer).length, `GAMMA's job did not run after the restart${transcript('restart')}`).toBeGreaterThan(gamma.floor); + }); + + it('uninstalling the holder of the bare name (ALPHA) stops its job, and only its own: GAMMA runs on', () => { + expect(readings.alphaUninstall!.status, JSON.stringify(readings.alphaUninstall!.body)).toBe(200); + const { floor, after } = readings.alphaGone!; + expect(floor, 'precondition: the job had run before the uninstall').toBeGreaterThan(0); + expect(after, `ALPHA's job kept running after its uninstall${transcript('restart')}`).toBe(floor); + const gamma = readings.gammaAfterAlphaGone!; + expect(rowsOf(gamma.answer).length, `GAMMA's job stopped at ALPHA's uninstall${transcript('restart')}`).toBeGreaterThan(gamma.floor); + }); +}); From 07d4deb5fbbb146c94607c3d9b5c0f0b4bd3f2dd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 19:53:47 +0000 Subject: [PATCH 2/4] fix(runtime): a package's job keeps its own identity on the job service when another package declares the same name The metadata registry keys a packaged item by :; the job service keys by one string and replaces. scheduleAppArtifactJobs now schedules a job under its authored name unless another package already holds that name, and then under the registry's package-scoped key, so both jobs run. The ownership record maps each authored name to the key it was scheduled under, so a replace or an uninstall cancels only its own package's job. A runtime where no two packages share a job name schedules every job under its authored name, unchanged. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz --- .../src/app-artifact-handlers.jobs.test.ts | 162 +++++++++++++++++- packages/runtime/src/app-artifact-handlers.ts | 154 +++++++++++++---- 2 files changed, 273 insertions(+), 43 deletions(-) diff --git a/packages/runtime/src/app-artifact-handlers.jobs.test.ts b/packages/runtime/src/app-artifact-handlers.jobs.test.ts index 2cfc44c806f..577da57d6e6 100644 --- a/packages/runtime/src/app-artifact-handlers.jobs.test.ts +++ b/packages/runtime/src/app-artifact-handlers.jobs.test.ts @@ -21,7 +21,12 @@ * - the job's `timeoutMs` is the body's one limit; with none, the runner's * JOB default applies — not the hook's, not the action's; * - the body's return is read as a `JobRunOutcome`, in that shape only; - * - a body's `ctx` carries no job name and no trigger data. + * - a body's `ctx` carries no job name and no trigger data; + * - (#21602) two packages' jobs of the same name each keep their own identity + * on the job service — the first its authored name, a later one the + * registry's package-scoped key — so neither replaces the other, and a + * replace or an uninstall cancels only its own package's job; a package + * whose names collide with no other's is scheduled under its authored names. */ import { describe, it, expect, vi } from 'vitest'; @@ -311,16 +316,17 @@ describe('#21489: re-scheduling replaces — a job the new version does not sche expect(h.jobs.scheduled.size).toBe(0); }); - it("another app's jobs are never cancelled — not even one that took over a name this app once scheduled", async () => { + it("another app's jobs are never cancelled — not even its job of the same name", async () => { const h = harness(); await h.schedule(pkg([job('shared_name'), job('mine_only')]), APP_ID); await h.schedule(pkg([job('shared_name'), job('theirs_only')]), 'com.example.other'); - // This app's next version drops both: only its own remaining job stops. + // This app's next version drops both: its own two stop, the other app's two run on. const out = await h.schedule(pkg([]), APP_ID); - expect(out.cancelled).toEqual(['mine_only']); - expect([...h.jobs.scheduled.keys()].sort()).toEqual(['shared_name', 'theirs_only']); + expect(out.cancelled.sort()).toEqual(['mine_only', 'shared_name']); + expect(h.jobs.cancels.sort()).toEqual(['mine_only', 'shared_name']); + expect([...h.jobs.scheduled.keys()].sort()).toEqual(['com.example.other:shared_name', 'theirs_only']); }); it('a cancel that throws is said at error, and the job stays on the record for the next attempt', async () => { @@ -382,6 +388,152 @@ describe('#21489: the uninstall cleanup cancels the uninstalled package\'s jobs' }); }); +describe('#21602: two packages declaring the same job name — each job keeps its own identity on the job service', () => { + const OTHER = 'com.example.other'; + const THIRD = 'com.example.third'; + /** A body job whose run writes a row carrying `marker`, so a run is attributable to its package. */ + const markedJob = (name: string, marker: string) => ({ + name, + schedule: INTERVAL, + body: { ...WRITE_BODY, source: `await ctx.api.object('${TICK}').insert({ name: '${marker}' });` }, + }); + /** Run whatever the job service holds under `key` once; the marker its body wrote. */ + const runKey = async (h: ReturnType, key: string) => { + const before = h.engine.writes.length; + await h.jobs.scheduled.get(key)!.run({ jobId: key }); + return h.engine.writes.slice(before).map((w) => (w.data as { name: string }).name); + }; + + it('a single package: every job is scheduled under its AUTHORED name, and a reinstall keeps it (control)', async () => { + const h = harness(); + + const first = await h.schedule(pkg([markedJob('a_job', 'a'), markedJob('b_job', 'b')])); + const again = await h.schedule(pkg([markedJob('a_job', 'a'), markedJob('b_job', 'b')])); + + expect([...h.jobs.scheduled.keys()].sort()).toEqual(['a_job', 'b_job']); + expect(first.bodies.sort()).toEqual(['a_job', 'b_job']); + expect(again.bodies.sort()).toEqual(['a_job', 'b_job']); + expect(again.cancelled).toEqual([]); + expect(h.jobs.cancels).toEqual([]); + expect(h.logger.info.mock.calls.some((c) => String(c[0]).includes('package-scoped identity'))).toBe(false); + }); + + it('the second package is scheduled under the registry\'s package-scoped key — the first package\'s job is NOT replaced', async () => { + const h = harness(); + await h.schedule(pkg([markedJob('shared_tick', 'mine')]), APP_ID); + + const out = await h.schedule(pkg([markedJob('shared_tick', 'theirs')]), OTHER); + + // Both are scheduled; nothing was cancelled or replaced. + expect([...h.jobs.scheduled.keys()].sort()).toEqual([`${OTHER}:shared_tick`, 'shared_tick']); + expect(h.jobs.cancels).toEqual([]); + // Each key runs its OWN package's body. + expect(await runKey(h, 'shared_tick')).toEqual(['mine']); + expect(await runKey(h, `${OTHER}:shared_tick`)).toEqual(['theirs']); + // The binder's own answer keeps the authored name. + expect(out.bodies).toEqual(['shared_tick']); + // Said once, naming the package that holds the name and the key it is catalogued under. + const said = h.logger.info.mock.calls.filter((c) => String(c[0]).includes('package-scoped identity')); + expect(said).toHaveLength(1); + expect(said[0][1]).toEqual({ appId: OTHER, job: 'shared_tick', scheduledAs: `${OTHER}:shared_tick`, heldBy: APP_ID }); + }); + + it('a reinstall of either package replaces its OWN job under the key it holds — never the other package\'s', async () => { + const h = harness(); + await h.schedule(pkg([markedJob('shared_tick', 'mine')]), APP_ID); + await h.schedule(pkg([markedJob('shared_tick', 'theirs')]), OTHER); + + await h.schedule(pkg([markedJob('shared_tick', 'theirs_v2')]), OTHER); + await h.schedule(pkg([markedJob('shared_tick', 'mine_v2')]), APP_ID); + + expect([...h.jobs.scheduled.keys()].sort()).toEqual([`${OTHER}:shared_tick`, 'shared_tick']); + expect(h.jobs.cancels).toEqual([]); + expect(await runKey(h, 'shared_tick')).toEqual(['mine_v2']); + expect(await runKey(h, `${OTHER}:shared_tick`)).toEqual(['theirs_v2']); + }); + + it('uninstalling the scoped holder cancels only its scoped key; the holder of the authored name runs on', async () => { + const h = harness({ withProtocol: true }); + await h.schedule(pkg([markedJob('shared_tick', 'mine')]), APP_ID); + await h.schedule(pkg([markedJob('shared_tick', 'theirs')]), OTHER); + + const result = await h.reg.cleanups.get(PACKAGE_JOBS_UNINSTALL_CLEANUP)!({ packageId: OTHER }); + + expect(result).toEqual({ success: true, removed: 1 }); + expect(h.jobs.cancels).toEqual([`${OTHER}:shared_tick`]); + expect([...h.jobs.scheduled.keys()]).toEqual(['shared_tick']); + expect(await runKey(h, 'shared_tick')).toEqual(['mine']); + }); + + it('uninstalling the holder of the authored name cancels only that; the scoped holder runs on', async () => { + const h = harness({ withProtocol: true }); + await h.schedule(pkg([markedJob('shared_tick', 'mine')]), APP_ID); + await h.schedule(pkg([markedJob('shared_tick', 'theirs')]), OTHER); + + const result = await h.reg.cleanups.get(PACKAGE_JOBS_UNINSTALL_CLEANUP)!({ packageId: APP_ID }); + + expect(result).toEqual({ success: true, removed: 1 }); + expect(h.jobs.cancels).toEqual(['shared_tick']); + expect([...h.jobs.scheduled.keys()]).toEqual([`${OTHER}:shared_tick`]); + expect(await runKey(h, `${OTHER}:shared_tick`)).toEqual(['theirs']); + }); + + it('a version of the scoped holder that drops the job cancels its scoped key only', async () => { + const h = harness(); + await h.schedule(pkg([markedJob('shared_tick', 'mine')]), APP_ID); + await h.schedule(pkg([markedJob('shared_tick', 'theirs'), markedJob('other_only', 'o')]), OTHER); + + const out = await h.schedule(pkg([markedJob('other_only', 'o')]), OTHER); + + expect(out.cancelled).toEqual(['shared_tick']); + expect(h.jobs.cancels).toEqual([`${OTHER}:shared_tick`]); + expect([...h.jobs.scheduled.keys()].sort()).toEqual(['other_only', 'shared_tick']); + }); + + it('a third package is scoped too; once the holder of the authored name is gone, a newcomer takes it', async () => { + const h = harness({ withProtocol: true }); + await h.schedule(pkg([markedJob('shared_tick', 'mine')]), APP_ID); + await h.schedule(pkg([markedJob('shared_tick', 'theirs')]), OTHER); + await h.schedule(pkg([markedJob('shared_tick', 'third')]), THIRD); + expect([...h.jobs.scheduled.keys()].sort()).toEqual([`${OTHER}:shared_tick`, `${THIRD}:shared_tick`, 'shared_tick']); + + await h.reg.cleanups.get(PACKAGE_JOBS_UNINSTALL_CLEANUP)!({ packageId: APP_ID }); + await h.reg.cleanups.get(PACKAGE_JOBS_UNINSTALL_CLEANUP)!({ packageId: THIRD }); + await h.schedule(pkg([markedJob('shared_tick', 'fresh')]), 'com.example.fresh'); + + // The scoped holder keeps its key: a running job's catalogue name never moves under it. + expect([...h.jobs.scheduled.keys()].sort()).toEqual([`${OTHER}:shared_tick`, 'shared_tick']); + expect(await runKey(h, 'shared_tick')).toEqual(['fresh']); + expect(await runKey(h, `${OTHER}:shared_tick`)).toEqual(['theirs']); + }); + + it("a handler job scheduled under a scoped key still hands its handler the AUTHORED name as jobId", async () => { + const h = harness(); + let seen: any; + const tick = vi.fn(async (c: any) => { seen = c; }); + await h.schedule(pkg([{ name: 'shared_tick', schedule: INTERVAL, handler: 'tick' }], { functions: { tick } }), APP_ID); + await h.schedule(pkg([{ name: 'shared_tick', schedule: INTERVAL, handler: 'tick' }], { functions: { tick } }), OTHER); + + await h.jobs.scheduled.get(`${OTHER}:shared_tick`)!.run({ jobId: `${OTHER}:shared_tick` }); + + expect(seen.jobId).toBe('shared_tick'); + }); + + it('a scoped job the uninstall could not cancel is named with the key it is scheduled under', async () => { + const h = harness({ withProtocol: true, failCancel: `${OTHER}:shared_tick` }); + await h.schedule(pkg([markedJob('shared_tick', 'mine')]), APP_ID); + await h.schedule(pkg([markedJob('shared_tick', 'theirs')]), OTHER); + + const result = await h.reg.cleanups.get(PACKAGE_JOBS_UNINSTALL_CLEANUP)!({ packageId: OTHER }); + + expect(result.success).toBe(false); + expect(result.removed).toBe(0); + expect(result.error).toContain(`shared_tick (scheduled as ${OTHER}:shared_tick)`); + // The holder of the authored name was never touched. + expect([...h.jobs.scheduled.keys()].sort()).toEqual([`${OTHER}:shared_tick`, 'shared_tick']); + }); +}); + describe('#21489: the sandbox job origin', () => { it("a job body with no timeoutMs gets the runner's JOB default — not the hook's, not the action's", async () => { const runner = new QuickJSScriptRunner({ jobTimeoutMs: 30, hookTimeoutMs: 5_000, actionTimeoutMs: 5_000 }); diff --git a/packages/runtime/src/app-artifact-handlers.ts b/packages/runtime/src/app-artifact-handlers.ts index 8ee17aa5a3f..dc88fbdf020 100644 --- a/packages/runtime/src/app-artifact-handlers.ts +++ b/packages/runtime/src/app-artifact-handlers.ts @@ -73,6 +73,14 @@ * (`judgeJobBody`) — and the install-local install route refuses a package that * declares one enabled. * + * A job's identity is the package's and the job's name together, as the + * metadata registry keys it (`:`), so two packages may each + * declare a job of the same name and both run (#21602). The job service keys by + * one string, so a job is scheduled under its authored name unless another + * package already holds that name on the service, and then under the registry's + * package-scoped key ({@link jobKeyFor}). A runtime where no two packages share + * a job name schedules every job under its authored name, exactly as before. + * * ## A hook with no `body`, on a door that carries no runtime module (#21585) * * The same holds for a hook in the deprecated function-name `handler` form, with @@ -452,13 +460,21 @@ export interface AppArtifactJobScheduling { * job the same `timeoutMs` also bounds the sandbox run — the one limit * `JobSchema.timeoutMs` states. * + * Each job is scheduled under the key {@link jobKeyFor} gives it (#21602): its + * authored name, or — when another package already holds that name on the job + * service — the registry's package-scoped `:`, so the two coexist + * rather than the later one replacing the earlier. The key is the job SERVICE's + * name for the job (the `sys_job` / `sys_job_run` name an operator reads); the + * job's own code and every count this function returns keep the authored name. + * * Re-scheduling replaces, it never accumulates (#21489), as the hook and - * action halves do: `IJobService.schedule` replaces a job of the same name, and - * every job this app scheduled on the job service before and does not schedule - * now — dropped by the new version, disabled, or no longer runnable — is - * CANCELLED ({@link retireAppJobs}). A call with no jobs at all cancels - * everything the app scheduled. The package's uninstall cancels the rest, through - * the uninstall cleanup this function registers ({@link ensureJobUninstallCleanup}). + * action halves do: a reinstall schedules each job under the key it already + * holds, which `IJobService.schedule` replaces, and every job this app + * scheduled on the job service before and does not schedule now — dropped by + * the new version, disabled, or no longer runnable — is CANCELLED + * ({@link retireAppJobs}). A call with no jobs at all cancels everything the + * app scheduled. The package's uninstall cancels the rest, through the + * uninstall cleanup this function registers ({@link ensureJobUninstallCleanup}). * * Never throws: a job that fails to schedule is logged at `error` with its own * counter (a silent outage otherwise — the app looks healthy and the work never @@ -589,9 +605,12 @@ export async function scheduleAppArtifactJobs( form = 'handler'; } + // [#21602] The job service's name for this job: the authored name, or + // the package-scoped key when another package already holds it. + const { key, heldBy } = jobKeyFor(jobService, appId, jobName); try { await jobService.schedule( - jobName, + key, // #4567: authoring tier → boundary tier. `job.schedule` is the // PARSED `Schedule`, whose cron `expression` is the ADR // expression envelope `{dialect,source}`; `IJobService.schedule` @@ -604,7 +623,14 @@ export async function scheduleAppArtifactJobs( : undefined, ); (form === 'body' ? out.bodies : out.handlers).push(jobName); - claimJobName(jobService, appId, jobName); + claimJobKey(jobService, appId, jobName, key); + if (heldBy !== undefined) { + logger.info( + `${tag} job '${jobName}' is also declared by package '${heldBy}', which holds that name on the job service — ` + + `scheduled under its package-scoped identity '${key}', the name it carries in the job catalogue and run history`, + { appId, job: jobName, scheduledAs: key, heldBy }, + ); + } } catch (err: any) { out.failed.push(jobName); // #4567: a job that fails to schedule is a SILENT OUTAGE — the app @@ -646,18 +672,17 @@ export async function scheduleAppArtifactJobs( // ─── Cancelling a package's jobs: replace, and uninstall (#21489) ─────── /** - * Which job names each app's scheduling put on a job service, keyed by the job - * service instance — one per kernel, so two kernels in one process never share - * a record, and a record goes with its kernel. - * - * A job's `name` is its identity on the job service, and it is not namespaced - * by package: the LAST app to schedule a name owns it ({@link claimJobName}), - * so cancelling one app's jobs never stops a job another app scheduled under - * the same name afterwards. + * Which jobs each app's scheduling put on a job service — per app, each + * authored job name and the KEY it is scheduled under ({@link jobKeyFor}) — + * keyed by the job service instance: one per kernel, so two kernels in one + * process never share a record, and a record goes with its kernel. + * + * A key is held by one app at a time, so cancelling one app's jobs — a replace + * or its uninstall — never stops a job another app scheduled (#21602). */ -const SCHEDULED_BY_APP = new WeakMap>>(); +const SCHEDULED_BY_APP = new WeakMap>>(); -function scheduledByApp(svc: IJobService): Map> { +function scheduledByApp(svc: IJobService): Map> { let record = SCHEDULED_BY_APP.get(svc); if (!record) { record = new Map(); @@ -666,26 +691,73 @@ function scheduledByApp(svc: IJobService): Map> { return record; } -/** `appId` now owns `jobName` on `svc`; no other app's record still claims it. */ -function claimJobName(svc: IJobService, appId: string, jobName: string): void { +/** The app whose record holds `key` on `svc`, other than `appId` — or `undefined`. */ +function otherHolderOf(svc: IJobService, appId: string, key: string): string | undefined { + for (const [owner, jobs] of scheduledByApp(svc)) { + if (owner === appId) continue; + for (const held of jobs.values()) if (held === key) return owner; + } + return undefined; +} + +/** + * The key `appId`'s job `jobName` is scheduled under on `svc` (#21602). + * + * The job's identity is `(appId, jobName)` — the metadata registry keys a + * packaged item `:` — but the job service keys by one string + * and REPLACES a job of the same name, so two packages' same-named jobs need two + * strings. In order: + * + * 1. the key this app already holds the job under — a reinstall replaces its + * own job and keeps its catalogue row and run history; + * 2. else the authored name, when no other package holds it — so a runtime + * in which no two packages share a job name schedules every job under its + * authored name, unchanged; + * 3. else the registry's package-scoped key `:`, which no + * authored name can equal (`JobSchema.name` is snake_case, so it never + * carries the `:`) and no other app ever derives. + * + * `heldBy` names the package that holds the authored name when (3) applies: + * the package that scheduled it first in this process — on a restart, the + * first package the boot schedules. + */ +function jobKeyFor(svc: IJobService, appId: string, jobName: string): { key: string; heldBy?: string } { + const mine = scheduledByApp(svc).get(appId)?.get(jobName); + if (mine !== undefined) return { key: mine }; + const heldBy = otherHolderOf(svc, appId, jobName); + return heldBy === undefined ? { key: jobName } : { key: `${appId}:${jobName}`, heldBy }; +} + +/** + * `appId` now holds `jobName` on `svc` under `key`. No other app's record still + * claims that key — so another app's replace or uninstall can never cancel it. + */ +function claimJobKey(svc: IJobService, appId: string, jobName: string, key: string): void { const record = scheduledByApp(svc); - for (const [owner, names] of record) { - if (owner !== appId) names.delete(jobName); + for (const [owner, jobs] of record) { + if (owner === appId) continue; + for (const [name, held] of jobs) if (held === key) jobs.delete(name); } - const mine = record.get(appId) ?? new Set(); - mine.add(jobName); + const mine = record.get(appId) ?? new Map(); + mine.set(jobName, key); record.set(appId, mine); } +/** `'name'`, or `'name' (scheduled as 'key')` when the job service knows it by its package-scoped key. */ +function describeScheduledJob(name: string, key: string): string { + return key === name ? name : `${name} (scheduled as ${key})`; +} + /** - * Cancel every job `appId` scheduled on `svc` that is not in `keep`, through - * `IJobService.cancel` — the verb every adapter implements (the cron adapter - * stops its timer, the DB adapter also marks the `sys_job` row inactive). - * Returns the names cancelled. + * Cancel every job `appId` scheduled on `svc` whose authored name is not in + * `keep`, through `IJobService.cancel` on the key it was scheduled under — the + * verb every adapter implements (the cron adapter stops its timer, the DB + * adapter also marks the `sys_job` row inactive). Returns the authored names + * cancelled. * * A cancel that throws leaves its job RUNNING for a package that no longer * declares it — code that should have stopped keeps executing while the - * install answered success — so it is logged at `error`, and the name stays on + * install answered success — so it is logged at `error`, and the job stays on * the record for the next replace or the uninstall to retry. */ async function retireAppJobs( @@ -698,18 +770,21 @@ async function retireAppJobs( const record = scheduledByApp(svc); const previous = record.get(appId); const cancelled: string[] = []; - const remaining = new Set(keep); - for (const name of previous ?? []) { - if (keep.has(name)) continue; + const remaining = new Map(); + for (const [name, key] of previous ?? []) { + if (keep.has(name)) { + remaining.set(name, key); + continue; + } try { - await svc.cancel(name); + await svc.cancel(key); cancelled.push(name); } catch (err: any) { - remaining.add(name); + remaining.set(name, key); logger.error( `${tag} a job its package no longer schedules could NOT be cancelled — it keeps running until the runtime restarts`, err as Error, - { appId, job: name }, + { appId, job: name, scheduledAs: key }, ); } } @@ -742,7 +817,9 @@ const JOB_CLEANUP_REGISTERED = new WeakSet(); * * Registered from here, the moment a package's jobs are first scheduled on a * kernel, because scheduling is what creates something to cancel; the record - * the cleanup reads is the one {@link claimJobName} keeps. The package id the + * the cleanup reads is the one {@link claimJobKey} keeps, so it cancels each + * job under the key it was scheduled under and never another package's job of + * the same name (#21602). The package id the * cleanup receives is the app id the jobs were scheduled under: install-local * passes the manifest id, which is what it schedules under. A protocol without * the registry registers nothing, and says nothing here: the uninstall door @@ -763,13 +840,14 @@ function ensureJobUninstallCleanup(ctx: PluginContext, svc: IJobService): void { const before = [...(scheduledByApp(svc).get(packageId) ?? [])]; if (before.length === 0) return { success: true, removed: 0 }; const cancelled = await retireAppJobs(svc, packageId, new Set(), logger, '[uninstall]'); - const left = before.filter((name) => !cancelled.includes(name)); + const left = before.filter(([name]) => !cancelled.includes(name)); return left.length === 0 ? { success: true, removed: cancelled.length } : { success: false, removed: cancelled.length, - error: `${left.length} job(s) of the uninstalled package could not be cancelled and keep running until the runtime restarts: ${left.join(', ')}`, + error: `${left.length} job(s) of the uninstalled package could not be cancelled and keep running until the runtime restarts: ` + + left.map(([name, key]) => describeScheduledJob(name, key)).join(', '), }; }); } From 2f9097c7c20f706088402d6d87447fa6f48e3890 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 20:00:32 +0000 Subject: [PATCH 3/4] chore(changeset): runtime patch for package-scoped job identity Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz --- .changeset/21602-package-scoped-job-identity.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/21602-package-scoped-job-identity.md diff --git a/.changeset/21602-package-scoped-job-identity.md b/.changeset/21602-package-scoped-job-identity.md new file mode 100644 index 00000000000..c7043d5a6bb --- /dev/null +++ b/.changeset/21602-package-scoped-job-identity.md @@ -0,0 +1,14 @@ +--- +'@objectstack/runtime': patch +--- + +fix(runtime): when two packages declare a job with the same name, both jobs now run. Uninstalling one stops only its own job. + +Clause-②: no + +The metadata registry keys a packaged item by package and name (`:`), so two packages may each declare a job called, say, `nightly_sync`. The job service keys a job by one string and replaces any job with the same name. Before this fix, installing the second package (`os package install`, or a second app on one boot) silently replaced the first package's job. That job stopped running while both installs reported success. + +- **Both jobs run.** A job is scheduled under its authored name unless another package already holds that name on the job service. In that case it is scheduled under the registry's package-scoped identity, `:`, and an `info` line names the package that holds the name. A package's job body and its `handler`'s `jobId` still see the authored name. +- **What an operator sees.** The Background Jobs catalogue (`sys_job`) and run history (`sys_job_run`) list the job under the name it is scheduled under. That is the authored name, or `:` for a package whose job name another package already holds. A reinstall keeps the name the job already has. +- **Each package cancels only its own job.** When a reinstall drops a job, or the package is uninstalled (the `runtime.package-jobs` uninstall cleanup), the job is cancelled under the name it was scheduled under. Another package's job with the same name keeps running. +- **Unchanged:** a runtime in which no two packages declare the same job name schedules every job under its authored name, so its catalogue and run history read exactly as before. No schema, export, `IJobService` contract or accept-set change. From 2279c378f6355f7852a9b8ff54e4a050e171bf68 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 20:32:46 +0000 Subject: [PATCH 4/4] docs(runtime): describeScheduledJob comment matches what it renders Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz --- packages/runtime/src/app-artifact-handlers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/app-artifact-handlers.ts b/packages/runtime/src/app-artifact-handlers.ts index dc88fbdf020..f518b4feaaa 100644 --- a/packages/runtime/src/app-artifact-handlers.ts +++ b/packages/runtime/src/app-artifact-handlers.ts @@ -743,7 +743,7 @@ function claimJobKey(svc: IJobService, appId: string, jobName: string, key: stri record.set(appId, mine); } -/** `'name'`, or `'name' (scheduled as 'key')` when the job service knows it by its package-scoped key. */ +/** `name`, or `name (scheduled as key)` when the job service knows the job by its package-scoped key. */ function describeScheduledJob(name: string, key: string): string { return key === name ? name : `${name} (scheduled as ${key})`; }