|
8 | 8 | canonicalApproverType, |
9 | 9 | normalizeDecisionOutputs, |
10 | 10 | type ApprovalNodeConfig, |
| 11 | + // [#16433] The canonical run-status vocabulary, imported for its TYPE so the |
| 12 | + // liveness classification below is checked against it member for member. |
| 13 | + type ExecutionStatus, |
11 | 14 | } from '@objectstack/spec/automation'; |
12 | 15 | import { ExpressionEngine, collectCelRootIdentifiers } from '@objectstack/formula'; |
13 | 16 | // [#10101] The SHARED platform-row organization resolver — the cloud#1395 |
@@ -284,33 +287,147 @@ export const SLA_ACTOR_ID = 'system:sla'; |
284 | 287 | export const ESCALATION_ENABLED_FLIP_CUTOFF_MS = Date.parse('2026-08-28T00:00:00Z'); |
285 | 288 | /** Reserved actor id for requests abandoned because their run died (#3456). */ |
286 | 289 | export const DEAD_RUN_ACTOR_ID = 'system:dead-run'; |
| 290 | +/** How the dead-run sweep must treat one `ExecutionStatus` member. */ |
| 291 | +type RunLiveness = 'terminal' | 'live'; |
| 292 | + |
287 | 293 | /** |
288 | | - * Run statuses that mean "this run will never resume", so a request still |
289 | | - * pending on it is orphaned (#3456). A CLOSED set, deliberately: the dead-run |
290 | | - * sweep treats every other answer — `paused` (a run waiting on its approval, |
291 | | - * the normal case), `running`, an unknown status, or no answer at all — as |
292 | | - * alive, so an unrecognised state can never cost someone a live approval. |
| 294 | + * EVERY `ExecutionStatus` member (`@objectstack/spec`), classified for the |
| 295 | + * dead-run sweep: `'terminal'` means the run has stopped and will never resume, |
| 296 | + * so a request still pending on it is orphaned (#3456); `'live'` means it may |
| 297 | + * still move, and the sweep must leave it strictly alone. |
| 298 | + * |
| 299 | + * ## Why a total MAP and not a list of the terminal ones (#16433) |
| 300 | + * |
| 301 | + * This was a hand-copied FOUR-member subset — `completed`, `failed`, |
| 302 | + * `cancelled`, `timed_out` — written when `ExecutionStatus` had eight members |
| 303 | + * and correct on the day it was written. #14945 then appended `refused` |
| 304 | + * ("Terminal, never resumed"), and this subset did not grow with it: the sweep |
| 305 | + * `continue`d past a refused run, so a pending approval on it read ALIVE and |
| 306 | + * was never released. Nothing went red, because a subset that is missing a |
| 307 | + * member is still a valid subset. |
| 308 | + * |
| 309 | + * ⚠️ Note what #14945's author DID do, directly above that member: *"Appended |
| 310 | + * last so every reader that indexes `.options` keeps its positions."* That is a |
| 311 | + * real precaution, taken with downstream readers genuinely in mind — but it |
| 312 | + * protects readers that index the enum BY POSITION. **This reader hand-copied |
| 313 | + * a subset, which lives by CONTENT, and position-safety does nothing for it.** |
| 314 | + * ⇒ Enum-growth compatibility reasoning points naturally at order and index; |
| 315 | + * the copies that rot are the ones keyed on membership, and they rot silently. |
| 316 | + * |
| 317 | + * So the repair is not "add `refused`" — that produces a five-member hand-copy |
| 318 | + * with the identical trap re-armed for the tenth member. It is not "derive the |
| 319 | + * terminal set from the enum" either: `running` and `paused` are obviously not |
| 320 | + * terminal, and a wholesale derivation would make every FUTURE member terminal |
| 321 | + * by default — the same silent default pointing the other way, and pointing at |
| 322 | + * the worse outcome (releasing approvals out from under live runs). |
293 | 323 | * |
294 | | - * `completed` belongs here with the failure states. The approval node only |
295 | | - * writes a request row on the path where it also suspends the run, and every |
296 | | - * in-band transition (decide / recall / send-back / resubmit) finalises the |
297 | | - * request *before* it resumes the run — so a completed run with a still-pending |
298 | | - * request means the run was resumed out of band and left the request behind. |
| 324 | + * What this map is instead: a TOTAL function over the enum, so growth forces a |
| 325 | + * decision. `satisfies Record<ExecutionStatus, RunLiveness>` is exhaustive in |
| 326 | + * both directions — a tenth member added to `ExecutionStatus` with no entry |
| 327 | + * here fails to compile, and an entry for a member the enum dropped fails too. |
| 328 | + * `run-status-liveness.test.ts` asserts the same totality at RUNTIME against |
| 329 | + * `ExecutionStatus.options` and drives every member through the real sweep, so |
| 330 | + * the classification and the behaviour cannot drift apart either. |
| 331 | + * |
| 332 | + * ⛔ Terminality is NOT declared machine-readably anywhere today — `refused`'s |
| 333 | + * terminality lives in a COMMENT beside the enum member, and a comment is not a |
| 334 | + * gate. The `TERMINAL_RUN_STATUSES` exported by `@objectstack/service-automation` |
| 335 | + * is a DIFFERENT vocabulary (which terminal states a run may be RECORDED in, |
| 336 | + * tied to `sys_automation_run.status`' options) that excludes `refused` on |
| 337 | + * purpose, and that package is only a devDependency here. Hence a local total |
| 338 | + * map rather than a shared import; see the card for the spec-level proposal. |
| 339 | + * |
| 340 | + * `completed` is classified terminal alongside the failure states. The approval |
| 341 | + * node only writes a request row on the path where it also suspends the run, |
| 342 | + * and every in-band transition (decide / recall / send-back / resubmit) |
| 343 | + * finalises the request *before* it resumes the run — so a completed run with a |
| 344 | + * still-pending request means the run was resumed out of band and left the |
| 345 | + * request behind. |
| 346 | + * |
| 347 | + * `pending` / `running` / `paused` / `retrying` are live. `paused` especially: |
| 348 | + * that is a run waiting on its own approval, the normal case. |
299 | 349 | */ |
300 | | -const TERMINAL_RUN_STATUSES: ReadonlySet<string> = new Set([ |
301 | | - 'completed', 'failed', 'cancelled', 'timed_out', |
302 | | -]); |
| 350 | +export const RUN_STATUS_LIVENESS = { |
| 351 | + pending: 'live', // queued, has not started |
| 352 | + running: 'live', // executing right now |
| 353 | + paused: 'live', // parked at a wait/checkpoint — usually THIS approval |
| 354 | + retrying: 'live', // failed and will run again |
| 355 | + completed: 'terminal', // finished; a pending request means an out-of-band resume |
| 356 | + failed: 'terminal', // terminated with an error |
| 357 | + cancelled: 'terminal', // cancelled by hand |
| 358 | + timed_out: 'terminal', // exceeded its budget |
| 359 | + // [#14945 / #16433] The flow reached an `end` node declaring |
| 360 | + // `outcome: 'refused'` — a successful evaluation that said no. The enum |
| 361 | + // documents it "Terminal, never resumed"; this is that sentence made |
| 362 | + // machine-readable for the one reader that has to act on it. |
| 363 | + refused: 'terminal', |
| 364 | +} as const satisfies Record<ExecutionStatus, RunLiveness>; |
| 365 | + |
| 366 | +/** |
| 367 | + * Run statuses that mean "this run will never resume" — DERIVED from |
| 368 | + * {@link RUN_STATUS_LIVENESS}, never re-typed (#16433). |
| 369 | + * |
| 370 | + * Still a closed set at the point of use, and deliberately so: the sweep treats |
| 371 | + * every other answer — a live status, an unknown status, or no answer at all — |
| 372 | + * as alive, so an unrecognised state can never cost someone a live approval. |
| 373 | + */ |
| 374 | +const TERMINAL_RUN_STATUSES: ReadonlySet<string> = new Set( |
| 375 | + Object.entries(RUN_STATUS_LIVENESS) |
| 376 | + .filter(([, liveness]) => liveness === 'terminal') |
| 377 | + .map(([status]) => status), |
| 378 | +); |
| 379 | + |
| 380 | +/** Whether one {@link ApprovalStatus} can leave a zombie behind (#4469). */ |
| 381 | +type RequestStrandability = 'strandable' | 'not-strandable'; |
303 | 382 |
|
304 | 383 | /** |
305 | | - * Request statuses that can leave a ZOMBIE behind (#4469) — the terminal states |
306 | | - * a decision reaches only by ALSO resuming the owning run. |
| 384 | + * EVERY {@link APPROVAL_STATUSES} member, classified for the stranded-request |
| 385 | + * scan: `'strandable'` are the terminal states a decision reaches only by ALSO |
| 386 | + * resuming the owning run (#4469), so a row sitting in one with its run still |
| 387 | + * parked is a zombie. |
| 388 | + * |
| 389 | + * ## The same construction as {@link RUN_STATUS_LIVENESS}, for the same reason |
307 | 390 | * |
308 | | - * `recalled` is deliberately absent: a recall ABANDONS the request on purpose, |
309 | | - * and {@link ApprovalService.recall} explicitly tolerates a run it cannot |
310 | | - * resume (the withdrawal and the lock release are the point). Reporting those |
311 | | - * would bury the real findings under expected ones. |
| 391 | + * [#16433] This was the second hand-copied subset of a growing enum in this |
| 392 | + * file, and the card that fixed the first one asked for a reading of it rather |
| 393 | + * than an assumption. The reading: `APPROVAL_STATUSES` gained `cancelled` |
| 394 | + * (#13568) AFTER this subset was written, and `cancelled` is correctly outside |
| 395 | + * it — {@link ApprovalService.cancelForDeletedRecord} does NOT resume |
| 396 | + * the run, says so in its own `warn` naming the parked run ids, and reporting |
| 397 | + * those here would bury the real findings under expected ones, exactly the |
| 398 | + * `recalled` argument. ⇒ the VALUE was right; only the MECHANISM was the |
| 399 | + * hand-copy, and nothing recorded that `cancelled` had been considered at all, |
| 400 | + * so the next reader could not tell "weighed and excluded" from "written before |
| 401 | + * it existed". |
| 402 | + * |
| 403 | + * The derived list below is byte-identical to the four-year-old literal |
| 404 | + * (`['approved', 'rejected', 'returned']`, in that order, which the `$in` at |
| 405 | + * the call site depends on). What changed is that a SEVENTH approval status now |
| 406 | + * fails to compile until someone classifies it. |
| 407 | + * |
| 408 | + * `recalled` is deliberately not strandable: a recall ABANDONS the request on |
| 409 | + * purpose, and {@link ApprovalService.recall} explicitly tolerates a run it |
| 410 | + * cannot resume (the withdrawal and the lock release are the point). |
| 411 | + * `pending` is not terminal at all. |
| 412 | + */ |
| 413 | +export const REQUEST_STATUS_STRANDABILITY = { |
| 414 | + pending: 'not-strandable', // not terminal — still awaiting a decision |
| 415 | + approved: 'strandable', // a decision that should have resumed the run |
| 416 | + rejected: 'strandable', // idem |
| 417 | + recalled: 'not-strandable', // deliberate abandonment; tolerates an unresumable run |
| 418 | + returned: 'strandable', // send-back resumes down the `revise` edge |
| 419 | + cancelled: 'not-strandable', // [#13568] platform void; never resumes, and says so |
| 420 | +} as const satisfies Record<ApprovalStatus, RequestStrandability>; |
| 421 | + |
| 422 | +/** |
| 423 | + * The strandable statuses, DERIVED from {@link REQUEST_STATUS_STRANDABILITY} |
| 424 | + * and never re-typed (#16433). Declaration order follows `APPROVAL_STATUSES`. |
312 | 425 | */ |
313 | | -const STRANDABLE_REQUEST_STATUSES = ['approved', 'rejected', 'returned'] as const; |
| 426 | +const STRANDABLE_REQUEST_STATUSES: readonly ApprovalStatus[] = Object.entries( |
| 427 | + REQUEST_STATUS_STRANDABILITY, |
| 428 | +) |
| 429 | + .filter(([, strandability]) => strandability === 'strandable') |
| 430 | + .map(([status]) => status as ApprovalStatus); |
314 | 431 |
|
315 | 432 | /** |
316 | 433 | * Where {@link ApprovalService.journalStrandedContinuation} keeps the signal a |
|
0 commit comments