@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.
- package/dist/context/binding/backend.d.ts +58 -0
- package/dist/context/binding/backend.js +15 -0
- package/dist/context/binding/descriptor.d.ts +51 -0
- package/dist/context/binding/descriptor.js +62 -0
- package/dist/context/binding/git-backend.d.ts +65 -0
- package/dist/context/binding/git-backend.js +318 -0
- package/dist/context/binding/identity.d.ts +42 -0
- package/dist/context/binding/identity.js +159 -0
- package/dist/context/binding/index.d.ts +5 -1
- package/dist/context/binding/index.js +14 -1
- package/dist/context/binding/resolver.d.ts +61 -0
- package/dist/context/binding/resolver.js +125 -0
- package/dist/runtime/core/modules/runtime.gen.js +1 -1
- package/package.json +1 -1
- package/src/context/binding/backend.ts +79 -0
- package/src/context/binding/descriptor.test.ts +51 -0
- package/src/context/binding/descriptor.ts +108 -0
- package/src/context/binding/git-backend.ts +368 -0
- package/src/context/binding/identity.test.ts +165 -0
- package/src/context/binding/identity.ts +206 -0
- package/src/context/binding/index.ts +46 -2
- package/src/context/binding/resolver.test.ts +713 -0
- package/src/context/binding/resolver.ts +176 -0
- package/src/runtime/browser/runtime.browser.js +63 -30
- package/src/runtime/core/modules/pages.test.ts +20 -1
- package/src/runtime/core/modules/runtime.gen.ts +1 -1
|
@@ -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
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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
|
|
928
|
-
//
|
|
929
|
-
//
|
|
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
|
-
|
|
933
|
-
|
|
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
|
|
1005
|
+
const atLocus = locus != null && (locus.stage != null ? String(s.key) === String(locus.stage) : i === activeIdx);
|
|
972
1006
|
const label = labelOf(s);
|
|
973
|
-
const
|
|
974
|
-
|
|
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
|
|
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 () => {
|