// nano-workforce — the app's single accessor for the `instanceTracking` derived read models // (ADR 0065, the writer→source inversion adopted with `@nanobpm/urban@0.81.0`). // // Since ADR 0065 the `instanceTracking` reconciler is a SOURCE, not a writer: on each poll it feeds // engine truth into urban's canonical projections (`urban_instance_state`, `urban_open_user_tasks`) // and NO LONGER writes the terminal (`onTerminated.set`) / wait-on-human (`onWaitingHuman.set`) // edges onto the app's base row. Those edges are now DERIVED — recomputed on every read — by an // auto-provisioned managed VIEW `__tracking` whose `derived_status` column is // `CASE WHEN terminated THEN WHEN waiting-human THEN // ELSE base. END`. So the base `statusField` keeps only the worker-owned transient // status, and any reader that used to rely on the reconciler having written the terminal status // onto the base row must read `derived_status` off the VIEW instead. // // This module is the ONE place that: // - parses the `instanceTracking` bindings from `nano.app.json` (the single source of truth), and // - resolves each binding's derived VIEW name + `derived_status` column via urban's OWN target // resolver (`instanceTrackingReadModelTarget`), so the app can never drift from the framework's // view naming. // // Writers are unchanged: a service-task worker that owns a terminal outcome (`converged`, `merged`, // `done`, …) still writes it to the base `data.table(
)`. Only readers that classify on the // RECONCILER-derived edge (terminated → abandoned/failed/reviewed, or waiting-human → // awaiting_operator) route through the derived VIEW here. import { readFileSync } from "node:fs"; import { type AppManifest, type DataLayer, type InstanceTracking, instanceTrackingReadModelTarget, type Table, } from "@nanobpm/urban"; /** The app manifest, parsed exactly ONCE at module load, typed by urban's own `AppManifest` so the * binding shape can never drift from the framework's schema. */ const APP_MANIFEST: AppManifest = JSON.parse( readFileSync(new URL("../nano.app.json", import.meta.url), "utf8"), ); /** The app manifest's `instanceTracking` bindings — the single source of truth for the derived * read-model registry. */ const INSTANCE_TRACKING_BINDINGS: readonly InstanceTracking[] = APP_MANIFEST.instanceTracking ?? []; /** The single `instanceTracking` binding for a base table, or throw if the manifest has none. */ export function trackingBindingFor(table: string): InstanceTracking { const binding = INSTANCE_TRACKING_BINDINGS.find((b) => b.table === table); if (!binding) { throw new Error(`nano.app.json: no instanceTracking binding for table "${table}"`); } return binding; } /** A tracked table's parked-and-active statuses, from the single source of truth * (`instanceTracking.
.activeStatuses` in nano.app.json), so an app-side scan can never drift * from the reconciler's notion of "in-flight". Throws if the binding is missing/empty. */ export function activeStatusesFor(table: string): readonly string[] { const binding = trackingBindingFor(table); if (!binding.activeStatuses?.length) { throw new Error( `nano.app.json: instanceTracking[table="${table}"].activeStatuses is missing or empty`, ); } return binding.activeStatuses; } /** A tracked table's engine-instance key column (the `keyField` in nano.app.json — e.g. * `process_key`), the single source of truth the app-side reconcile probes/orphans by so it can * never drift from the reconciler's notion of "which column holds the engine instance key". */ export function keyFieldFor(table: string): string { return trackingBindingFor(table).keyField; } /** Every `instanceTracking` binding — the full registry of ENGINE-BACKED base tables (each row is * projected off a live engine process instance keyed by `keyField`). The app-side engine-reset * reconcile (app/reconcile.ts) scans exactly this set: a row whose `statusField` is still in the * binding's `activeStatuses` and whose `keyField` is populated is non-terminal engine-backed work, * the only surface reconcile may drive to `orphaned`. Terminal rows and non-engine-backed surfaces * (presence, append-only audit) are, by construction, not in this set and are never touched. */ export function engineBackedBindings(): readonly InstanceTracking[] { return INSTANCE_TRACKING_BINDINGS; } /** The managed derived read-model VIEW name + effective-status column for a base table, resolved by * urban's OWN target resolver so the app never drifts from the framework's `
__tracking` / * `derived_status` naming (ADR 0065). */ export function trackingTargetFor(table: string): { view: string; statusColumn: string } { return instanceTrackingReadModelTarget(trackingBindingFor(table)); } /** The base table a derived tracking VIEW projects, or undefined when `view` is not a tracking view. * The inverse of {@link trackingTargetFor}, resolved off the same binding registry so it can't drift * from the framework's view naming. */ export function baseTableForTrackingView(view: string): string | undefined { return INSTANCE_TRACKING_BINDINGS.find((b) => trackingTargetFor(b.table).view === view)?.table; } /** The base `statusField` a binding's derived edge falls through to when no terminal/wait edge * applies (the VIEW's `ELSE base.` branch). Defaults to `"status"`, mirroring urban. */ export function baseStatusFieldFor(table: string): string { return trackingBindingFor(table).statusField ?? "status"; } /** A read-only typed gateway over a tracked table's derived VIEW (`
__tracking`). The VIEW * re-exports `base.*` plus the derived `derived_status` column, so a row carries BOTH the base * transient `` and the effective (ADR-0065-derived) `derived_status`. Read * `derived_status` to classify on the terminal / wait-on-human edge; urban forbids writing a VIEW, * so use `data.table(
)` for writes. `T` should include `derived_status: string`. */ export function derivedTrackingTable( data: DataLayer, table: string, pk: string, ): Table { return data.table(trackingTargetFor(table).view, pk); } /** A tracked record resolved from an engine process-instance key: which binding's base table owns it, * its ADR-0065 `derived_status` (the terminal edge folded over the base transient), and whether that * derived status is still ACTIVE (in the binding's `activeStatuses`). `active === false` means the * record has LEFT Active — it is at a terminal edge (`abandoned`/`failed`/`reviewed`/…) and * resubmittable. */ export interface ResolvedTrackedInstance { table: string; keyField: string; derivedStatus: string; active: boolean; } /** Resolve which tracked record (if any) an engine process-instance key belongs to, scanning every * engine-backed binding and matching the key against each binding's `keyField` on its DERIVED * tracking VIEW — so the answer reflects the ADR-0065 terminal edge, not the frozen base transient. * This is the cancel door's record-type resolver (issue #705): it lets the door route a key to the * correct aggregate (PR vs plan vs feature run vs …) and report a TRUTHFUL reconciled result, and * distinguishes a key that maps to a tracked record (reconcile it) from one that maps to NONE (a * clean no-op, never a silent success). Returns `undefined` when no binding has a row for the key. * Reads only — the terminal transition itself is DERIVED (recorded through the shared * `reconcileTerminatedKey` seam), never hand-written here. */ export async function resolveTrackedInstance( data: DataLayer, processInstanceKey: string, ): Promise { for (const binding of engineBackedBindings()) { const table = binding.table; const keyField = keyFieldFor(table); const row = await derivedTrackingTable>(data, table, keyField).findOne({ [keyField]: processInstanceKey, }); if (!row) continue; const statusColumn = trackingTargetFor(table).statusColumn; const derivedStatus = String(row[statusColumn] ?? ""); // Classify against the manifest-enforced active set (throws on a binding whose `activeStatuses` // is missing/empty) rather than a silent `?? []` — an empty fallback would misclassify EVERY // resolved record as terminal (`active:false`) and let the cancel door report a false success. const active = activeStatusesFor(table).some((s) => s === derivedStatus); return { table, keyField, derivedStatus, active }; } return undefined; }