@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,159 @@
1
+ // Slice S1 (binding) — context *identity*: the private-per-app /
2
+ // shared-on-same-name semantics.
3
+ //
4
+ // A context is named by its `(repo, ref)` pair. Two apps (or two Nano
5
+ // instances) that name the SAME context — the same normalised repo and the same
6
+ // ref — resolve to the SAME shared substrate; a distinct name stays private.
7
+ // This module turns a binding into a stable, canonical **identity key** that
8
+ // the resolver uses both to memoise in-process and to derive an on-disk
9
+ // location, so "same name ⇒ same substrate" holds within a process AND across
10
+ // processes/instances that share a cache root.
11
+ import { createHash } from "node:crypto";
12
+ import { isAbsolute, resolve as resolvePath } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ const SHORTHAND = /^[\w.-]+\/[\w.-]+$/;
15
+ const SCHEME_URL = /^[a-z][a-z0-9+.-]*:\/\//i;
16
+ const SCP_LIKE = /^[\w.-]+@([\w.-]+):(.+)$/; // git@github.com:owner/name.git
17
+ function stripGitSuffix(value) {
18
+ // Trim trailing slashes BEFORE stripping `.git` so a trailing-slash spelling
19
+ // (`owner/name.git/`, a URL pathname ending `.git/`) still has its `.git`
20
+ // suffix removed — otherwise `.git$` would not match and equivalent repo
21
+ // spellings would canonicalise to different identities (different substrates).
22
+ // Trim once more afterwards to drop any slash exposed by removing the suffix.
23
+ return value.replace(/\/+$/, "").replace(/\.git$/i, "").replace(/\/+$/, "");
24
+ }
25
+ // A URL's scheme-default port is equivalent to omitting the port, so it must not
26
+ // split identity. `URL` already strips the default port for the web schemes it
27
+ // knows (http/https/ws/…), but NOT for git transports like ssh:// and git://, so
28
+ // their defaults are normalised here explicitly.
29
+ const DEFAULT_PORTS = {
30
+ "http:": "80",
31
+ "https:": "443",
32
+ "ssh:": "22",
33
+ "git:": "9418",
34
+ "ftp:": "21",
35
+ };
36
+ /**
37
+ * The canonical authority (`host` or `host:port`) for a scheme URL identity.
38
+ * Excludes any `user:pass@` userinfo (uses `hostname`, never `host`) so
39
+ * credentials never affect identity, and drops a scheme's DEFAULT port while
40
+ * retaining any NON-default port as a distinguishing factor.
41
+ */
42
+ function canonicalAuthority(parsed) {
43
+ const host = parsed.hostname.toLowerCase();
44
+ const defaultPort = DEFAULT_PORTS[parsed.protocol.toLowerCase()];
45
+ if (parsed.port && parsed.port !== defaultPort) {
46
+ return `${host}:${parsed.port}`;
47
+ }
48
+ return host;
49
+ }
50
+ /**
51
+ * Resolve a `file://` URL to an absolute filesystem path, honouring the host
52
+ * component and percent-encoding. Falls back to naive `file://` stripping for a
53
+ * malformed URL so identity stays stable rather than throwing.
54
+ */
55
+ function fileUrlToAbsPath(raw) {
56
+ try {
57
+ return resolvePath(fileURLToPath(raw));
58
+ }
59
+ catch {
60
+ return resolvePath(raw.slice("file://".length));
61
+ }
62
+ }
63
+ /**
64
+ * Classify a binding `repo` field into a concrete clone URL plus a normalised
65
+ * canonical form for identity. Public git is NOT hard-coded: local/`file://`
66
+ * paths and arbitrary git hosts are first-class, leaving a seam for a future
67
+ * PII/mutable or self-hosted substrate backend.
68
+ */
69
+ function classifyRepo(repo) {
70
+ const raw = repo.trim();
71
+ // Local filesystem path or file:// URL — used for tests and self-hosted
72
+ // substrates. Canonicalised by absolute path so two spellings of one path
73
+ // share identity. `file://` URLs are parsed via `fileURLToPath` so the host
74
+ // component (e.g. `file://localhost/…`) and percent-encoding are handled and
75
+ // equivalent spellings map to one identity.
76
+ if (raw.startsWith("file://")) {
77
+ const abs = fileUrlToAbsPath(raw);
78
+ return { url: raw, canonical: `file:${abs}` };
79
+ }
80
+ if (raw.startsWith(".") || isAbsolute(raw)) {
81
+ const abs = resolvePath(raw);
82
+ return { url: abs, canonical: `file:${abs}` };
83
+ }
84
+ // owner/name shorthand ⇒ GitHub HTTPS clone URL.
85
+ if (SHORTHAND.test(raw)) {
86
+ const canonical = `github.com/${stripGitSuffix(raw).toLowerCase()}`;
87
+ return { url: `https://github.com/${stripGitSuffix(raw)}.git`, canonical };
88
+ }
89
+ // scp-like git URL: git@host:owner/name(.git)
90
+ const scp = SCP_LIKE.exec(raw);
91
+ if (scp) {
92
+ const host = scp[1].toLowerCase();
93
+ const path = stripGitSuffix(scp[2]).toLowerCase();
94
+ return { url: raw, canonical: `${host}/${path}` };
95
+ }
96
+ // scheme URL: https://, ssh://, git://, http://
97
+ if (SCHEME_URL.test(raw)) {
98
+ try {
99
+ const parsed = new URL(raw);
100
+ // Build the authority from `hostname` (never `host`) so any `user:pass@`
101
+ // userinfo is excluded — credentials never affect identity. The port is
102
+ // *normalised*, not dropped: a scheme's DEFAULT port collapses to the
103
+ // portless spelling (so `https://h:443/o/n` ≡ `https://h/o/n` and
104
+ // `ssh://h:22/o/n` ≡ `ssh://h/o/n`), but a NON-default port is retained as
105
+ // a distinguishing factor — two remotes on the same host but different
106
+ // ports are genuinely different repos and MUST NOT share a substrate.
107
+ const host = canonicalAuthority(parsed);
108
+ const path = stripGitSuffix(parsed.pathname).replace(/^\/+/, "").toLowerCase();
109
+ return { url: raw, canonical: `${host}/${path}` };
110
+ }
111
+ catch {
112
+ // Fall through to the opaque case below.
113
+ }
114
+ }
115
+ // Anything else: treat opaquely but still stable.
116
+ return { url: raw, canonical: stripGitSuffix(raw).toLowerCase() };
117
+ }
118
+ function toSlug(canonical, ref, hash) {
119
+ const readable = `${canonical}@${ref}`
120
+ .toLowerCase()
121
+ .replace(/[^a-z0-9]+/g, "-")
122
+ .replace(/^-+|-+$/g, "")
123
+ .slice(0, 48);
124
+ // Suffix with the FULL identity key (sha256 hex), not a truncated prefix: the
125
+ // on-disk location is derived from the slug, so a truncated suffix would make
126
+ // two distinct keys collide onto one `localPath` (breaking "distinct names
127
+ // stay private") far sooner than the key itself collides. Deriving the suffix
128
+ // from the whole key keeps on-disk uniqueness exactly as strong as the
129
+ // in-process sharing key — a single source of truth, distinct keys ⇒ distinct
130
+ // slugs.
131
+ return `${readable || "context"}-${hash}`;
132
+ }
133
+ /**
134
+ * Compute the canonical {@link ContextIdentity} for a binding. Deterministic and
135
+ * pure: equal `(repo, ref)` names (after normalisation) always produce an equal
136
+ * `key` and `slug`. Distinct names are collision-resistant, not collision-proof:
137
+ * both `key` and `slug` are sha256-derived, so two distinct names collide only on
138
+ * a full sha256 collision (the `slug` embeds the whole `key`, so it is exactly as
139
+ * strong as the `key`).
140
+ */
141
+ export function resolveContextIdentity(binding) {
142
+ const { url, canonical } = classifyRepo(binding.repo);
143
+ const ref = binding.ref.trim();
144
+ const canonicalName = `${canonical}#${ref}`;
145
+ const key = createHash("sha256").update(canonicalName, "utf8").digest("hex");
146
+ return { key, repo: url, ref, slug: toSlug(canonical, ref, key) };
147
+ }
148
+ /**
149
+ * The stable identity key of a binding — the sharing primitive. Two bindings
150
+ * resolve to the same shared substrate iff this returns the same value for
151
+ * both.
152
+ */
153
+ export function contextIdentityKey(binding) {
154
+ return resolveContextIdentity(binding).key;
155
+ }
156
+ /** `true` iff two bindings name the same shared context (same substrate). */
157
+ export function sameContext(a, b) {
158
+ return contextIdentityKey(a) === contextIdentityKey(b);
159
+ }
@@ -1 +1,5 @@
1
- export {};
1
+ export { type ContextBinding, ContextBindingError, isContextBinding, parseContextBinding, } from "./descriptor.ts";
2
+ export { type ContextIdentity, contextIdentityKey, resolveContextIdentity, sameContext, } from "./identity.ts";
3
+ export { type ResolvedContextHandle, type SubstrateBackend, type SubstrateResolveOptions, } from "./backend.ts";
4
+ export { GitSubstrateBackend, type GitRunner, GIT_SAFETY_CONFIG, hardenedGitArgs, SubstrateResolveError, } from "./git-backend.ts";
5
+ export { ContextResolver, type ContextResolverOptions, type ResolveOptions, defaultContextCacheRoot, resolveContextBinding, } from "./resolver.ts";
@@ -1 +1,14 @@
1
- export {};
1
+ // @nanobpm/urban/context/binding — slice S1.
2
+ //
3
+ // The bindable `context` resource: the binding descriptor (`uses: context:
4
+ // { repo, ref }`) plus local↔remote resolution to a resolved handle, with
5
+ // private-per-app / shared-on-same-name identity semantics. This is one half of
6
+ // the published contract nanobpm/nano-workforce#291 builds against.
7
+ //
8
+ // Downstream: S3 (git substrate/governance) and S4 (retrieval) consume the
9
+ // ResolvedContextHandle; the SubstrateBackend interface is the seam for a
10
+ // future PII/mutable or self-hosted backend.
11
+ export { ContextBindingError, isContextBinding, parseContextBinding, } from "./descriptor.js";
12
+ export { contextIdentityKey, resolveContextIdentity, sameContext, } from "./identity.js";
13
+ export { GitSubstrateBackend, GIT_SAFETY_CONFIG, hardenedGitArgs, SubstrateResolveError, } from "./git-backend.js";
14
+ export { ContextResolver, defaultContextCacheRoot, resolveContextBinding, } from "./resolver.js";
@@ -0,0 +1,61 @@
1
+ import type { ResolvedContextHandle, SubstrateBackend } from "./backend.ts";
2
+ /** Options for a {@link ContextResolver}. */
3
+ export interface ContextResolverOptions {
4
+ /**
5
+ * Root directory under which per-context working copies are cloned. Two
6
+ * resolvers sharing a `cacheRoot` share substrates on disk. Defaults to
7
+ * {@link defaultContextCacheRoot}.
8
+ */
9
+ readonly cacheRoot?: string;
10
+ /**
11
+ * The substrate backend. Defaults to a git backend ({@link GitSubstrateBackend}).
12
+ * Injecting a different backend is the seam for a future PII/mutable or
13
+ * self-hosted substrate.
14
+ */
15
+ readonly backend?: SubstrateBackend;
16
+ }
17
+ /** Per-resolution overrides. */
18
+ export interface ResolveOptions {
19
+ /**
20
+ * Controls whether a re-resolution re-fetches the substrate. Note the
21
+ * {@link ContextResolver} memoises per identity: once an identity has resolved,
22
+ * repeat resolves return the cached handle and do **not** re-fetch unless you
23
+ * explicitly pass `refresh: true`, which forces a fresh materialisation (a
24
+ * fetch + re-pin on the backend). While a materialisation is still in flight,
25
+ * concurrent resolves — including `refresh: true` — coalesce onto it rather
26
+ * than starting a second one. The backend's own default (fetch on an existing
27
+ * clone) therefore only applies the first time an identity is materialised or
28
+ * when `refresh: true` is passed. Defaults to `undefined` (reuse the memoised
29
+ * handle).
30
+ */
31
+ readonly refresh?: boolean;
32
+ }
33
+ /**
34
+ * The default cache root for context working copies. Honours
35
+ * `URBAN_CONTEXT_CACHE_DIR`, then `XDG_CACHE_HOME`, then `~/.cache`, falling
36
+ * back to the OS temp dir when no home directory is available.
37
+ */
38
+ export declare function defaultContextCacheRoot(env?: Record<string, string | undefined>): string;
39
+ /**
40
+ * Resolves context bindings to concrete local working copies. A single resolver
41
+ * instance enforces shared-on-same-name in-process by memoising per identity
42
+ * key; distinct names stay private.
43
+ */
44
+ export declare class ContextResolver {
45
+ #private;
46
+ constructor(options?: ContextResolverOptions);
47
+ /** The cache root this resolver clones into. */
48
+ get cacheRoot(): string;
49
+ /**
50
+ * Resolve a binding (validated first) to a {@link ResolvedContextHandle}. Two
51
+ * bindings that name the same context return the identical handle from this
52
+ * resolver; distinct names return distinct handles.
53
+ */
54
+ resolve(binding: unknown, options?: ResolveOptions): Promise<ResolvedContextHandle>;
55
+ }
56
+ /**
57
+ * One-shot convenience: resolve a single binding with a fresh resolver. Prefer a
58
+ * long-lived {@link ContextResolver} when resolving many bindings so sharing is
59
+ * memoised across calls.
60
+ */
61
+ export declare function resolveContextBinding(binding: unknown, options?: ContextResolverOptions & ResolveOptions): Promise<ResolvedContextHandle>;
@@ -0,0 +1,125 @@
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
+ import { homedir, tmpdir } from "node:os";
17
+ import { join, resolve as resolvePath } from "node:path";
18
+ import { parseContextBinding } from "./descriptor.js";
19
+ import { resolveContextIdentity } from "./identity.js";
20
+ import { GitSubstrateBackend } from "./git-backend.js";
21
+ /**
22
+ * The default cache root for context working copies. Honours
23
+ * `URBAN_CONTEXT_CACHE_DIR`, then `XDG_CACHE_HOME`, then `~/.cache`, falling
24
+ * back to the OS temp dir when no home directory is available.
25
+ */
26
+ export function defaultContextCacheRoot(env = process.env) {
27
+ if (env.URBAN_CONTEXT_CACHE_DIR)
28
+ return env.URBAN_CONTEXT_CACHE_DIR;
29
+ if (env.XDG_CACHE_HOME)
30
+ return join(env.XDG_CACHE_HOME, "urban", "context");
31
+ const home = safeHomedir();
32
+ if (home)
33
+ return join(home, ".cache", "urban", "context");
34
+ return join(tmpdir(), "urban", "context");
35
+ }
36
+ /** `os.homedir()`, but tolerant: returns `undefined` if the home dir can't be
37
+ * determined (Node can throw here) so callers fall back to the temp dir. */
38
+ function safeHomedir() {
39
+ try {
40
+ return homedir() || undefined;
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ }
46
+ /**
47
+ * Resolves context bindings to concrete local working copies. A single resolver
48
+ * instance enforces shared-on-same-name in-process by memoising per identity
49
+ * key; distinct names stay private.
50
+ */
51
+ export class ContextResolver {
52
+ #cacheRoot;
53
+ #backend;
54
+ // Per-identity resolution record. Despite serving the single-flight guarantee,
55
+ // this map is NOT purged once a resolution settles: the settled record is
56
+ // retained as the long-lived memoisation cache backing shared-on-same-name, so
57
+ // repeat resolves return the identical handle. Entries are removed only on
58
+ // rejection (so a failed materialise can be retried); a fulfilled entry lives
59
+ // for the resolver's lifetime. Do not add "drop settled entries" cleanup here
60
+ // without also giving up memoisation.
61
+ #resolutions = new Map();
62
+ constructor(options = {}) {
63
+ // Normalise to an absolute path so a relative `cacheRoot` (or a relative
64
+ // `URBAN_CONTEXT_CACHE_DIR`) still yields an absolute `localPath`, honouring
65
+ // `SubstrateResolveOptions.localPath` ("Absolute path…"). Note this resolves
66
+ // a relative `cacheRoot` against *this* process's CWD, so the cross-process
67
+ // sharing promised in the module header only holds for an ABSOLUTE
68
+ // `cacheRoot`; a relative one is shared only among instances that happen to
69
+ // share a working directory.
70
+ this.#cacheRoot = resolvePath(options.cacheRoot ?? defaultContextCacheRoot());
71
+ this.#backend = options.backend ?? new GitSubstrateBackend();
72
+ }
73
+ /** The cache root this resolver clones into. */
74
+ get cacheRoot() {
75
+ return this.#cacheRoot;
76
+ }
77
+ /**
78
+ * Resolve a binding (validated first) to a {@link ResolvedContextHandle}. Two
79
+ * bindings that name the same context return the identical handle from this
80
+ * resolver; distinct names return distinct handles.
81
+ */
82
+ async resolve(binding, options = {}) {
83
+ const parsed = parseContextBinding(binding);
84
+ const identity = resolveContextIdentity(parsed);
85
+ const localPath = join(this.#cacheRoot, identity.slug);
86
+ // Shared-on-same-name: concurrent or repeated resolves of one identity share
87
+ // a single materialisation. Coalesce onto an in-flight one regardless of
88
+ // `refresh` — it is already producing a fresh working copy, so starting a
89
+ // second `materialise()` would double-clone into the same `localPath` and
90
+ // break the single-in-flight guarantee. Only re-materialise once the prior
91
+ // resolution has settled AND the caller explicitly asked to `refresh`.
92
+ const existing = this.#resolutions.get(identity.key);
93
+ if (existing && (!existing.settled || options.refresh !== true)) {
94
+ return existing.promise;
95
+ }
96
+ const record = {
97
+ promise: this.#backend
98
+ .materialise(identity, { localPath, refresh: options.refresh })
99
+ .catch((error) => {
100
+ if (this.#resolutions.get(identity.key) === record) {
101
+ this.#resolutions.delete(identity.key);
102
+ }
103
+ throw error;
104
+ }),
105
+ settled: false,
106
+ };
107
+ this.#resolutions.set(identity.key, record);
108
+ record.promise.then(() => {
109
+ record.settled = true;
110
+ }, () => {
111
+ // Rejection is surfaced to the caller via the returned promise; the map
112
+ // entry is already removed in the catch above.
113
+ });
114
+ return record.promise;
115
+ }
116
+ }
117
+ /**
118
+ * One-shot convenience: resolve a single binding with a fresh resolver. Prefer a
119
+ * long-lived {@link ContextResolver} when resolving many bindings so sharing is
120
+ * memoised across calls.
121
+ */
122
+ export function resolveContextBinding(binding, options = {}) {
123
+ const { cacheRoot, backend, ...resolveOptions } = options;
124
+ return new ContextResolver({ cacheRoot, backend }).resolve(binding, resolveOptions);
125
+ }
@@ -2,7 +2,8 @@ import type { HostContext } from "./host.ts";
2
2
  import type { AppManifest, InstanceTracking as SchemaInstanceTracking, Worker } from "@nanobpm/nano-app-schema";
3
3
  export type { ActionDecl, AppManifest, AppUi, ChatSurface, Connection, Data, DataSource, DomainField, DomainType, LlmBinding, Models, PagesSurface, Runtime, Security, Surfaces, TaskInboxSurface, Trigger, TriggerAction, Worker, } from "@nanobpm/nano-app-schema";
4
4
  /**
5
- * `instanceTracking` binding, bridged to add the fail-open `terminalStatuses` selector.
5
+ * `instanceTracking` binding, bridged to add the fail-open `terminalStatuses` selector and the
6
+ * `onWaitingHuman` wait-on-human edge (issue #355).
6
7
  *
7
8
  * `terminalStatuses` is landing in the canonical schema (`@nanobpm/nano-app-schema`,
8
9
  * Magikcraft/nano-bpm#769): an exclusion list — the runtime polls every row whose
@@ -16,6 +17,28 @@ export type InstanceTracking = SchemaInstanceTracking & {
16
17
  /** Values of `statusField` considered finished. When set, every row NOT in one of these
17
18
  * is polled (fail-open). Requires `statusField`; mutually exclusive with `activeStatuses`. */
18
19
  readonly terminalStatuses?: readonly string[];
20
+ /**
21
+ * The reconciliation applied to a row whose instance is **parked waiting on a human** — the
22
+ * wait-state twin of {@link SchemaInstanceTracking.onTerminated} (issue #355). An instance is
23
+ * waiting on a human *iff* it has an open user task, so the runtime derives this edge from
24
+ * engine truth (`openUserTasks`) rather than from a written column: on each poll, an active,
25
+ * non-terminated row whose instance has any open user task has this patch applied (typically
26
+ * `status = "awaiting_operator"`). Terminated wins over waiting-human (the {@link
27
+ * SchemaInstanceTracking.onTerminated} edge is applied first and excludes the key from this
28
+ * pass); a row with neither a terminated instance nor an open user task is left untouched, so
29
+ * the worker-owned transient status (running / converging / merging) survives.
30
+ *
31
+ * Optional and, like `terminalStatuses`, bridged locally until the canonical schema
32
+ * (`@nanobpm/nano-app-schema`) folds it in; drop this augmentation and re-export once the dep
33
+ * bump lands (No Drift Surfaces).
34
+ */
35
+ readonly onWaitingHuman?: {
36
+ /** Column → literal value patch written to a row whose instance has an open user task
37
+ * (e.g. set the status to a "waiting on human" value). */
38
+ readonly set: {
39
+ readonly [k: string]: string | number | boolean | null;
40
+ };
41
+ };
19
42
  };
20
43
  /**
21
44
  * A status selector (`activeStatuses`/`terminalStatuses`) counts as *configured* only when it is
@@ -3,6 +3,12 @@ import { type InstanceTracking } from "../manifest.ts";
3
3
  import { type SchedulerDeps } from "./scheduler.ts";
4
4
  /** Default poll interval when a binding does not set `pollMs`. */
5
5
  export declare const DEFAULT_INSTANCE_TRACKING_POLL_MS = 15000;
6
+ /** Max in-flight `openUserTasks` probes per reconcile pass. The wait-on-human edge probes the
7
+ * engine once per active, non-terminated key; probing them sequentially makes a pass O(N) network
8
+ * round-trips and can make a single pass take longer than `pollMs` under a large active backlog.
9
+ * Probing in bounded-parallel batches makes wall-clock time scale with max latency rather than
10
+ * N×latency, while the cap keeps in-flight load on the engine bounded. */
11
+ export declare const WAITING_HUMAN_PROBE_CONCURRENCY = 8;
6
12
  export interface InstanceTrackingHandle extends Mounted {
7
13
  /** `table.keyField` labels of the armed bindings, for `inspect()`. */
8
14
  readonly bindings: string[];
@@ -14,6 +20,14 @@ export interface InstanceTrackingHandle extends Mounted {
14
20
  * once per TERMINATED key it discovers) and the cancel primitive (immediately when it terminates an
15
21
  * instance) route through here, so the two can never drift on the patch or the log. */
16
22
  export declare function reconcileTerminatedKey(api: Pick<AppApi, "data" | "log">, bindings: InstanceTracking[], processInstanceKey: string): Promise<number>;
23
+ /** Apply a binding's `onWaitingHuman.set` patch to the single row keyed by `processInstanceKey`
24
+ * — the wait-on-human twin of {@link reconcileTerminatedKey} (issue #355). The caller establishes
25
+ * the precondition (the instance is non-terminated and has an open user task); this is the single
26
+ * place that writes `onWaitingHuman.set` and logs the reconcile. Bindings without an
27
+ * `onWaitingHuman` edge are no-ops, so callers can fan this over every binding. It skips the write
28
+ * when the row already carries the patch (quiet-idempotent — a long-parked instance is re-polled
29
+ * every tick and must not re-log). Returns rows changed. */
30
+ export declare function reconcileWaitingHumanKey(api: Pick<AppApi, "data" | "log">, bindings: InstanceTracking[], processInstanceKey: string): Promise<number>;
17
31
  /**
18
32
  * Mount the `instanceTracking` reconciler: arm one poll loop per binding. Each loop selects
19
33
  * the active rows, asks the engine which of their instances are terminated, and applies the
@@ -1,21 +1,45 @@
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
  import { isConfiguredStatusSelector } from "../manifest.js";
16
34
  import { defaultScheduler, MAX_TIMER_DELAY_MS } from "./scheduler.js";
17
35
  /** Default poll interval when a binding does not set `pollMs`. */
18
36
  export const DEFAULT_INSTANCE_TRACKING_POLL_MS = 15_000;
37
+ /** Max in-flight `openUserTasks` probes per reconcile pass. The wait-on-human edge probes the
38
+ * engine once per active, non-terminated key; probing them sequentially makes a pass O(N) network
39
+ * round-trips and can make a single pass take longer than `pollMs` under a large active backlog.
40
+ * Probing in bounded-parallel batches makes wall-clock time scale with max latency rather than
41
+ * N×latency, while the cap keeps in-flight load on the engine bounded. */
42
+ export const WAITING_HUMAN_PROBE_CONCURRENCY = 8;
19
43
  /** Apply a binding's `onTerminated.set` patch to the single row keyed by `processInstanceKey`.
20
44
  * Only the binding whose table owns that key has a matching row, so callers can fan this over
21
45
  * every binding and let the no-match ones be no-ops. Returns rows changed. The single place that
@@ -40,8 +64,55 @@ export async function reconcileTerminatedKey(api, bindings, processInstanceKey)
40
64
  }
41
65
  return reconciled;
42
66
  }
67
+ /** Whether `row` already satisfies `patch` on every column it sets — used to make the
68
+ * wait-on-human writer quiet-idempotent. Unlike a terminated row (which flips to a terminal
69
+ * status and then drops out of the active set), a row parked at a user task stays active and is
70
+ * re-polled every tick, so re-writing/re-logging the same `awaiting_operator` patch on every poll
71
+ * would be noise; skip the write when it would be a no-op. */
72
+ function patchAlreadyApplied(row, set) {
73
+ return Object.entries(set).every(([col, value]) => row[col] === value);
74
+ }
75
+ /** Apply a binding's `onWaitingHuman.set` patch to the single row keyed by `processInstanceKey`
76
+ * — the wait-on-human twin of {@link reconcileTerminatedKey} (issue #355). The caller establishes
77
+ * the precondition (the instance is non-terminated and has an open user task); this is the single
78
+ * place that writes `onWaitingHuman.set` and logs the reconcile. Bindings without an
79
+ * `onWaitingHuman` edge are no-ops, so callers can fan this over every binding. It skips the write
80
+ * when the row already carries the patch (quiet-idempotent — a long-parked instance is re-polled
81
+ * every tick and must not re-log). Returns rows changed. */
82
+ export async function reconcileWaitingHumanKey(api, bindings, processInstanceKey) {
83
+ const key = String(processInstanceKey);
84
+ let reconciled = 0;
85
+ for (const binding of bindings) {
86
+ const onWaitingHuman = binding.onWaitingHuman;
87
+ if (!onWaitingHuman)
88
+ continue;
89
+ const table = api.data.table(binding.table, binding.keyField);
90
+ // Quiet-idempotence must consider EVERY row sharing this key, not just one. `keyField` need not
91
+ // be a unique column (e.g. a read model keyed on a non-PK `process_key`), and `table.update`
92
+ // patches ALL matching rows — so a `get()` (LIMIT 1) that happens to hit one already-patched row
93
+ // would skip the write and leave the OTHER matching rows unreconciled. Skip only when every
94
+ // matching row already carries the patch; otherwise fall through and re-patch them all.
95
+ const current = await table.find({ [binding.keyField]: key });
96
+ if (current.length > 0 && current.every((row) => patchAlreadyApplied(row, onWaitingHuman.set))) {
97
+ continue;
98
+ }
99
+ const changed = await table.update(key, onWaitingHuman.set);
100
+ if (changed > 0) {
101
+ reconciled += changed;
102
+ api.log("info", "instanceTracking: reconciled instance awaiting operator", {
103
+ table: binding.table,
104
+ keyField: binding.keyField,
105
+ processInstanceKey: key,
106
+ rowsChanged: changed,
107
+ });
108
+ }
109
+ }
110
+ return reconciled;
111
+ }
43
112
  /** Poll one binding's instances once. Selects the active rows, asks the engine which of their
44
- * instances are TERMINATED, and applies `onTerminated.set` to each. Returns how many keys were
113
+ * instances are TERMINATED, and applies `onTerminated.set` to each; then, for the binding's
114
+ * wait-on-human edge (`onWaitingHuman`, issue #355), applies `onWaitingHuman.set` to every
115
+ * non-terminated active row whose instance has an open user task. Returns how many keys were
45
116
  * scanned and how many rows were reconciled this tick. */
46
117
  async function reconcileOnce(api, binding) {
47
118
  const table = api.data.table(binding.table, binding.keyField);
@@ -97,13 +168,39 @@ async function reconcileOnce(api, binding) {
97
168
  // writes `onTerminated.set` and logs the reconcile — so the poll path and the cancel primitive
98
169
  // can never drift on the patch or its logging (No Drift Surfaces).
99
170
  let reconciled = 0;
171
+ const terminatedKeys = new Set();
100
172
  for (const snap of snapshots) {
101
173
  if (snap.state !== "TERMINATED")
102
174
  continue; // defensive; we asked for TERMINATED only
103
175
  if (!keyToRows.has(snap.processInstanceKey))
104
176
  continue; // not one we were tracking
177
+ terminatedKeys.add(snap.processInstanceKey);
105
178
  reconciled += await reconcileTerminatedKey(api, [binding], snap.processInstanceKey);
106
179
  }
180
+ // Wait-on-human edge (issue #355), symmetric to onTerminated. Precedence: terminated wins, so a
181
+ // key reconciled as terminated above is excluded here. An instance is "waiting on a human" iff
182
+ // it has an open user task — the authoritative engine truth — so read `openUserTasks` (never a
183
+ // bare `searchUserTasks`, which lags a completion and would latch `awaiting_operator` onto a
184
+ // just-answered task; issue #355 "instance B"). When there is no open user task the row is left
185
+ // untouched, so a worker-owned transient status (running / converging / merging) survives and a
186
+ // stale `awaiting_operator` is not re-latched. Because the condition is derived every tick, a
187
+ // re-escalation after an answer re-flips to `awaiting_operator` on the next poll by construction
188
+ // (issue #355 "instance A"). Probe in bounded-parallel batches (`WAITING_HUMAN_PROBE_CONCURRENCY`)
189
+ // so a pass over a large active backlog scales with max latency rather than N×latency.
190
+ if (binding.onWaitingHuman) {
191
+ const humanKeys = keys.filter((key) => !terminatedKeys.has(key)); // terminated already won
192
+ for (let i = 0; i < humanKeys.length; i += WAITING_HUMAN_PROBE_CONCURRENCY) {
193
+ const batch = humanKeys.slice(i, i + WAITING_HUMAN_PROBE_CONCURRENCY);
194
+ const perKey = await Promise.all(batch.map(async (key) => {
195
+ const openTasks = await api.engine.openUserTasks({ processInstanceKey: key });
196
+ if (openTasks.length === 0)
197
+ return 0; // not parked on a human ⇒ leave the transient status
198
+ return reconcileWaitingHumanKey(api, [binding], key);
199
+ }));
200
+ for (const n of perKey)
201
+ reconciled += n;
202
+ }
203
+ }
107
204
  return { scanned: keys.length, reconciled };
108
205
  }
109
206
  /**
@@ -183,6 +183,21 @@ export function collectManifestIssues(m) {
183
183
  message: "missing onTerminated.set patch (a non-empty column → value map)",
184
184
  });
185
185
  }
186
+ // `onWaitingHuman` (issue #355) is the optional wait-on-human edge — the twin of
187
+ // `onTerminated`. It is optional (a binding may reconcile only the terminal edge), but when
188
+ // present its `set` must be a non-empty column → value patch for the same reason
189
+ // `onTerminated.set` must be: a reconciler with nothing to write is inert and almost
190
+ // certainly a mistake.
191
+ if (b?.onWaitingHuman !== undefined) {
192
+ const onWaitingHuman = isRecord(b.onWaitingHuman) ? b.onWaitingHuman : undefined;
193
+ const waitingSet = isRecord(onWaitingHuman?.set) ? onWaitingHuman.set : undefined;
194
+ if (!waitingSet || Object.keys(waitingSet).length === 0) {
195
+ issues.push({
196
+ path: `instanceTracking[${i}].onWaitingHuman.set`,
197
+ message: "missing onWaitingHuman.set patch (a non-empty column → value map)",
198
+ });
199
+ }
200
+ }
186
201
  // `activeStatuses` (fail-closed allow-list) and `terminalStatuses` (fail-open exclusion) are
187
202
  // both consumed as arrays of status strings. A malformed value — a bare string, or an array
188
203
  // holding a non-string/empty entry — would misbehave at runtime (`new Set("abandoned")`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.64.0",
3
+ "version": "0.66.0",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",