Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .changeset/showcase-inert-wirings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
---

Showcase-only: fix four declarations the reference app made that the runtime
never honoured, each announced by one line in the boot warning block. Releases
nothing — `@objectstack/example-showcase` is private.

- **The nightly job now exists.** `showcase_health_sweep` named
`handler: 'sweepProjectHealth'` and no function of that name was defined
anywhere, so `AppPlugin` skipped it at every boot and "Nightly Project Health
Sweep" had never run. Implemented for real: it recomputes
`showcase_project.health` from budget burn measured against delivered task
progress, over an engine handle captured at `onEnable` (a job handler is
invoked with `{ jobId, data }` and no data engine). It is registered in the
bare-callable form rather than the `effect: 'writes'` declaration it wants:
that declared form cannot survive `objectstack build` today, filed as #4976.
- **`showcase.export_data` now materializes.** The capability declared no
owning package, so it was never written to `sys_capability` — leaving
`OpsPermissionSet` granting a permission that would never exist. It now
authors the ADR-0086 D3 `packageId` provenance. The platform half (an
app-declared capability can never receive the registry stamp, and a refused
declaration still suppresses the back-compat derivation) is filed as #4967.
- **Retry policies use the canonical key.** Two `try_catch` nodes still spelled
the base delay `retryDelayMs`, which only kept working through the
`retry-policy-converged` conversion — and that conversion retires in protocol
18. Renamed to `backoffMs`. `maxRetryDelayMs` is unchanged: it is a canonical
key of `RetryPolicySchema`, not part of that rename.
- **Seed data no longer breaks ADR-0104 on its own first boot.** Ten
`showcase_task.cover` values were inline `data:image/svg+xml` URIs in a
`Field.image()`, whose stored form is an opaque `sys_file` id. Because a boot
may not attest a contract it has already broken (#4769), a brand-new
datastore could never auto-attest `adr-0104-file-references` — the gate stayed
open on day one of every fresh install. The values are removed; `cover` stays
declared, with its gallery binding, and is populated by uploading a cover.

Guarded by `test/inert-wirings.test.ts`: every declared job's handler must
resolve against `defineStack({ functions })` in a form the build can carry,
every declared capability must
resolve an owning package, no permission set may grant an undeclared
capability, no **source file** may author `retryDelayMs` (the parsed stack
cannot answer this — the conversion has already rewritten it), and no seeded
file-class value may fail its ADR-0104 stored shape.
26 changes: 25 additions & 1 deletion examples/app-showcase/objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } from './src/automation/jobs/index.js';
import { allJobs, sweepProjectHealth, bindShowcaseJobRuntime } 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';
Expand Down Expand Up @@ -209,9 +209,28 @@ export default defineStack({
// A flow function is PURE: it takes `inputs`, RETURNS a value, and a later
// declarative node uses or persists it — it does no data I/O of its own
// (#4396), which is why it needs no `effect` declaration here.
//
// 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`.
//
// ⚠️ Do NOT rewrite this as `{ handler: sweepProjectHealth, effect: 'writes' }`.
// That declared form (#4396) is the honest spelling for a writer and is what
// this entry wants — but it cannot survive `objectstack build` today: the CLI
// lowers it to `{ handler: 'sweepProjectHealth', effect: 'writes' }` and
// `FlowFunctionEntrySchema` accepts a bare callable, a declaration whose
// `handler` is a CALLABLE, or a bare string ref — never a declaration whose
// handler has been lowered to a string. `pnpm build` fails with
// `functions: invalid_union`. Filed as #4976; switch back once it lands.
// Nothing is lost at runtime meanwhile: `effect` has exactly one consumer,
// the `script` node's `unmeasuredEffect` metric, and the JOB path drops it
// (`collectBundleFunctions` keeps only the handler).
functions: {
summarizeCompletedTask: ({ input }: { input: Record<string, unknown> }) =>
`Completed: ${String(input.title ?? 'task')} (priority ${String(input.priority ?? 'normal')}).`,
sweepProjectHealth,
},
jobs: allJobs,
emailTemplates: allEmails,
Expand Down Expand Up @@ -262,4 +281,9 @@ export const onEnable = async (ctx: unknown): Promise<void> => {
// real pending requests land in the inbox (cannot be a seed — see
// seed-approval-demo.ts).
registerShowcaseApprovalDemo(ctx as Parameters<typeof registerShowcaseApprovalDemo>[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<typeof bindShowcaseJobRuntime>[0]);
};
12 changes: 10 additions & 2 deletions examples/app-showcase/src/automation/flows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -981,7 +981,14 @@ export const ResilientSyncFlow = defineFlow({
type: 'try_catch',
label: 'Push with retry',
config: {
retry: { maxRetries: 3, retryDelayMs: 1000, backoffMultiplier: 2, maxRetryDelayMs: 10000 },
// Canonical retry policy (`@objectstack/spec` 17.0.0, #4661): the base
// delay is `backoffMs` on BOTH `try_catch.retry` and `job.retryPolicy`.
// The pre-17 automation-side spelling `retryDelayMs` is tombstoned and
// only survives via the `retry-policy-converged` conversion, which
// retires in protocol 18 — never author it. `maxRetryDelayMs` is NOT
// part of that rename: it is a canonical key of `RetryPolicySchema`
// (the ceiling for a single backoff delay).
retry: { maxRetries: 3, backoffMs: 1000, backoffMultiplier: 2, maxRetryDelayMs: 10000 },
errorVariable: '$error',
try: {
nodes: [
Expand Down Expand Up @@ -1173,7 +1180,8 @@ export const ProjectEscalationFlow = defineFlow({
type: 'try_catch',
label: 'Push to incident system',
config: {
retry: { maxRetries: 2, retryDelayMs: 500, backoffMultiplier: 2 },
// Canonical `backoffMs` — see the note on ResilientSyncFlow above.
retry: { maxRetries: 2, backoffMs: 500, backoffMultiplier: 2 },
errorVariable: '$error',
try: {
nodes: [{ id: 'push', type: 'http', label: 'POST incident', config: { url: 'https://api.example.com/v1/incidents', method: 'POST', body: { project: '{record.id}', severity: 'critical' } } }],
Expand Down
12 changes: 11 additions & 1 deletion examples/app-showcase/src/automation/jobs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,17 @@

import { defineJob } from '@objectstack/spec';

/** Nightly job — recompute project health. Handler is registered in defineStack({ functions }). */
export { sweepProjectHealth, bindShowcaseJobRuntime, healthFor } from './sweep-project-health.js';

/**
* Nightly job — recompute project health.
*
* `handler` names a key of `defineStack({ functions })` (the only form
* `JobSchema.handler` accepts); `sweepProjectHealth` is registered there with
* `effect: 'writes'`. It was declared here for a long time with no function of
* that name anywhere in the example, so the AppPlugin skipped it at every boot
* and the sweep never ran (#4774 / #4888) — see `./sweep-project-health.ts`.
*/
export const HealthSweepJob = defineJob({
name: 'showcase_health_sweep',
label: 'Nightly Project Health Sweep',
Expand Down
224 changes: 224 additions & 0 deletions examples/app-showcase/src/automation/jobs/sweep-project-health.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `sweepProjectHealth` — the handler behind the nightly `showcase_health_sweep`
* job (see `./index.ts`).
*
* ## Why this file exists
*
* The job declared `handler: 'sweepProjectHealth'` and nothing of that name was
* ever defined, so every boot printed
*
* ```
* WARN [AppPlugin] job handler not found in bundle.functions — skipping
* {"appId":"com.example.showcase","job":"showcase_health_sweep","handler":"sweepProjectHealth"}
* ```
*
* and the "Nightly Project Health Sweep" never ran (#4774 / #4888). A scheduled
* job is one of the capabilities the showcase exists to demonstrate end to end,
* so the fix is a real implementation rather than deleting the declaration —
* "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
*
* 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.
*
* ## What it computes
*
* Health is budget burn measured against delivered progress — the drift between
* the money spent and the work finished:
*
* burn = spent / budget (0 when no budget is set)
* done = mean(task.progress) / 100 (0 when the project has no tasks)
* drift = burn - done
*
* red — over budget (burn > 1), or drift >= 0.30
* yellow — drift >= 0.15
* green — otherwise
*
* Only `active` / `on_hold` projects are swept: `planned` has nothing to burn
* yet, and `completed` / `cancelled` are settled facts a nightly job must not
* relitigate. Writes are limited to the projects whose health actually changed,
* so a steady-state sweep performs zero updates.
*/

/** Statuses whose health is still in play. */
const SWEPT_STATUSES = ['active', 'on_hold'] as const;

/** Drift at or above which a project turns red (spending far ahead of delivery). */
const RED_DRIFT = 0.3;
/** Drift at or above which a project turns yellow. */
const YELLOW_DRIFT = 0.15;

/** Bound on rows read per sweep — a demo dataset, read in one pass. */
const READ_LIMIT = 1000;

const SYS = { isSystem: true } as const;

type Health = 'green' | 'yellow' | 'red';

interface JobHostEngine {
find: (object: string, query: unknown, options?: unknown) => Promise<unknown>;
update: (object: string, data: Record<string, unknown>, options?: unknown) => Promise<unknown>;
}

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<Record<string, unknown>> {
if (Array.isArray(result)) return result as Array<Record<string, unknown>>;
const records = (result as { records?: unknown })?.records;
return Array.isArray(records) ? (records as Array<Record<string, unknown>>) : [];
}

/** Read a numeric column defensively — a currency/progress column may arrive as a string. */
function num(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
}

/**
* The health verdict for one project — exported so the rule is unit-testable
* without an engine (see `test/job-health-sweep.test.ts`).
*/
export function healthFor(input: {
budget?: unknown;
spent?: unknown;
taskProgress: readonly number[];
}): Health {
const budget = num(input.budget) ?? 0;
const spent = num(input.spent) ?? 0;
const burn = budget > 0 ? spent / budget : 0;
if (burn > 1) return 'red';

const done =
input.taskProgress.length > 0
? input.taskProgress.reduce((sum, p) => sum + p, 0) / input.taskProgress.length / 100
: 0;

const drift = burn - done;
if (drift >= RED_DRIFT) return 'red';
if (drift >= YELLOW_DRIFT) return 'yellow';
return 'green';
}

/**
* Recompute `showcase_project.health` for every in-play project.
*
* Registered as `functions.sweepProjectHealth` with `effect: 'writes'` and
* scheduled by `HealthSweepJob` (`0 1 * * *` UTC).
*/
export async function sweepProjectHealth(ctx?: { jobId?: string }): Promise<void> {
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;

const projects = rowsOf(
await ql.find('showcase_project', {
where: { status: { $in: [...SWEPT_STATUSES] } },
fields: ['id', 'status', 'health', 'budget', 'spent'],
limit: READ_LIMIT,
context: SYS,
}),
);
if (projects.length === 0) {
logger?.info?.('[showcase] project health sweep: no in-play projects', { job: jobId });
return;
}

const projectIds = projects.map((p) => String(p.id));
const tasks = rowsOf(
await ql.find('showcase_task', {
where: { project: { $in: projectIds } },
fields: ['project', 'progress'],
limit: READ_LIMIT,
context: SYS,
}),
);

const progressByProject = new Map<string, number[]>();
for (const task of tasks) {
const projectId = task.project == null ? '' : String(task.project);
if (!projectId) continue;
const progress = num(task.progress) ?? 0;
const bucket = progressByProject.get(projectId);
if (bucket) bucket.push(progress);
else progressByProject.set(projectId, [progress]);
}

let updated = 0;
for (const project of projects) {
const id = String(project.id);
const next = healthFor({
budget: project.budget,
spent: project.spent,
taskProgress: progressByProject.get(id) ?? [],
});
if (next === project.health) continue;
try {
await ql.update('showcase_project', { id, health: next }, { context: SYS });
updated += 1;
} catch (err) {
logger?.warn?.('[showcase] project health update failed', {
job: jobId,
project: id,
error: err instanceof Error ? err.message : String(err),
});
}
}

logger?.info?.('[showcase] project health sweep complete', {
job: jobId,
scanned: projects.length,
updated,
});
}
7 changes: 6 additions & 1 deletion examples/app-showcase/src/coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,12 @@ export const KIND_COVERAGE: Record<MetadataType, KindCoverage> = {

// ── automation ──
flow: { status: 'demonstrated', files: ['src/automation/flows/index.ts'] },
job: { status: 'demonstrated', files: ['src/automation/jobs/index.ts'] },
// `job` is only demonstrated end-to-end if its handler actually resolves —
// the declaration alone left the sweep registered and never run (#4774).
job: {
status: 'demonstrated',
files: ['src/automation/jobs/index.ts', 'src/automation/jobs/sweep-project-health.ts'],
},

// ── system ──
datasource: {
Expand Down
Loading
Loading