@nanobpm/urban 0.64.0 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/context/binding/backend.d.ts +58 -0
  2. package/dist/context/binding/backend.js +15 -0
  3. package/dist/context/binding/descriptor.d.ts +51 -0
  4. package/dist/context/binding/descriptor.js +62 -0
  5. package/dist/context/binding/git-backend.d.ts +65 -0
  6. package/dist/context/binding/git-backend.js +318 -0
  7. package/dist/context/binding/identity.d.ts +42 -0
  8. package/dist/context/binding/identity.js +159 -0
  9. package/dist/context/binding/index.d.ts +5 -1
  10. package/dist/context/binding/index.js +14 -1
  11. package/dist/context/binding/resolver.d.ts +61 -0
  12. package/dist/context/binding/resolver.js +125 -0
  13. package/dist/runtime/core/manifest.d.ts +24 -1
  14. package/dist/runtime/core/modules/instance-tracking.d.ts +14 -0
  15. package/dist/runtime/core/modules/instance-tracking.js +108 -11
  16. package/dist/runtime/core/validate.js +15 -0
  17. package/package.json +1 -1
  18. package/src/context/binding/backend.ts +79 -0
  19. package/src/context/binding/descriptor.test.ts +51 -0
  20. package/src/context/binding/descriptor.ts +108 -0
  21. package/src/context/binding/git-backend.ts +368 -0
  22. package/src/context/binding/identity.test.ts +165 -0
  23. package/src/context/binding/identity.ts +206 -0
  24. package/src/context/binding/index.ts +46 -2
  25. package/src/context/binding/resolver.test.ts +713 -0
  26. package/src/context/binding/resolver.ts +176 -0
  27. package/src/runtime/core/manifest.ts +22 -1
  28. package/src/runtime/core/modules/instance-tracking.test.ts +192 -6
  29. package/src/runtime/core/modules/instance-tracking.ts +115 -11
  30. package/src/runtime/core/validate.test.ts +46 -0
  31. package/src/runtime/core/validate.ts +15 -0
@@ -0,0 +1,176 @@
1
+ // Slice S1 (binding) — the resolver: bind → validate → identity → resolved
2
+ // handle, with private-per-app / shared-on-same-name semantics enforced two
3
+ // ways:
4
+ //
5
+ // 1. In-process: a ContextResolver memoises by identity key, so two resolves of
6
+ // the same name return the SAME handle (and never double-clone), while
7
+ // distinct names get distinct handles.
8
+ // 2. Across processes/instances: the on-disk location is derived
9
+ // deterministically from the identity slug under a shared cache root, so two
10
+ // Nano instances naming the same context share one working copy on disk.
11
+ // Known first-use limitation (deferred past slice S1): the single-flight in
12
+ // (1) is in-process only, so concurrent *first* resolves of the same context
13
+ // across instances can race materialisation (clone/pin). Sharing is reliable
14
+ // once the substrate exists; a follow-up slice adds an inter-process lock to
15
+ // make concurrent first-use safe too — see GitSubstrateBackend.materialise.
16
+
17
+ import { homedir, tmpdir } from "node:os";
18
+ import { join, resolve as resolvePath } from "node:path";
19
+ import { parseContextBinding } from "./descriptor.ts";
20
+ import { resolveContextIdentity } from "./identity.ts";
21
+ import { GitSubstrateBackend } from "./git-backend.ts";
22
+ import type { ResolvedContextHandle, SubstrateBackend } from "./backend.ts";
23
+
24
+ /** Options for a {@link ContextResolver}. */
25
+ export interface ContextResolverOptions {
26
+ /**
27
+ * Root directory under which per-context working copies are cloned. Two
28
+ * resolvers sharing a `cacheRoot` share substrates on disk. Defaults to
29
+ * {@link defaultContextCacheRoot}.
30
+ */
31
+ readonly cacheRoot?: string;
32
+ /**
33
+ * The substrate backend. Defaults to a git backend ({@link GitSubstrateBackend}).
34
+ * Injecting a different backend is the seam for a future PII/mutable or
35
+ * self-hosted substrate.
36
+ */
37
+ readonly backend?: SubstrateBackend;
38
+ }
39
+
40
+ /** Per-resolution overrides. */
41
+ export interface ResolveOptions {
42
+ /**
43
+ * Controls whether a re-resolution re-fetches the substrate. Note the
44
+ * {@link ContextResolver} memoises per identity: once an identity has resolved,
45
+ * repeat resolves return the cached handle and do **not** re-fetch unless you
46
+ * explicitly pass `refresh: true`, which forces a fresh materialisation (a
47
+ * fetch + re-pin on the backend). While a materialisation is still in flight,
48
+ * concurrent resolves — including `refresh: true` — coalesce onto it rather
49
+ * than starting a second one. The backend's own default (fetch on an existing
50
+ * clone) therefore only applies the first time an identity is materialised or
51
+ * when `refresh: true` is passed. Defaults to `undefined` (reuse the memoised
52
+ * handle).
53
+ */
54
+ readonly refresh?: boolean;
55
+ }
56
+
57
+ /**
58
+ * The default cache root for context working copies. Honours
59
+ * `URBAN_CONTEXT_CACHE_DIR`, then `XDG_CACHE_HOME`, then `~/.cache`, falling
60
+ * back to the OS temp dir when no home directory is available.
61
+ */
62
+ export function defaultContextCacheRoot(
63
+ env: Record<string, string | undefined> = process.env,
64
+ ): string {
65
+ if (env.URBAN_CONTEXT_CACHE_DIR) return env.URBAN_CONTEXT_CACHE_DIR;
66
+ if (env.XDG_CACHE_HOME) return join(env.XDG_CACHE_HOME, "urban", "context");
67
+ const home = safeHomedir();
68
+ if (home) return join(home, ".cache", "urban", "context");
69
+ return join(tmpdir(), "urban", "context");
70
+ }
71
+
72
+ /** `os.homedir()`, but tolerant: returns `undefined` if the home dir can't be
73
+ * determined (Node can throw here) so callers fall back to the temp dir. */
74
+ function safeHomedir(): string | undefined {
75
+ try {
76
+ return homedir() || undefined;
77
+ } catch {
78
+ return undefined;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Resolves context bindings to concrete local working copies. A single resolver
84
+ * instance enforces shared-on-same-name in-process by memoising per identity
85
+ * key; distinct names stay private.
86
+ */
87
+ export class ContextResolver {
88
+ readonly #cacheRoot: string;
89
+ readonly #backend: SubstrateBackend;
90
+ // Per-identity resolution record. Despite serving the single-flight guarantee,
91
+ // this map is NOT purged once a resolution settles: the settled record is
92
+ // retained as the long-lived memoisation cache backing shared-on-same-name, so
93
+ // repeat resolves return the identical handle. Entries are removed only on
94
+ // rejection (so a failed materialise can be retried); a fulfilled entry lives
95
+ // for the resolver's lifetime. Do not add "drop settled entries" cleanup here
96
+ // without also giving up memoisation.
97
+ readonly #resolutions = new Map<string, { promise: Promise<ResolvedContextHandle>; settled: boolean }>();
98
+
99
+ constructor(options: ContextResolverOptions = {}) {
100
+ // Normalise to an absolute path so a relative `cacheRoot` (or a relative
101
+ // `URBAN_CONTEXT_CACHE_DIR`) still yields an absolute `localPath`, honouring
102
+ // `SubstrateResolveOptions.localPath` ("Absolute path…"). Note this resolves
103
+ // a relative `cacheRoot` against *this* process's CWD, so the cross-process
104
+ // sharing promised in the module header only holds for an ABSOLUTE
105
+ // `cacheRoot`; a relative one is shared only among instances that happen to
106
+ // share a working directory.
107
+ this.#cacheRoot = resolvePath(options.cacheRoot ?? defaultContextCacheRoot());
108
+ this.#backend = options.backend ?? new GitSubstrateBackend();
109
+ }
110
+
111
+ /** The cache root this resolver clones into. */
112
+ get cacheRoot(): string {
113
+ return this.#cacheRoot;
114
+ }
115
+
116
+ /**
117
+ * Resolve a binding (validated first) to a {@link ResolvedContextHandle}. Two
118
+ * bindings that name the same context return the identical handle from this
119
+ * resolver; distinct names return distinct handles.
120
+ */
121
+ async resolve(
122
+ binding: unknown,
123
+ options: ResolveOptions = {},
124
+ ): Promise<ResolvedContextHandle> {
125
+ const parsed = parseContextBinding(binding);
126
+ const identity = resolveContextIdentity(parsed);
127
+ const localPath = join(this.#cacheRoot, identity.slug);
128
+
129
+ // Shared-on-same-name: concurrent or repeated resolves of one identity share
130
+ // a single materialisation. Coalesce onto an in-flight one regardless of
131
+ // `refresh` — it is already producing a fresh working copy, so starting a
132
+ // second `materialise()` would double-clone into the same `localPath` and
133
+ // break the single-in-flight guarantee. Only re-materialise once the prior
134
+ // resolution has settled AND the caller explicitly asked to `refresh`.
135
+ const existing = this.#resolutions.get(identity.key);
136
+ if (existing && (!existing.settled || options.refresh !== true)) {
137
+ return existing.promise;
138
+ }
139
+
140
+ const record: { promise: Promise<ResolvedContextHandle>; settled: boolean } = {
141
+ promise: this.#backend
142
+ .materialise(identity, { localPath, refresh: options.refresh })
143
+ .catch((error) => {
144
+ if (this.#resolutions.get(identity.key) === record) {
145
+ this.#resolutions.delete(identity.key);
146
+ }
147
+ throw error;
148
+ }),
149
+ settled: false,
150
+ };
151
+ this.#resolutions.set(identity.key, record);
152
+ record.promise.then(
153
+ () => {
154
+ record.settled = true;
155
+ },
156
+ () => {
157
+ // Rejection is surfaced to the caller via the returned promise; the map
158
+ // entry is already removed in the catch above.
159
+ },
160
+ );
161
+ return record.promise;
162
+ }
163
+ }
164
+
165
+ /**
166
+ * One-shot convenience: resolve a single binding with a fresh resolver. Prefer a
167
+ * long-lived {@link ContextResolver} when resolving many bindings so sharing is
168
+ * memoised across calls.
169
+ */
170
+ export function resolveContextBinding(
171
+ binding: unknown,
172
+ options: ContextResolverOptions & ResolveOptions = {},
173
+ ): Promise<ResolvedContextHandle> {
174
+ const { cacheRoot, backend, ...resolveOptions } = options;
175
+ return new ContextResolver({ cacheRoot, backend }).resolve(binding, resolveOptions);
176
+ }
@@ -39,7 +39,8 @@ export type {
39
39
  } from "@nanobpm/nano-app-schema";
40
40
 
41
41
  /**
42
- * `instanceTracking` binding, bridged to add the fail-open `terminalStatuses` selector.
42
+ * `instanceTracking` binding, bridged to add the fail-open `terminalStatuses` selector and the
43
+ * `onWaitingHuman` wait-on-human edge (issue #355).
43
44
  *
44
45
  * `terminalStatuses` is landing in the canonical schema (`@nanobpm/nano-app-schema`,
45
46
  * Magikcraft/nano-bpm#769): an exclusion list — the runtime polls every row whose
@@ -53,6 +54,26 @@ export type InstanceTracking = SchemaInstanceTracking & {
53
54
  /** Values of `statusField` considered finished. When set, every row NOT in one of these
54
55
  * is polled (fail-open). Requires `statusField`; mutually exclusive with `activeStatuses`. */
55
56
  readonly terminalStatuses?: readonly string[];
57
+ /**
58
+ * The reconciliation applied to a row whose instance is **parked waiting on a human** — the
59
+ * wait-state twin of {@link SchemaInstanceTracking.onTerminated} (issue #355). An instance is
60
+ * waiting on a human *iff* it has an open user task, so the runtime derives this edge from
61
+ * engine truth (`openUserTasks`) rather than from a written column: on each poll, an active,
62
+ * non-terminated row whose instance has any open user task has this patch applied (typically
63
+ * `status = "awaiting_operator"`). Terminated wins over waiting-human (the {@link
64
+ * SchemaInstanceTracking.onTerminated} edge is applied first and excludes the key from this
65
+ * pass); a row with neither a terminated instance nor an open user task is left untouched, so
66
+ * the worker-owned transient status (running / converging / merging) survives.
67
+ *
68
+ * Optional and, like `terminalStatuses`, bridged locally until the canonical schema
69
+ * (`@nanobpm/nano-app-schema`) folds it in; drop this augmentation and re-export once the dep
70
+ * bump lands (No Drift Surfaces).
71
+ */
72
+ readonly onWaitingHuman?: {
73
+ /** Column → literal value patch written to a row whose instance has an open user task
74
+ * (e.g. set the status to a "waiting on human" value). */
75
+ readonly set: { readonly [k: string]: string | number | boolean | null };
76
+ };
56
77
  };
57
78
 
58
79
  /**
@@ -9,11 +9,12 @@ import { makeGateway } from "./gateway.ts";
9
9
  import type { Table } from "./gateway.ts";
10
10
  import { DataLayer, type ProvisionedSource } from "./datasource.ts";
11
11
  import type { AppApi } from "../context.ts";
12
- import type { EngineClient, ProcessInstanceSnapshot } from "../host.ts";
12
+ import type { EngineClient, ProcessInstanceSnapshot, UserTaskFilter, UserTaskSummary } from "../host.ts";
13
13
  import type { InstanceTracking } from "../manifest.ts";
14
14
  import {
15
15
  DEFAULT_INSTANCE_TRACKING_POLL_MS,
16
16
  mountInstanceTracking,
17
+ WAITING_HUMAN_PROBE_CONCURRENCY,
17
18
  } from "./instance-tracking.ts";
18
19
  import { MAX_TIMER_DELAY_MS, type SchedulerDeps } from "./scheduler.ts";
19
20
 
@@ -63,13 +64,21 @@ function fakeScheduler(): SchedulerDeps & { advance: (ms: number) => Promise<voi
63
64
  };
64
65
  }
65
66
 
66
- /** An EngineClient whose only live method is searchProcessInstances, driven by a fixed map of
67
- * processInstanceKey → state. Records the keys asked for on each call. */
68
- function fakeEngine(states: Record<string, ProcessInstanceSnapshot["state"]>): {
67
+ /** An EngineClient driven by a fixed map of processInstanceKey → state (for searchProcessInstances)
68
+ * and an optional map of processInstanceKey → number-of-open-user-tasks (for openUserTasks, the
69
+ * wait-on-human edge). Records the keys asked for on each call. `openUserTasks` models the engine
70
+ * truth the reconciler derives `awaiting_operator` from — only *open* (CREATED) tasks count, so a
71
+ * key absent from `openTasks` (or mapped to 0) reports no parked human. */
72
+ function fakeEngine(
73
+ states: Record<string, ProcessInstanceSnapshot["state"]>,
74
+ openTasks: Record<string, number> = {},
75
+ ): {
69
76
  engine: EngineClient;
70
77
  queries: string[][];
78
+ userTaskQueries: (string | undefined)[];
71
79
  } {
72
80
  const queries: string[][] = [];
81
+ const userTaskQueries: (string | undefined)[] = [];
73
82
  const notUsed = (m: string) => (): never => {
74
83
  throw new Error(`fakeEngine.${m} is not exercised by this test`);
75
84
  };
@@ -91,18 +100,25 @@ function fakeEngine(states: Record<string, ProcessInstanceSnapshot["state"]>): {
91
100
  }
92
101
  return out;
93
102
  },
103
+ async openUserTasks(filter?: UserTaskFilter): Promise<UserTaskSummary[]> {
104
+ const key = filter?.processInstanceKey;
105
+ userTaskQueries.push(key);
106
+ const n = key ? (openTasks[key] ?? 0) : 0;
107
+ return Array.from({ length: n }, (_unused, i): UserTaskSummary => ({
108
+ userTaskKey: `${key}-t${i}`,
109
+ }));
110
+ },
94
111
  deployResources: notUsed("deployResources"),
95
112
  createInstance: notUsed("createInstance"),
96
113
  cancelInstance: notUsed("cancelInstance"),
97
114
  publishMessage: notUsed("publishMessage"),
98
115
  searchUserTasks: notUsed("searchUserTasks"),
99
- openUserTasks: notUsed("openUserTasks"),
100
116
  getForm: notUsed("getForm"),
101
117
  completeUserTask: notUsed("completeUserTask"),
102
118
  registerWorker: notUsed("registerWorker"),
103
119
  close: notUsed("close"),
104
120
  };
105
- return { engine, queries };
121
+ return { engine, queries, userTaskQueries };
106
122
  }
107
123
 
108
124
  interface Harness {
@@ -428,6 +444,176 @@ for (const bad of [0, -1000, Number.NaN]) {
428
444
  }
429
445
 
430
446
 
447
+ // ── Wait-on-human edge (onWaitingHuman, issue #355) ────────────────────────────────────────────
448
+
449
+ // A binding that reconciles both engine-truth edges: terminal (onTerminated) and wait-on-human
450
+ // (onWaitingHuman). terminalStatuses (fail-open) keeps every non-abandoned row polled so a stale
451
+ // worker-written transient status (converging/merging/running) is still reconciled to awaiting.
452
+ const waitingBinding = (over: Partial<InstanceTracking> = {}): InstanceTracking => ({
453
+ table: "plans",
454
+ keyField: "process_key",
455
+ statusField: "status",
456
+ terminalStatuses: ["abandoned"],
457
+ onTerminated: { set: { status: "abandoned", note: null } },
458
+ onWaitingHuman: { set: { status: "awaiting_operator" } },
459
+ pollMs: 1000,
460
+ ...over,
461
+ });
462
+
463
+ test("derives awaiting_operator for a row whose instance has an open user task (forward)", async () => {
464
+ // The row carries a STALE worker-written transient status; the instance is parked at a user task.
465
+ // One reconcile pass must read it back as awaiting_operator — derived, not written.
466
+ const { engine } = fakeEngine({ pi1: "ACTIVE" }, { pi1: 1 });
467
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
468
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "converging", note: null });
469
+ });
470
+ const sched = fakeScheduler();
471
+ const handle = mount(h, [waitingBinding()], sched);
472
+ await sched.advance(1000);
473
+ assert.equal((await h.table.get("pi1"))?.status, "awaiting_operator");
474
+ await handle.stop();
475
+ await h.close();
476
+ });
477
+
478
+ test("leaves the worker-owned transient status when the instance has NO open user task (backward)", async () => {
479
+ // No open user task and not terminated ⇒ the reconciler must not force awaiting_operator; the
480
+ // worker-owned transient status survives. This is the "must not remain awaiting_operator once the
481
+ // task completes" leg: a just-answered instance (task completed, worker wrote converging) is not
482
+ // re-latched to awaiting_operator.
483
+ const { engine } = fakeEngine({ pi1: "ACTIVE" }, { pi1: 0 });
484
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
485
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "converging", note: null });
486
+ });
487
+ const sched = fakeScheduler();
488
+ const handle = mount(h, [waitingBinding()], sched);
489
+ await sched.advance(1000);
490
+ assert.equal((await h.table.get("pi1"))?.status, "converging");
491
+ await handle.stop();
492
+ await h.close();
493
+ });
494
+
495
+ test("re-escalation after an answer re-flips to awaiting_operator on the next poll (instance A)", async () => {
496
+ // The re-escalation-after-answer defect (nano-workforce#318): the row sits at converging after an
497
+ // answer; a re-escalation opens a NEW user task. Because the status is DERIVED every tick, the
498
+ // next poll re-flips it to awaiting_operator by construction — no bespoke poller, no stuck state.
499
+ const openTasks: Record<string, number> = { pi1: 0 }; // answered: task completed, none open
500
+ const { engine } = fakeEngine({ pi1: "ACTIVE" }, openTasks);
501
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
502
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "converging", note: null });
503
+ });
504
+ const sched = fakeScheduler();
505
+ const handle = mount(h, [waitingBinding()], sched);
506
+ await sched.advance(1000);
507
+ assert.equal((await h.table.get("pi1"))?.status, "converging"); // still converging: no open task
508
+ openTasks.pi1 = 1; // re-escalation: a new user task opens
509
+ await sched.advance(1000);
510
+ assert.equal((await h.table.get("pi1"))?.status, "awaiting_operator"); // re-flipped
511
+ await handle.stop();
512
+ await h.close();
513
+ });
514
+
515
+ test("terminated wins over an open user task (precedence)", async () => {
516
+ // A terminated instance that also (transiently) reports an open user task must reconcile to the
517
+ // terminal patch, never awaiting_operator — onTerminated has precedence.
518
+ const { engine, userTaskQueries } = fakeEngine({ pi1: "TERMINATED" }, { pi1: 1 });
519
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
520
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "converging", note: "x" });
521
+ });
522
+ const sched = fakeScheduler();
523
+ const handle = mount(h, [waitingBinding()], sched);
524
+ await sched.advance(1000);
525
+ const row = await h.table.get("pi1");
526
+ assert.equal(row?.status, "abandoned");
527
+ assert.equal(row?.note, null);
528
+ assert.deepEqual(userTaskQueries, []); // terminated key was excluded before the openUserTasks probe
529
+ await handle.stop();
530
+ await h.close();
531
+ });
532
+
533
+ test("does not re-write or re-log a row already at awaiting_operator (quiet-idempotent)", async () => {
534
+ // A long-parked instance is re-polled every tick. The wait-on-human writer must be a no-op when
535
+ // the row already carries the patch, so it does not re-log on every poll.
536
+ const { engine } = fakeEngine({ pi1: "ACTIVE" }, { pi1: 1 });
537
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
538
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "awaiting_operator", note: null });
539
+ });
540
+ const sched = fakeScheduler();
541
+ const handle = mount(h, [waitingBinding()], sched);
542
+ await sched.advance(1000);
543
+ await sched.advance(1000);
544
+ assert.equal((await h.table.get("pi1"))?.status, "awaiting_operator");
545
+ const awaitingLogs = h.logs.filter((l) => l.msg.includes("awaiting operator"));
546
+ assert.equal(awaitingLogs.length, 0); // no-op patch never logged
547
+ await handle.stop();
548
+ await h.close();
549
+ });
550
+
551
+ test("a binding without onWaitingHuman never probes open user tasks (edge is opt-in)", async () => {
552
+ const { engine, userTaskQueries } = fakeEngine({ pi1: "ACTIVE" }, { pi1: 1 });
553
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
554
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: null });
555
+ });
556
+ const sched = fakeScheduler();
557
+ const handle = mount(h, [planBinding()], sched); // planBinding has no onWaitingHuman
558
+ await sched.advance(1000);
559
+ assert.deepEqual(userTaskQueries, []); // never probed
560
+ assert.equal((await h.table.get("pi1"))?.status, "dispatched"); // untouched
561
+ await handle.stop();
562
+ await h.close();
563
+ });
564
+
565
+ test("reconciles EVERY row sharing a non-unique key, even when one is already patched (multi-row)", async () => {
566
+ // `keyField` (process_key) is NOT unique here, and table.update patches ALL matching rows. The
567
+ // quiet-idempotence check must consider every matching row, not a single get() (LIMIT 1): with two
568
+ // rows on the same process_key — one already awaiting_operator, one still converging — a LIMIT-1
569
+ // probe that hits the patched row would skip the write and strand the other. Both must converge.
570
+ const { engine } = fakeEngine({ pi1: "ACTIVE" }, { pi1: 1 });
571
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
572
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "awaiting_operator", note: null });
573
+ await t.insert({ plan_key: "p2", process_key: "pi1", status: "converging", note: null });
574
+ });
575
+ const sched = fakeScheduler();
576
+ const handle = mount(h, [waitingBinding()], sched);
577
+ await sched.advance(1000);
578
+ const rows = await h.table.find({ process_key: "pi1" });
579
+ assert.deepEqual(
580
+ rows.map((r) => r.status).sort(),
581
+ ["awaiting_operator", "awaiting_operator"],
582
+ );
583
+ await handle.stop();
584
+ await h.close();
585
+ });
586
+
587
+ test("reconciles every parked key across multiple probe batches (bounded-parallel probing)", async () => {
588
+ // More active keys than WAITING_HUMAN_PROBE_CONCURRENCY forces multiple probe batches. Every key
589
+ // with an open user task must still be probed and reconciled — the batching must not drop keys.
590
+ const total = WAITING_HUMAN_PROBE_CONCURRENCY * 2 + 3; // spans three batches
591
+ const states: Record<string, ProcessInstanceSnapshot["state"]> = {};
592
+ const openTasks: Record<string, number> = {};
593
+ for (let i = 0; i < total; i++) {
594
+ states[`pi${i}`] = "ACTIVE";
595
+ openTasks[`pi${i}`] = i % 2; // half parked on a human, half not
596
+ }
597
+ const { engine, userTaskQueries } = fakeEngine(states, openTasks);
598
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
599
+ for (let i = 0; i < total; i++) {
600
+ await t.insert({ plan_key: `p${i}`, process_key: `pi${i}`, status: "converging", note: null });
601
+ }
602
+ });
603
+ const sched = fakeScheduler();
604
+ const handle = mount(h, [waitingBinding()], sched);
605
+ await sched.advance(1000);
606
+ // Every active key was probed exactly once.
607
+ assert.equal(userTaskQueries.length, total);
608
+ assert.deepEqual([...userTaskQueries].sort(), Object.keys(states).sort());
609
+ for (let i = 0; i < total; i++) {
610
+ const expected = i % 2 === 1 ? "awaiting_operator" : "converging";
611
+ assert.equal((await h.table.get(`pi${i}`))?.status, expected, `pi${i}`);
612
+ }
613
+ await handle.stop();
614
+ await h.close();
615
+ });
616
+
431
617
  function mount(h: Harness, bindings: InstanceTracking[], sched: SchedulerDeps) {
432
618
  h.api.manifest.instanceTracking = bindings;
433
619
  return mountInstanceTracking(
@@ -1,17 +1,35 @@
1
1
  // instance-tracking — the process-instance lifecycle reconciler (manifest `instanceTracking`).
2
2
  //
3
3
  // The problem it closes: an Urban app derives a row's status from the workers that run
4
- // along a process's *happy path*. When an instance instead reaches a terminal state with
5
- // no completion job — an operator/`cancelInstance` termination, or a crash — no worker
6
- // runs, so the read-model row stays "active" forever and any UI derived from it lies.
4
+ // along a process's *happy path*. When an instance instead reaches an engine-truth state with
5
+ // no worker to record it, the read-model row lies. Two such edges are reconciled declaratively:
7
6
  //
8
- // Each `instanceTracking` binding names a datasource table whose rows track an instance by
9
- // a key column. This module polls the engine for those rows' instances and, when one is
10
- // `TERMINATED`, applies the binding's `onTerminated.set` patch to the row. It deliberately
11
- // reconciles only on `TERMINATED` — the state that by definition runs no completion worker
12
- // — and never on `COMPLETED`, whose terminal row-write the app's own finalize worker owns
13
- // (reconciling it here would race that worker and could clobber a legitimately-completed
14
- // row). The poll is driven through the shared `SchedulerDeps` seam so tests are deterministic.
7
+ // - The **terminal** edge (`onTerminated`): each binding's poll asks the engine which of its
8
+ // active instances are `TERMINATED` and applies `onTerminated.set` (→ e.g. `abandoned`). A
9
+ // terminated instance runs no completion worker, so nothing else would ever close the row.
10
+ //
11
+ // - The **wait-on-human** edge (`onWaitingHuman`, issue #355): the symmetric primitive for
12
+ // "this instance is parked waiting on a human." That state is authoritatively knowable — an
13
+ // instance is waiting on a human *iff* it has an open user task (`openUserTasks` =
14
+ // `searchUserTasks({state:"CREATED"})`) — for every process, forever, with no per-element
15
+ // registry. On each poll, an active, non-terminated row whose instance has any open user task
16
+ // has `onWaitingHuman.set` applied (typically `status = "awaiting_operator"`). This retires the
17
+ // per-subject imperative pattern (workers writing the status on escalate/answer plus a bespoke
18
+ // self-heal poller): the parked status becomes a derived view over the one user-task
19
+ // projection, so there is no second surface to drift (No Drift Surfaces).
20
+ //
21
+ // Precedence per active row, all derived from engine truth (never a worker write):
22
+ // terminated -> onTerminated.set (e.g. abandoned) // terminal edge
23
+ // else has open user task -> onWaitingHuman.set (e.g. awaiting_operator) // wait-on-human edge
24
+ // else -> leave the worker-owned transient status (running / converging / …)
25
+ //
26
+ // It deliberately reconciles the terminal edge only on `TERMINATED` — the state that by
27
+ // definition runs no completion worker — and never on `COMPLETED`, whose terminal row-write the
28
+ // app's own finalize worker owns (reconciling it here would race that worker and could clobber a
29
+ // legitimately-completed row). The wait-on-human edge reads *open* user tasks via `openUserTasks`
30
+ // (never a bare `searchUserTasks`, which lags a completion and would latch `awaiting_operator`
31
+ // onto a just-answered task). The poll is driven through the shared `SchedulerDeps` seam so tests
32
+ // are deterministic.
15
33
 
16
34
  import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
17
35
  import { type InstanceTracking, isConfiguredStatusSelector } from "../manifest.ts";
@@ -20,6 +38,13 @@ import { defaultScheduler, MAX_TIMER_DELAY_MS, type SchedulerDeps } from "./sche
20
38
  /** Default poll interval when a binding does not set `pollMs`. */
21
39
  export const DEFAULT_INSTANCE_TRACKING_POLL_MS = 15_000;
22
40
 
41
+ /** Max in-flight `openUserTasks` probes per reconcile pass. The wait-on-human edge probes the
42
+ * engine once per active, non-terminated key; probing them sequentially makes a pass O(N) network
43
+ * round-trips and can make a single pass take longer than `pollMs` under a large active backlog.
44
+ * Probing in bounded-parallel batches makes wall-clock time scale with max latency rather than
45
+ * N×latency, while the cap keeps in-flight load on the engine bounded. */
46
+ export const WAITING_HUMAN_PROBE_CONCURRENCY = 8;
47
+
23
48
  export interface InstanceTrackingHandle extends Mounted {
24
49
  /** `table.keyField` labels of the armed bindings, for `inspect()`. */
25
50
  readonly bindings: string[];
@@ -56,8 +81,60 @@ export async function reconcileTerminatedKey(
56
81
  return reconciled;
57
82
  }
58
83
 
84
+ /** Whether `row` already satisfies `patch` on every column it sets — used to make the
85
+ * wait-on-human writer quiet-idempotent. Unlike a terminated row (which flips to a terminal
86
+ * status and then drops out of the active set), a row parked at a user task stays active and is
87
+ * re-polled every tick, so re-writing/re-logging the same `awaiting_operator` patch on every poll
88
+ * would be noise; skip the write when it would be a no-op. */
89
+ function patchAlreadyApplied(row: Row, set: Record<string, string | number | boolean | null>): boolean {
90
+ return Object.entries(set).every(([col, value]) => row[col] === value);
91
+ }
92
+
93
+ /** Apply a binding's `onWaitingHuman.set` patch to the single row keyed by `processInstanceKey`
94
+ * — the wait-on-human twin of {@link reconcileTerminatedKey} (issue #355). The caller establishes
95
+ * the precondition (the instance is non-terminated and has an open user task); this is the single
96
+ * place that writes `onWaitingHuman.set` and logs the reconcile. Bindings without an
97
+ * `onWaitingHuman` edge are no-ops, so callers can fan this over every binding. It skips the write
98
+ * when the row already carries the patch (quiet-idempotent — a long-parked instance is re-polled
99
+ * every tick and must not re-log). Returns rows changed. */
100
+ export async function reconcileWaitingHumanKey(
101
+ api: Pick<AppApi, "data" | "log">,
102
+ bindings: InstanceTracking[],
103
+ processInstanceKey: string,
104
+ ): Promise<number> {
105
+ const key = String(processInstanceKey);
106
+ let reconciled = 0;
107
+ for (const binding of bindings) {
108
+ const onWaitingHuman = binding.onWaitingHuman;
109
+ if (!onWaitingHuman) continue;
110
+ const table = api.data.table<Row>(binding.table, binding.keyField);
111
+ // Quiet-idempotence must consider EVERY row sharing this key, not just one. `keyField` need not
112
+ // be a unique column (e.g. a read model keyed on a non-PK `process_key`), and `table.update`
113
+ // patches ALL matching rows — so a `get()` (LIMIT 1) that happens to hit one already-patched row
114
+ // would skip the write and leave the OTHER matching rows unreconciled. Skip only when every
115
+ // matching row already carries the patch; otherwise fall through and re-patch them all.
116
+ const current = await table.find({ [binding.keyField]: key });
117
+ if (current.length > 0 && current.every((row) => patchAlreadyApplied(row, onWaitingHuman.set))) {
118
+ continue;
119
+ }
120
+ const changed = await table.update(key, onWaitingHuman.set);
121
+ if (changed > 0) {
122
+ reconciled += changed;
123
+ api.log("info", "instanceTracking: reconciled instance awaiting operator", {
124
+ table: binding.table,
125
+ keyField: binding.keyField,
126
+ processInstanceKey: key,
127
+ rowsChanged: changed,
128
+ });
129
+ }
130
+ }
131
+ return reconciled;
132
+ }
133
+
59
134
  /** Poll one binding's instances once. Selects the active rows, asks the engine which of their
60
- * instances are TERMINATED, and applies `onTerminated.set` to each. Returns how many keys were
135
+ * instances are TERMINATED, and applies `onTerminated.set` to each; then, for the binding's
136
+ * wait-on-human edge (`onWaitingHuman`, issue #355), applies `onWaitingHuman.set` to every
137
+ * non-terminated active row whose instance has an open user task. Returns how many keys were
61
138
  * scanned and how many rows were reconciled this tick. */
62
139
  async function reconcileOnce(
63
140
  api: AppApi,
@@ -116,11 +193,38 @@ async function reconcileOnce(
116
193
  // writes `onTerminated.set` and logs the reconcile — so the poll path and the cancel primitive
117
194
  // can never drift on the patch or its logging (No Drift Surfaces).
118
195
  let reconciled = 0;
196
+ const terminatedKeys = new Set<string>();
119
197
  for (const snap of snapshots) {
120
198
  if (snap.state !== "TERMINATED") continue; // defensive; we asked for TERMINATED only
121
199
  if (!keyToRows.has(snap.processInstanceKey)) continue; // not one we were tracking
200
+ terminatedKeys.add(snap.processInstanceKey);
122
201
  reconciled += await reconcileTerminatedKey(api, [binding], snap.processInstanceKey);
123
202
  }
203
+
204
+ // Wait-on-human edge (issue #355), symmetric to onTerminated. Precedence: terminated wins, so a
205
+ // key reconciled as terminated above is excluded here. An instance is "waiting on a human" iff
206
+ // it has an open user task — the authoritative engine truth — so read `openUserTasks` (never a
207
+ // bare `searchUserTasks`, which lags a completion and would latch `awaiting_operator` onto a
208
+ // just-answered task; issue #355 "instance B"). When there is no open user task the row is left
209
+ // untouched, so a worker-owned transient status (running / converging / merging) survives and a
210
+ // stale `awaiting_operator` is not re-latched. Because the condition is derived every tick, a
211
+ // re-escalation after an answer re-flips to `awaiting_operator` on the next poll by construction
212
+ // (issue #355 "instance A"). Probe in bounded-parallel batches (`WAITING_HUMAN_PROBE_CONCURRENCY`)
213
+ // so a pass over a large active backlog scales with max latency rather than N×latency.
214
+ if (binding.onWaitingHuman) {
215
+ const humanKeys = keys.filter((key) => !terminatedKeys.has(key)); // terminated already won
216
+ for (let i = 0; i < humanKeys.length; i += WAITING_HUMAN_PROBE_CONCURRENCY) {
217
+ const batch = humanKeys.slice(i, i + WAITING_HUMAN_PROBE_CONCURRENCY);
218
+ const perKey = await Promise.all(
219
+ batch.map(async (key): Promise<number> => {
220
+ const openTasks = await api.engine.openUserTasks({ processInstanceKey: key });
221
+ if (openTasks.length === 0) return 0; // not parked on a human ⇒ leave the transient status
222
+ return reconcileWaitingHumanKey(api, [binding], key);
223
+ }),
224
+ );
225
+ for (const n of perKey) reconciled += n;
226
+ }
227
+ }
124
228
  return { scanned: keys.length, reconciled };
125
229
  }
126
230