@nanobpm/urban 0.63.1 → 0.65.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.
@@ -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
+ }
@@ -633,6 +633,39 @@ function pageHashHref(page, key) {
633
633
  return "#/" + encodeURIComponent(page) + "/" + encodeURIComponent(keyStr);
634
634
  }
635
635
 
636
+ // SINGLE source of truth for a console process-explorer link. The grid link cell
637
+ // (link kind "processExplorer") and the pipeline locus link (issue #347) both
638
+ // route here so they can't drift on the href or the embedded-vs-standalone
639
+ // navigation. The console path is constructed HERE (never taken from row data)
640
+ // and the trimmed key URL-encoded, so row data can't smuggle a path/scheme.
641
+ // Returns null for a blank key (after trimming) so the caller keeps the node
642
+ // plain. Standalone the anchor opens a new tab (hardened rel); embedded in the
643
+ // console a plain primary click instead routes the host's explorer in place via
644
+ // the nano-navigate bridge (a non-primary/modified click keeps the new-tab
645
+ // behavior).
646
+ /** @param {any} label
647
+ * @param {any} key
648
+ * @param {string} cls
649
+ * @returns {any}
650
+ */
651
+ function explorerAnchor(label, key, cls) {
652
+ const keyStr = key == null ? "" : String(key).trim();
653
+ if (keyStr === "") return null;
654
+ const href = "/console/explorer?instance=" + encodeURIComponent(keyStr);
655
+ return el("a", {
656
+ class: cls,
657
+ href,
658
+ target: "_blank",
659
+ rel: "noopener noreferrer",
660
+ /** @param {MouseEvent} ev */
661
+ onclick: (ev) => {
662
+ if (ev.button !== 0) return;
663
+ if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
664
+ if (hostNavigate("processExplorer", { instance: keyStr })) ev.preventDefault();
665
+ },
666
+ }, label);
667
+ }
668
+
636
669
  // Coerce a not-in-path value (issue #265) into a Set of stage keys. Accepts an
637
670
  // array of keys or a comma/whitespace-separated string; null/undefined → empty
638
671
  // set. Used to mark the stages a given row skips (dashed/omitted).
@@ -746,27 +779,13 @@ function gridCell(col, row, role) {
746
779
  }
747
780
  }
748
781
  if (primary === text && col.link && col.link.kind === "processExplorer" && col.link.keyField) {
749
- const key = row[col.link.keyField];
750
- const keyStr = key == null ? "" : String(key).trim();
751
- if (text !== "" && keyStr !== "") {
752
- const href = "/console/explorer?instance=" + encodeURIComponent(keyStr);
753
- primary = el("a", {
754
- class: "pc-link",
755
- href,
756
- target: "_blank",
757
- rel: "noopener noreferrer",
758
- // Embedded in the console a plain primary click routes the host's
759
- // explorer in place (via the nano-navigate bridge) instead of opening
760
- // a new window. A non-primary button (middle/right = new-tab/context
761
- // intent), a modified click (⌘/ctrl/shift/alt = new-tab intent) and
762
- // any standalone run fall through to the native anchor above.
763
- /** @param {MouseEvent} ev */
764
- onclick: (ev) => {
765
- if (ev.button !== 0) return;
766
- if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
767
- if (hostNavigate("processExplorer", { instance: keyStr })) ev.preventDefault();
768
- },
769
- }, text);
782
+ // Route through the shared explorerAnchor builder (single source of truth) so
783
+ // this cell and the pipeline locus link can't drift on the href or the
784
+ // embedded-in-place / standalone-new-tab navigation. A blank key → null →
785
+ // the cell stays plain text.
786
+ const a = explorerAnchor(text, row[col.link.keyField], "pc-link");
787
+ if (text !== "" && a) {
788
+ primary = a;
770
789
  }
771
790
  }
772
791
  return cellTd(primary, text, subText, truncate, mob);
@@ -924,13 +943,28 @@ function pipelineCell(col, row, text, subText, truncate, tdAttrs) {
924
943
  const badgeText = col.badgeField != null && row[col.badgeField] != null ? String(row[col.badgeField]) : "";
925
944
  const badgeTone = state === "failed" || state === "blocked" ? "danger" : "warn";
926
945
  // Optional locus out-link: the relevant stage (default the active one, or
927
- // locus.stage by key) becomes an in-app page link when the row's locus key is
928
- // non-empty. Href comes from the shared pageHashHref builder (safePageId guard,
929
- // encodeURIComponent) — a blank key yields "" so the stage stays plain.
946
+ // locus.stage by key) becomes a link when the row's locus key is non-empty.
947
+ // Two link kinds are supported, mirroring the grid link cell and built via the
948
+ // SAME single-source builders so the two can't drift: `page` → an in-app
949
+ // #/<page>/<key> hash route (pageHashHref, safePageId guard + encodeURIComponent);
950
+ // `processExplorer` → the console explorer for the row's process instance
951
+ // (explorerAnchor, embedded-in-place / standalone-new-tab). An unknown kind or
952
+ // blank key yields no link, so the stage stays plain.
930
953
  const locus = col.locus;
931
954
  const locusKey = locus && locus.field != null && row[locus.field] != null ? String(row[locus.field]) : "";
932
- const locusHref =
933
- locus && locus.link && locus.link.kind === "page" && locus.link.page ? pageHashHref(locus.link.page, locusKey) : "";
955
+ /** @param {any} label
956
+ * @returns {any} an anchor node for the locus stage, or null (→ plain label). */
957
+ const locusLabelNode = (label) => {
958
+ if (!locus || !locus.link || locusKey === "") return null;
959
+ if (locus.link.kind === "page" && locus.link.page) {
960
+ const href = pageHashHref(locus.link.page, locusKey);
961
+ return href === "" ? null : el("a", { class: "pc-link pc-pipe-label", href }, label);
962
+ }
963
+ if (locus.link.kind === "processExplorer") {
964
+ return explorerAnchor(label, locusKey, "pc-link pc-pipe-label");
965
+ }
966
+ return null;
967
+ };
934
968
  const track = el("div", {
935
969
  class: "pc-pipe",
936
970
  role: "list",
@@ -968,11 +1002,10 @@ function pipelineCell(col, row, text, subText, truncate, tdAttrs) {
968
1002
  } else {
969
1003
  cls = "pc-pipe-upcoming"; word = "upcoming";
970
1004
  }
971
- const isLocus = locusHref !== "" && (locus.stage != null ? String(s.key) === String(locus.stage) : i === activeIdx);
1005
+ const atLocus = locus != null && (locus.stage != null ? String(s.key) === String(locus.stage) : i === activeIdx);
972
1006
  const label = labelOf(s);
973
- const labelNode = isLocus
974
- ? el("a", { class: "pc-link pc-pipe-label", href: locusHref }, label)
975
- : el("span", { class: "pc-pipe-label" }, label);
1007
+ const locusNode = atLocus ? locusLabelNode(label) : null;
1008
+ const labelNode = locusNode != null ? locusNode : el("span", { class: "pc-pipe-label" }, label);
976
1009
  /** @type {Record<string, any>} */
977
1010
  const attrs = { class: "pc-pipe-stage " + cls, role: "listitem", "aria-label": label + " \u2014 " + word };
978
1011
  if (current) attrs["aria-current"] = "step";
@@ -1561,7 +1561,26 @@ test("pipeline builds a locus link via the shared pageHashHref route builder, on
1561
1561
  assert.match(js, /const href = pageHashHref\(col\.link\.page, row\[col\.link\.keyField\]\)/);
1562
1562
  // The locus link uses it and the shared .pc-link class; blank key → "" → plain.
1563
1563
  assert.match(js, /pageHashHref\(locus\.link\.page, locusKey\)/);
1564
- assert.match(js, /el\("a", \{ class: "pc-link pc-pipe-label", href: locusHref \}, label\)/);
1564
+ assert.match(js, /el\("a", \{ class: "pc-link pc-pipe-label", href \}, label\)/);
1565
+ });
1566
+
1567
+ test("pipeline locus supports a processExplorer link routing to the console explorer via the shared explorerAnchor", async () => {
1568
+ const res = await dispatch("GET", "/app/runtime.js");
1569
+ const js = res.body ?? "";
1570
+ // A pipeline column whose locus declares `link:{ kind:"processExplorer" }` links
1571
+ // the locus stage to the console explorer for the row's process instance — via
1572
+ // the SAME single-source explorerAnchor builder the grid link cell uses, so the
1573
+ // href and the embedded-in-place / standalone-new-tab navigation can't drift.
1574
+ assert.match(js, /function explorerAnchor\(label, key, cls\)/);
1575
+ assert.match(js, /"\/console\/explorer\?instance="\s*\+\s*encodeURIComponent\(keyStr\)/);
1576
+ assert.match(js, /hostNavigate\("processExplorer",\s*\{\s*instance:\s*keyStr\s*\}\)/);
1577
+ // The pipeline locus branches to it on the processExplorer kind, reusing the
1578
+ // .pc-pipe-label class; the grid link cell routes through the same builder.
1579
+ assert.match(js, /locus\.link\.kind === "processExplorer"/);
1580
+ assert.match(js, /return explorerAnchor\(label, locusKey, "pc-link pc-pipe-label"\)/);
1581
+ assert.match(js, /explorerAnchor\(text, row\[col\.link\.keyField\], "pc-link"\)/);
1582
+ // Unknown kind / blank key → no link → the stage stays plain (graceful).
1583
+ assert.match(js, /if \(!locus \|\| !locus\.link \|\| locusKey === ""\) return null/);
1565
1584
  });
1566
1585
 
1567
1586
  test("pipeline exposes step semantics to assistive tech (role/list + aria-current)", async () => {