|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * `sweepProjectHealth` — the handler behind the nightly `showcase_health_sweep` |
| 5 | + * job (see `./index.ts`). |
| 6 | + * |
| 7 | + * ## Why this file exists |
| 8 | + * |
| 9 | + * The job declared `handler: 'sweepProjectHealth'` and nothing of that name was |
| 10 | + * ever defined, so every boot printed |
| 11 | + * |
| 12 | + * ``` |
| 13 | + * WARN [AppPlugin] job handler not found in bundle.functions — skipping |
| 14 | + * {"appId":"com.example.showcase","job":"showcase_health_sweep","handler":"sweepProjectHealth"} |
| 15 | + * ``` |
| 16 | + * |
| 17 | + * and the "Nightly Project Health Sweep" never ran (#4774 / #4888). A scheduled |
| 18 | + * job is one of the capabilities the showcase exists to demonstrate end to end, |
| 19 | + * so the fix is a real implementation rather than deleting the declaration — |
| 20 | + * "never advertise a capability the runtime doesn't deliver" (AGENTS.md Prime |
| 21 | + * Directive #10) cuts both ways. |
| 22 | + * |
| 23 | + * ## Why the engine handle is captured rather than passed in |
| 24 | + * |
| 25 | + * A job handler is resolved through the SAME `defineStack({ functions })` |
| 26 | + * registry as a `script` flow node (`collectBundleFunctions` in |
| 27 | + * `@objectstack/runtime`), and the job service invokes it with |
| 28 | + * `{ jobId, data }` — `IJobService`'s `JobHandler` context — plus the `bundle` |
| 29 | + * the AppPlugin adds. There is deliberately no data engine in that context: a |
| 30 | + * flow function is PURE by default, returning a value a later declarative node |
| 31 | + * persists (#4343 / #4396). |
| 32 | + * |
| 33 | + * A background job is the case that contract does not cover — nothing |
| 34 | + * downstream is going to persist for it — so it does its own I/O over a handle |
| 35 | + * captured at `onEnable`, and DECLARES that in the `functions` map with |
| 36 | + * `effect: 'writes'` (#4396). That declaration grants nothing; it tells the |
| 37 | + * platform this callable's writes are not counted by the caller, so a run |
| 38 | + * reports "cannot say" instead of silently claiming it wrote nothing. |
| 39 | + * |
| 40 | + * ## What it computes |
| 41 | + * |
| 42 | + * Health is budget burn measured against delivered progress — the drift between |
| 43 | + * the money spent and the work finished: |
| 44 | + * |
| 45 | + * burn = spent / budget (0 when no budget is set) |
| 46 | + * done = mean(task.progress) / 100 (0 when the project has no tasks) |
| 47 | + * drift = burn - done |
| 48 | + * |
| 49 | + * red — over budget (burn > 1), or drift >= 0.30 |
| 50 | + * yellow — drift >= 0.15 |
| 51 | + * green — otherwise |
| 52 | + * |
| 53 | + * Only `active` / `on_hold` projects are swept: `planned` has nothing to burn |
| 54 | + * yet, and `completed` / `cancelled` are settled facts a nightly job must not |
| 55 | + * relitigate. Writes are limited to the projects whose health actually changed, |
| 56 | + * so a steady-state sweep performs zero updates. |
| 57 | + */ |
| 58 | + |
| 59 | +/** Statuses whose health is still in play. */ |
| 60 | +const SWEPT_STATUSES = ['active', 'on_hold'] as const; |
| 61 | + |
| 62 | +/** Drift at or above which a project turns red (spending far ahead of delivery). */ |
| 63 | +const RED_DRIFT = 0.3; |
| 64 | +/** Drift at or above which a project turns yellow. */ |
| 65 | +const YELLOW_DRIFT = 0.15; |
| 66 | + |
| 67 | +/** Bound on rows read per sweep — a demo dataset, read in one pass. */ |
| 68 | +const READ_LIMIT = 1000; |
| 69 | + |
| 70 | +const SYS = { isSystem: true } as const; |
| 71 | + |
| 72 | +type Health = 'green' | 'yellow' | 'red'; |
| 73 | + |
| 74 | +interface JobHostEngine { |
| 75 | + find: (object: string, query: unknown, options?: unknown) => Promise<unknown>; |
| 76 | + update: (object: string, data: Record<string, unknown>, options?: unknown) => Promise<unknown>; |
| 77 | +} |
| 78 | + |
| 79 | +interface JobHostContext { |
| 80 | + ql: JobHostEngine; |
| 81 | + logger?: { |
| 82 | + info?: (...a: unknown[]) => void; |
| 83 | + warn?: (...a: unknown[]) => void; |
| 84 | + }; |
| 85 | +} |
| 86 | + |
| 87 | +/** |
| 88 | + * The engine handle the job runs over, captured from the host context at |
| 89 | + * `onEnable`. Module scope is what makes it reachable from a `functions` entry, |
| 90 | + * which the job service calls with no context of its own — the "closed over a |
| 91 | + * client at module scope" shape `effect: 'writes'` exists to declare. |
| 92 | + */ |
| 93 | +let host: JobHostContext | undefined; |
| 94 | + |
| 95 | +/** |
| 96 | + * Give `sweepProjectHealth` its data handle. Called from `onEnable` in |
| 97 | + * `objectstack.config.ts`, which is the one place the app is handed a live |
| 98 | + * engine. Idempotent — a re-enable simply rebinds. |
| 99 | + */ |
| 100 | +export function bindShowcaseJobRuntime(ctx: JobHostContext): void { |
| 101 | + host = ctx; |
| 102 | +} |
| 103 | + |
| 104 | +/** Normalize the engine's list shape (array, or `{ records }`). */ |
| 105 | +function rowsOf(result: unknown): Array<Record<string, unknown>> { |
| 106 | + if (Array.isArray(result)) return result as Array<Record<string, unknown>>; |
| 107 | + const records = (result as { records?: unknown })?.records; |
| 108 | + return Array.isArray(records) ? (records as Array<Record<string, unknown>>) : []; |
| 109 | +} |
| 110 | + |
| 111 | +/** Read a numeric column defensively — a currency/progress column may arrive as a string. */ |
| 112 | +function num(value: unknown): number | undefined { |
| 113 | + if (typeof value === 'number' && Number.isFinite(value)) return value; |
| 114 | + if (typeof value === 'string' && value.trim() !== '') { |
| 115 | + const parsed = Number(value); |
| 116 | + if (Number.isFinite(parsed)) return parsed; |
| 117 | + } |
| 118 | + return undefined; |
| 119 | +} |
| 120 | + |
| 121 | +/** |
| 122 | + * The health verdict for one project — exported so the rule is unit-testable |
| 123 | + * without an engine (see `test/job-health-sweep.test.ts`). |
| 124 | + */ |
| 125 | +export function healthFor(input: { |
| 126 | + budget?: unknown; |
| 127 | + spent?: unknown; |
| 128 | + taskProgress: readonly number[]; |
| 129 | +}): Health { |
| 130 | + const budget = num(input.budget) ?? 0; |
| 131 | + const spent = num(input.spent) ?? 0; |
| 132 | + const burn = budget > 0 ? spent / budget : 0; |
| 133 | + if (burn > 1) return 'red'; |
| 134 | + |
| 135 | + const done = |
| 136 | + input.taskProgress.length > 0 |
| 137 | + ? input.taskProgress.reduce((sum, p) => sum + p, 0) / input.taskProgress.length / 100 |
| 138 | + : 0; |
| 139 | + |
| 140 | + const drift = burn - done; |
| 141 | + if (drift >= RED_DRIFT) return 'red'; |
| 142 | + if (drift >= YELLOW_DRIFT) return 'yellow'; |
| 143 | + return 'green'; |
| 144 | +} |
| 145 | + |
| 146 | +/** |
| 147 | + * Recompute `showcase_project.health` for every in-play project. |
| 148 | + * |
| 149 | + * Registered as `functions.sweepProjectHealth` with `effect: 'writes'` and |
| 150 | + * scheduled by `HealthSweepJob` (`0 1 * * *` UTC). |
| 151 | + */ |
| 152 | +export async function sweepProjectHealth(ctx?: { jobId?: string }): Promise<void> { |
| 153 | + const jobId = ctx?.jobId ?? 'showcase_health_sweep'; |
| 154 | + if (!host) { |
| 155 | + // Reached only if the job somehow fires before `onEnable` bound the |
| 156 | + // handle. Functional degradation, not a durability one: nothing claimed to |
| 157 | + // be persisted has been lost, and the next scheduled run recomputes |
| 158 | + // everything from scratch (AGENTS.md "Degradation log levels"). |
| 159 | + // eslint-disable-next-line no-console |
| 160 | + console.warn(`[showcase] ${jobId}: no engine handle bound yet — skipping this run`); |
| 161 | + return; |
| 162 | + } |
| 163 | + const { ql, logger } = host; |
| 164 | + |
| 165 | + const projects = rowsOf( |
| 166 | + await ql.find('showcase_project', { |
| 167 | + where: { status: { $in: [...SWEPT_STATUSES] } }, |
| 168 | + fields: ['id', 'status', 'health', 'budget', 'spent'], |
| 169 | + limit: READ_LIMIT, |
| 170 | + context: SYS, |
| 171 | + }), |
| 172 | + ); |
| 173 | + if (projects.length === 0) { |
| 174 | + logger?.info?.('[showcase] project health sweep: no in-play projects', { job: jobId }); |
| 175 | + return; |
| 176 | + } |
| 177 | + |
| 178 | + const projectIds = projects.map((p) => String(p.id)); |
| 179 | + const tasks = rowsOf( |
| 180 | + await ql.find('showcase_task', { |
| 181 | + where: { project: { $in: projectIds } }, |
| 182 | + fields: ['project', 'progress'], |
| 183 | + limit: READ_LIMIT, |
| 184 | + context: SYS, |
| 185 | + }), |
| 186 | + ); |
| 187 | + |
| 188 | + const progressByProject = new Map<string, number[]>(); |
| 189 | + for (const task of tasks) { |
| 190 | + const projectId = task.project == null ? '' : String(task.project); |
| 191 | + if (!projectId) continue; |
| 192 | + const progress = num(task.progress) ?? 0; |
| 193 | + const bucket = progressByProject.get(projectId); |
| 194 | + if (bucket) bucket.push(progress); |
| 195 | + else progressByProject.set(projectId, [progress]); |
| 196 | + } |
| 197 | + |
| 198 | + let updated = 0; |
| 199 | + for (const project of projects) { |
| 200 | + const id = String(project.id); |
| 201 | + const next = healthFor({ |
| 202 | + budget: project.budget, |
| 203 | + spent: project.spent, |
| 204 | + taskProgress: progressByProject.get(id) ?? [], |
| 205 | + }); |
| 206 | + if (next === project.health) continue; |
| 207 | + try { |
| 208 | + await ql.update('showcase_project', { id, health: next }, { context: SYS }); |
| 209 | + updated += 1; |
| 210 | + } catch (err) { |
| 211 | + logger?.warn?.('[showcase] project health update failed', { |
| 212 | + job: jobId, |
| 213 | + project: id, |
| 214 | + error: err instanceof Error ? err.message : String(err), |
| 215 | + }); |
| 216 | + } |
| 217 | + } |
| 218 | + |
| 219 | + logger?.info?.('[showcase] project health sweep complete', { |
| 220 | + job: jobId, |
| 221 | + scanned: projects.length, |
| 222 | + updated, |
| 223 | + }); |
| 224 | +} |
0 commit comments