@nanobpm/urban 0.50.0 → 0.52.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/runtime/core/execContext.d.ts +5 -0
- package/dist/runtime/core/lineage.d.ts +116 -0
- package/dist/runtime/core/lineage.js +186 -0
- package/dist/runtime/core/modules/datasource.d.ts +6 -0
- package/dist/runtime/core/modules/datasource.js +8 -0
- package/dist/runtime/core/modules/lineage-store.d.ts +81 -0
- package/dist/runtime/core/modules/lineage-store.js +175 -0
- package/dist/runtime/core/modules/pages.js +165 -33
- package/dist/runtime/core/modules/workers.js +69 -2
- package/dist/runtime/engine/nanosdk.js +8 -2
- package/dist/runtime/index.d.ts +4 -0
- package/dist/runtime/index.js +5 -0
- package/package.json +1 -1
- package/src/runtime/core/execContext.ts +5 -0
- package/src/runtime/core/lineage.test.ts +215 -0
- package/src/runtime/core/lineage.ts +297 -0
- package/src/runtime/core/modules/datasource.ts +9 -0
- package/src/runtime/core/modules/lineage-store.test.ts +177 -0
- package/src/runtime/core/modules/lineage-store.ts +239 -0
- package/src/runtime/core/modules/pages.test.ts +85 -0
- package/src/runtime/core/modules/pages.ts +165 -33
- package/src/runtime/core/modules/workers.test.ts +136 -1
- package/src/runtime/core/modules/workers.ts +72 -6
- package/src/runtime/engine/nanosdk.test.ts +97 -3
- package/src/runtime/engine/nanosdk.ts +8 -2
- package/src/runtime/index.ts +35 -0
|
@@ -549,6 +549,16 @@ table.pc-grid { width:100%; border-collapse:collapse; font-size:.9rem; table-lay
|
|
|
549
549
|
neighbours. */
|
|
550
550
|
table.pc-grid th, table.pc-grid td { text-align:left; padding:.4rem .6rem; border-bottom:1px solid var(--nano-edge); overflow-wrap:anywhere; word-wrap:break-word; vertical-align:top; }
|
|
551
551
|
table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
552
|
+
/* A cell's optional muted second line (col.subtitleField / subtitleTemplate):
|
|
553
|
+
the identity key rendered under the title, GitHub/Linear style. Plain DOM
|
|
554
|
+
text (never a link/href source), smaller and muted so the primary line leads. */
|
|
555
|
+
.pc-cell-sub { font-size:.8rem; color:var(--nano-text-muted); margin-top:.1rem; }
|
|
556
|
+
/* One-line clamp (col.truncate): stop a bounded column wrapping and show an
|
|
557
|
+
ellipsis, with the full value carried in a title=/aria-label tooltip. Needs a
|
|
558
|
+
bounded column (per-column width/weight) to clip against; a native <col> width
|
|
559
|
+
is honoured under table-layout:fixed. Applied to the primary and, when present,
|
|
560
|
+
the subtitle line so both stay single-line. */
|
|
561
|
+
.pc-truncate { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
552
562
|
.pc-tabs { display:flex; gap:.5rem; margin-bottom:.75rem; }
|
|
553
563
|
.pc-tab { padding:.35rem .8rem; border:1px solid var(--nano-edge); border-radius:.4rem; background:transparent; color:inherit; font:inherit; cursor:pointer; }
|
|
554
564
|
.pc-tab.active { background:var(--nano-accent); color:var(--nano-on-accent); border-color:var(--nano-accent); }
|
|
@@ -1039,6 +1049,15 @@ function renderButton(node) {
|
|
|
1039
1049
|
// tooltip stay keyed to the raw 'field' value (a badge shows a fixed glyph, not
|
|
1040
1050
|
// the field text), so a template does not change badge behaviour. Sorting,
|
|
1041
1051
|
// groupBy and orderBy also key off real fields, never the rendered template.
|
|
1052
|
+
//
|
|
1053
|
+
// Two-line identity + one-line clamp (additive, opt-in, backward-compatible):
|
|
1054
|
+
// a column may also declare 'subtitleField'/'subtitleTemplate' to render a muted
|
|
1055
|
+
// second line under the primary text (the identity key beneath a title, GitHub/
|
|
1056
|
+
// Linear style) and 'truncate: true' to clamp each line to one row with an
|
|
1057
|
+
// ellipsis and a full-text title/aria-label tooltip (pairs with a per-column
|
|
1058
|
+
// width/weight — see buildColgroup — to give the clip a bounded column). The
|
|
1059
|
+
// subtitle is ALWAYS plain DOM text, never an href source. With none of these
|
|
1060
|
+
// set the cell is a single inline node exactly as before (see cellTd).
|
|
1042
1061
|
function gridCell(col, row) {
|
|
1043
1062
|
// Raw single-field value: drives the badge presence gate + tooltip and any
|
|
1044
1063
|
// field-derived defaults. Kept separate from the (possibly templated) display
|
|
@@ -1068,17 +1087,28 @@ function gridCell(col, row) {
|
|
|
1068
1087
|
el("span", { class: "pc-badge pc-badge-" + tone, title: rawText, "aria-label": rawText }, label),
|
|
1069
1088
|
);
|
|
1070
1089
|
}
|
|
1090
|
+
// Optional second line (col.subtitleField / subtitleTemplate): a muted key
|
|
1091
|
+
// under the primary title. subtitleTemplate wins (interpolated from the row);
|
|
1092
|
+
// else the raw subtitleField value. Absent/null → "" → no extra line. It is
|
|
1093
|
+
// ALWAYS plain DOM text (see cellTd), never a link/href source — a subtitle
|
|
1094
|
+
// cannot smuggle a scheme or path, only linkField/keyField drive hrefs.
|
|
1095
|
+
const subRaw =
|
|
1096
|
+
col.subtitleField != null && row[col.subtitleField] != null ? String(row[col.subtitleField]) : "";
|
|
1097
|
+
const subText = typeof col.subtitleTemplate === "string" ? interpTemplate(col.subtitleTemplate, row) : subRaw;
|
|
1098
|
+
const truncate = col.truncate === true;
|
|
1099
|
+
// Primary inline content: an anchor for a valid link mode (checked in the same
|
|
1100
|
+
// badge < linkField < link(page) < link(processExplorer) precedence as before),
|
|
1101
|
+
// else the plain display text. Each link mode only claims the cell while
|
|
1102
|
+
// 'primary' is still the bare text — an invalid href falls through to the next
|
|
1103
|
+
// mode and ultimately to plain text, exactly as the early-return chain did.
|
|
1104
|
+
let primary = text;
|
|
1071
1105
|
if (col.linkField) {
|
|
1072
1106
|
const href = row[col.linkField] == null ? "" : String(row[col.linkField]);
|
|
1073
1107
|
if (text !== "" && /^https?:\/\//i.test(href)) {
|
|
1074
|
-
|
|
1075
|
-
"td",
|
|
1076
|
-
{},
|
|
1077
|
-
el("a", { class: "pc-link", href, target: "_blank", rel: "noopener noreferrer" }, text),
|
|
1078
|
-
);
|
|
1108
|
+
primary = el("a", { class: "pc-link", href, target: "_blank", rel: "noopener noreferrer" }, text);
|
|
1079
1109
|
}
|
|
1080
1110
|
}
|
|
1081
|
-
if (col.link && col.link.kind === "page" && col.link.page && col.link.keyField) {
|
|
1111
|
+
if (primary === text && col.link && col.link.kind === "page" && col.link.page && col.link.keyField) {
|
|
1082
1112
|
// An in-app link to another page, scoped to this row: #/<page>/<key>. The
|
|
1083
1113
|
// key (e.g. a plan_key "owner/repo#123") is URL-encoded into the route's
|
|
1084
1114
|
// param tail, which parseRoute() decodes back verbatim; the page id is
|
|
@@ -1089,37 +1119,130 @@ function gridCell(col, row) {
|
|
|
1089
1119
|
const keyStr = key == null ? "" : String(key).trim();
|
|
1090
1120
|
if (text !== "" && safePageId(col.link.page) && keyStr !== "") {
|
|
1091
1121
|
const href = "#/" + encodeURIComponent(col.link.page) + "/" + encodeURIComponent(keyStr);
|
|
1092
|
-
|
|
1122
|
+
primary = el("a", { class: "pc-link", href }, text);
|
|
1093
1123
|
}
|
|
1094
1124
|
}
|
|
1095
|
-
if (col.link && col.link.kind === "processExplorer" && col.link.keyField) {
|
|
1125
|
+
if (primary === text && col.link && col.link.kind === "processExplorer" && col.link.keyField) {
|
|
1096
1126
|
const key = row[col.link.keyField];
|
|
1097
1127
|
const keyStr = key == null ? "" : String(key).trim();
|
|
1098
1128
|
if (text !== "" && keyStr !== "") {
|
|
1099
1129
|
const href = "/console/explorer?instance=" + encodeURIComponent(keyStr);
|
|
1100
|
-
|
|
1101
|
-
"
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1130
|
+
primary = el("a", {
|
|
1131
|
+
class: "pc-link",
|
|
1132
|
+
href,
|
|
1133
|
+
target: "_blank",
|
|
1134
|
+
rel: "noopener noreferrer",
|
|
1135
|
+
// Embedded in the console a plain primary click routes the host's
|
|
1136
|
+
// explorer in place (via the nano-navigate bridge) instead of opening
|
|
1137
|
+
// a new window. A non-primary button (middle/right = new-tab/context
|
|
1138
|
+
// intent), a modified click (⌘/ctrl/shift/alt = new-tab intent) and
|
|
1139
|
+
// any standalone run fall through to the native anchor above.
|
|
1140
|
+
onclick: (ev) => {
|
|
1141
|
+
if (ev.button !== 0) return;
|
|
1142
|
+
if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
|
|
1143
|
+
if (hostNavigate("processExplorer", { instance: keyStr })) ev.preventDefault();
|
|
1144
|
+
},
|
|
1145
|
+
}, text);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
return cellTd(primary, text, subText, truncate);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// Compose a grid <td> from its primary inline content (a string or an anchor),
|
|
1152
|
+
// an optional muted subtitle line, and optional one-line truncation. With
|
|
1153
|
+
// NEITHER a subtitle nor truncation the cell is exactly the single inline node
|
|
1154
|
+
// as before, so existing grids render byte-for-byte unchanged (backward
|
|
1155
|
+
// compatible). When either is set the content is wrapped in a .pc-cell-main
|
|
1156
|
+
// line and, if a subtitle is present, a .pc-cell-sub line beneath it. truncate
|
|
1157
|
+
// adds .pc-truncate (nowrap + ellipsis) to each line and mirrors the full text
|
|
1158
|
+
// into title=/aria-label so the clipped value is still recoverable on hover and
|
|
1159
|
+
// to assistive tech. When the primary is an element (a link cell's <a>), title=/
|
|
1160
|
+
// aria-label= are ALSO mirrored onto it: HTML does not inherit those to
|
|
1161
|
+
// descendants, so hovering the link text or naming the anchor for assistive tech
|
|
1162
|
+
// would otherwise miss the full value the wrapper carries. The subtitle is set as
|
|
1163
|
+
// DOM text only — never an href.
|
|
1164
|
+
function cellTd(primary, primaryText, subText, truncate) {
|
|
1165
|
+
if (subText === "" && !truncate) return el("td", {}, primary);
|
|
1166
|
+
const mainAttrs = { class: "pc-cell-main" + (truncate ? " pc-truncate" : "") };
|
|
1167
|
+
if (truncate && primaryText !== "") {
|
|
1168
|
+
mainAttrs.title = primaryText; mainAttrs["aria-label"] = primaryText;
|
|
1169
|
+
if (primary && typeof primary.setAttribute === "function") {
|
|
1170
|
+
primary.setAttribute("title", primaryText); primary.setAttribute("aria-label", primaryText);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
const main = el("div", mainAttrs, primary);
|
|
1174
|
+
if (subText === "") return el("td", {}, main);
|
|
1175
|
+
const subAttrs = { class: "pc-cell-sub" + (truncate ? " pc-truncate" : "") };
|
|
1176
|
+
if (truncate) { subAttrs.title = subText; subAttrs["aria-label"] = subText; }
|
|
1177
|
+
return el("td", {}, main, el("div", subAttrs, subText));
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// Parse a "<number>%" width into its numeric percentage, or null if the string
|
|
1181
|
+
// is not a bare percentage (e.g. "22rem", "120px") — used to decide whether the
|
|
1182
|
+
// remainder left for weighted columns can be computed.
|
|
1183
|
+
function colWidthPct(width) {
|
|
1184
|
+
const m = /^\s*([0-9]*\.?[0-9]+)%\s*$/.exec(width);
|
|
1185
|
+
return m ? parseFloat(m[1]) : null;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// A column carries an explicit width only when it is a non-empty string. Single
|
|
1189
|
+
// source of truth so colWidthStyle/buildColgroup can't drift on what "explicit"
|
|
1190
|
+
// means (which columns win verbatim, consume the remainder, or use their weight).
|
|
1191
|
+
function hasExplicitWidth(col) {
|
|
1192
|
+
return typeof col.width === "string" && col.width !== "";
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// A column's <col> width for the grid's <colgroup>: an explicit col.width string
|
|
1196
|
+
// ("40%", "22rem") wins verbatim; else a positive numeric col.weight is
|
|
1197
|
+
// normalised across the weighted columns into a percentage share of the width
|
|
1198
|
+
// left over by explicit columns (remainderPct); else null (no width — the column
|
|
1199
|
+
// shares the remainder equally under table-layout:fixed).
|
|
1200
|
+
function colWidthStyle(col, weightTotal, remainderPct) {
|
|
1201
|
+
if (hasExplicitWidth(col)) return col.width;
|
|
1202
|
+
if (typeof col.weight === "number" && col.weight > 0 && weightTotal > 0) {
|
|
1203
|
+
return Math.round((col.weight / weightTotal) * remainderPct * 100) / 100 + "%";
|
|
1204
|
+
}
|
|
1205
|
+
return null;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// Build a <colgroup> that sizes the grid's columns so table-layout:fixed stops
|
|
1209
|
+
// splitting width equally. Returns null when no column declares width/weight, so
|
|
1210
|
+
// an unsized grid emits no colgroup and lays out exactly as today. 'extraCount'
|
|
1211
|
+
// adds trailing unsized <col>s for the row-actions/detail column so the sized
|
|
1212
|
+
// data columns stay aligned with their headers.
|
|
1213
|
+
function buildColgroup(cols, extraCount) {
|
|
1214
|
+
const hasSizing = cols.some(
|
|
1215
|
+
(c) => hasExplicitWidth(c) || (typeof c.weight === "number" && c.weight > 0),
|
|
1216
|
+
);
|
|
1217
|
+
if (!hasSizing) return null;
|
|
1218
|
+
// Only columns that will actually use their weight (no explicit width — an
|
|
1219
|
+
// explicit width wins verbatim in colWidthStyle and never consumes weight)
|
|
1220
|
+
// contribute to weightTotal; counting a width-bearing column's weight here
|
|
1221
|
+
// would inflate the divisor and shrink the truly-weighted columns' share.
|
|
1222
|
+
let weightTotal = 0;
|
|
1223
|
+
for (const c of cols)
|
|
1224
|
+
if (!hasExplicitWidth(c) && typeof c.weight === "number" && c.weight > 0) weightTotal += c.weight;
|
|
1225
|
+
// Weighted columns share only the width the explicit columns leave behind, so
|
|
1226
|
+
// mixing e.g. width:"40%" with weighted columns can't oversubscribe past 100%.
|
|
1227
|
+
// The remainder is only computable when every explicit width is a percentage;
|
|
1228
|
+
// if any uses an absolute/relative unit (rem/px) we can't subtract it from
|
|
1229
|
+
// 100%, so weighted columns fall back to a share of the full width.
|
|
1230
|
+
let explicitPctTotal = 0;
|
|
1231
|
+
let allExplicitArePct = true;
|
|
1232
|
+
for (const c of cols) {
|
|
1233
|
+
if (hasExplicitWidth(c)) {
|
|
1234
|
+
const pct = colWidthPct(c.width);
|
|
1235
|
+
if (pct === null) allExplicitArePct = false;
|
|
1236
|
+
else explicitPctTotal += pct;
|
|
1120
1237
|
}
|
|
1121
1238
|
}
|
|
1122
|
-
|
|
1239
|
+
const remainderPct = allExplicitArePct ? Math.max(0, 100 - explicitPctTotal) : 100;
|
|
1240
|
+
const colEls = cols.map((c) => {
|
|
1241
|
+
const w = colWidthStyle(c, weightTotal, remainderPct);
|
|
1242
|
+
return el("col", w ? { style: "width:" + w } : {});
|
|
1243
|
+
});
|
|
1244
|
+
for (let i = 0; i < extraCount; i++) colEls.push(el("col", {}));
|
|
1245
|
+
return el("colgroup", {}, ...colEls);
|
|
1123
1246
|
}
|
|
1124
1247
|
|
|
1125
1248
|
function renderActionForm(node) {
|
|
@@ -1315,7 +1438,13 @@ function renderDataGrid(node) {
|
|
|
1315
1438
|
if (hasExtra) headCells.push(el("th", {}, ""));
|
|
1316
1439
|
const thead = el("thead", {}, el("tr", {}, ...headCells));
|
|
1317
1440
|
const tbody = el("tbody", {});
|
|
1318
|
-
|
|
1441
|
+
// A <colgroup> (present only when a column declares width/weight) sizes the
|
|
1442
|
+
// columns so table-layout:fixed stops splitting width equally; it must precede
|
|
1443
|
+
// <thead> in the table.
|
|
1444
|
+
const colgroup = buildColgroup(cols, hasExtra ? 1 : 0);
|
|
1445
|
+
const table = colgroup
|
|
1446
|
+
? el("table", { class: "pc-grid" }, colgroup, thead, tbody)
|
|
1447
|
+
: el("table", { class: "pc-grid" }, thead, tbody);
|
|
1319
1448
|
card.append(table);
|
|
1320
1449
|
const span = String((cols.length || 1) + (hasExtra ? 1 : 0));
|
|
1321
1450
|
|
|
@@ -1407,9 +1536,12 @@ function renderDataGrid(node) {
|
|
|
1407
1536
|
if (cg.title) wrap.append(el("div", { class: "pc-child-title" }, cg.title));
|
|
1408
1537
|
const ccols = cg.columns || [];
|
|
1409
1538
|
const cbody = el("tbody", {});
|
|
1410
|
-
const
|
|
1411
|
-
|
|
1412
|
-
|
|
1539
|
+
const ccolgroup = buildColgroup(ccols, cg.lazyField ? 1 : 0);
|
|
1540
|
+
const cthead = el("thead", {}, el("tr", {}, ...ccols.map((c) => el("th", {}, c.header || c.field)),
|
|
1541
|
+
...(cg.lazyField ? [el("th", {}, "")] : [])));
|
|
1542
|
+
const ctable = ccolgroup
|
|
1543
|
+
? el("table", { class: "pc-grid" }, ccolgroup, cthead, cbody)
|
|
1544
|
+
: el("table", { class: "pc-grid" }, cthead, cbody);
|
|
1413
1545
|
wrap.append(ctable);
|
|
1414
1546
|
try {
|
|
1415
1547
|
const { rows } = await getJSON(dataUrl(cg.source || "app", cg.table,
|
|
@@ -4,8 +4,20 @@ import assert from "node:assert/strict";
|
|
|
4
4
|
import type { AppApi, RuntimeContext } from "../context.ts";
|
|
5
5
|
import type { EngineClient, EngineJob, HostContext, JobHandler, WorkerSubscription } from "../host.ts";
|
|
6
6
|
import type { AppManifest } from "../manifest.ts";
|
|
7
|
-
import { DataLayer } from "./datasource.ts";
|
|
7
|
+
import { DataLayer, type ProvisionedSource } from "./datasource.ts";
|
|
8
8
|
import { mountWorkers, sdkDecisionEvaluator, type AppJobHandler } from "./workers.ts";
|
|
9
|
+
import { makeGateway } from "./gateway.ts";
|
|
10
|
+
import { LineageStore } from "./lineage-store.ts";
|
|
11
|
+
import {
|
|
12
|
+
__resetExecStoreForTests,
|
|
13
|
+
currentJobContext,
|
|
14
|
+
installExecStore,
|
|
15
|
+
type JobExecContext,
|
|
16
|
+
} from "../execContext.ts";
|
|
17
|
+
import { createNodeHost } from "../../adapters/node.ts";
|
|
18
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
9
21
|
|
|
10
22
|
/** A tiny engine that records registrations and can deliver a job to a handler. */
|
|
11
23
|
class MiniEngine implements EngineClient {
|
|
@@ -303,3 +315,126 @@ test("AppJobHandler carries optional In/Out variable types", async () => {
|
|
|
303
315
|
assert.deepEqual(await open(openJob, app), { count: 2 });
|
|
304
316
|
assert.equal(await voidReturn(job, app), undefined);
|
|
305
317
|
});
|
|
318
|
+
|
|
319
|
+
// --- Lineage threading + projection recording (issue #254) ---------------------
|
|
320
|
+
|
|
321
|
+
/** A DataLayer with a single default sqlite source, so `tryLineageStore` can record into it. */
|
|
322
|
+
async function dataLayerWithSqlite(): Promise<{ data: DataLayer; db: ProvisionedSource["db"]; cleanup: () => Promise<void> }> {
|
|
323
|
+
const dir = await mkdtemp(join(tmpdir(), "urban-workers-lineage-"));
|
|
324
|
+
const host = createNodeHost({ cwd: dir, log: () => {} });
|
|
325
|
+
const db = host.openSqlite(join(dir, "app.db"));
|
|
326
|
+
const src: ProvisionedSource = {
|
|
327
|
+
name: "main",
|
|
328
|
+
driver: "sqlite",
|
|
329
|
+
db,
|
|
330
|
+
source: makeGateway(db),
|
|
331
|
+
migrationsApplied: [],
|
|
332
|
+
close: () => db.close(),
|
|
333
|
+
};
|
|
334
|
+
const data = new DataLayer(new Map([["main", src]]), "main", {});
|
|
335
|
+
return { data, db, cleanup: async () => { try { db.close(); } catch { /* already closed */ } await rm(dir, { recursive: true, force: true }); } };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
test("mountWorkers threads the lineage rootRequestKey into the ambient job context", async () => {
|
|
339
|
+
__resetExecStoreForTests();
|
|
340
|
+
const host = createNodeHost({ cwd: process.cwd(), log: () => {} });
|
|
341
|
+
installExecStore(() => host.createAsyncStore?.<JobExecContext>());
|
|
342
|
+
const engine = new MiniEngine();
|
|
343
|
+
const { ctx } = makeCtx({ workers: [{ taskType: "spawn", handler: "workers/spawn.ts" }] }, engine);
|
|
344
|
+
const seen: Array<JobExecContext | undefined> = [];
|
|
345
|
+
const handler: AppJobHandler = () => {
|
|
346
|
+
seen.push(currentJobContext());
|
|
347
|
+
};
|
|
348
|
+
ctx.host.importModule = async () => ({ default: handler });
|
|
349
|
+
try {
|
|
350
|
+
await mountWorkers(ctx, makeApp());
|
|
351
|
+
// An instance carrying an envelope propagates its root.
|
|
352
|
+
await engine.deliver("spawn", {
|
|
353
|
+
jobKey: "j1",
|
|
354
|
+
jobType: "spawn",
|
|
355
|
+
processInstanceKey: "pi-child",
|
|
356
|
+
variables: { _urban: { lineage: { rootRequestKey: "ROOT", causedByInstanceKey: "pi-parent" } } },
|
|
357
|
+
});
|
|
358
|
+
// An instance with no envelope is treated as its own root.
|
|
359
|
+
await engine.deliver("spawn", { jobKey: "j2", jobType: "spawn", processInstanceKey: "pi-solo", variables: {} });
|
|
360
|
+
assert.equal(seen[0]?.rootRequestKey, "ROOT");
|
|
361
|
+
assert.equal(seen[1]?.rootRequestKey, "pi-solo");
|
|
362
|
+
} finally {
|
|
363
|
+
__resetExecStoreForTests();
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("mountWorkers records each activated job's lineage edge into the projection (idempotently)", async () => {
|
|
368
|
+
const engine = new MiniEngine();
|
|
369
|
+
const { ctx } = makeCtx({ workers: [{ taskType: "work", handler: "workers/work.ts" }] }, engine);
|
|
370
|
+
const handler: AppJobHandler = () => ({ ok: true });
|
|
371
|
+
ctx.host.importModule = async () => ({ default: handler });
|
|
372
|
+
const { data, db, cleanup } = await dataLayerWithSqlite();
|
|
373
|
+
try {
|
|
374
|
+
await mountWorkers(ctx, makeApp({ data }));
|
|
375
|
+
const job = {
|
|
376
|
+
jobKey: "j1",
|
|
377
|
+
jobType: "work",
|
|
378
|
+
processInstanceKey: "pi-child",
|
|
379
|
+
variables: { _urban: { lineage: { rootRequestKey: "ROOT", causedByInstanceKey: "pi-root" } } },
|
|
380
|
+
};
|
|
381
|
+
await engine.deliver("work", job);
|
|
382
|
+
await engine.deliver("work", { ...job, jobKey: "j2" }); // re-activation → idempotent
|
|
383
|
+
|
|
384
|
+
const tree = new LineageStore(db).getLineage("ROOT");
|
|
385
|
+
const child = tree.nodes.find((n) => n.instanceKey === "pi-child");
|
|
386
|
+
assert.equal(child?.causedByInstanceKey, "pi-root");
|
|
387
|
+
assert.equal(child?.edgeType, "weak");
|
|
388
|
+
assert.equal(new LineageStore(db).edges("ROOT").length, 1, "re-activation does not duplicate the edge");
|
|
389
|
+
} finally {
|
|
390
|
+
await cleanup();
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("mountWorkers records nothing when the app has no default data source (absent-safe)", async () => {
|
|
395
|
+
const engine = new MiniEngine();
|
|
396
|
+
const { ctx, logs } = makeCtx({ workers: [{ taskType: "work", handler: "workers/work.ts" }] }, engine);
|
|
397
|
+
const handler: AppJobHandler = () => ({ ok: true });
|
|
398
|
+
ctx.host.importModule = async () => ({ default: handler });
|
|
399
|
+
// makeApp's DataLayer has no default source; mounting + delivering must not throw.
|
|
400
|
+
await mountWorkers(ctx, makeApp());
|
|
401
|
+
await engine.deliver("work", { jobKey: "j1", jobType: "work", processInstanceKey: "pi", variables: {} });
|
|
402
|
+
assert.ok(!logs.some((l) => l.level === "error"));
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("mountWorkers surfaces a real provisioning failure as a warn (not the silent absent case)", async () => {
|
|
406
|
+
const engine = new MiniEngine();
|
|
407
|
+
const { ctx, logs } = makeCtx({ workers: [{ taskType: "work", handler: "workers/work.ts" }] }, engine);
|
|
408
|
+
const handler: AppJobHandler = () => ({ ok: true });
|
|
409
|
+
ctx.host.importModule = async () => ({ default: handler });
|
|
410
|
+
const { data, db, cleanup } = await dataLayerWithSqlite();
|
|
411
|
+
try {
|
|
412
|
+
// A default source IS configured, but provisioning the projection fails (closed db). This is a
|
|
413
|
+
// genuine fault, not the absent case, so it must be logged at `warn` with the error — never
|
|
414
|
+
// disguised as an expected "no default data source" debug, and never a failed worker mount.
|
|
415
|
+
db.close();
|
|
416
|
+
await mountWorkers(ctx, makeApp({ data }));
|
|
417
|
+
await engine.deliver("work", { jobKey: "j1", jobType: "work", processInstanceKey: "pi", variables: {} });
|
|
418
|
+
const warned = logs.find((l) => l.level === "warn" && l.msg.includes("failed to provision"));
|
|
419
|
+
assert.ok(warned, "a configured-but-broken datasource must warn, not silently degrade");
|
|
420
|
+
assert.ok(!logs.some((l) => l.msg.includes("no default data source")), "must not claim the datasource is absent");
|
|
421
|
+
} finally {
|
|
422
|
+
await cleanup();
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("mountWorkers surfaces a configured-but-missing default source as a warn (not the silent absent case)", async () => {
|
|
427
|
+
const engine = new MiniEngine();
|
|
428
|
+
const { ctx, logs } = makeCtx({ workers: [{ taskType: "work", handler: "workers/work.ts" }] }, engine);
|
|
429
|
+
const handler: AppJobHandler = () => ({ ok: true });
|
|
430
|
+
ctx.host.importModule = async () => ({ default: handler });
|
|
431
|
+
// A default source is NAMED but no such source is provisioned, so `data.source()` throws
|
|
432
|
+
// `no such data source "main"`. That is a genuine misconfiguration — it must warn, never be
|
|
433
|
+
// disguised as the expected "no default data source" absent case.
|
|
434
|
+
const data = new DataLayer(new Map(), "main", {});
|
|
435
|
+
await mountWorkers(ctx, makeApp({ data }));
|
|
436
|
+
await engine.deliver("work", { jobKey: "j1", jobType: "work", processInstanceKey: "pi", variables: {} });
|
|
437
|
+
const warned = logs.find((l) => l.level === "warn" && l.msg.includes("failed to provision"));
|
|
438
|
+
assert.ok(warned, "a configured-but-missing default source must warn, not silently degrade");
|
|
439
|
+
assert.ok(!logs.some((l) => l.msg.includes("no default data source")), "must not claim the datasource is absent");
|
|
440
|
+
});
|
|
@@ -8,6 +8,9 @@ import type { EngineJob, JobHandler, WorkerSubscription } from "../host.ts";
|
|
|
8
8
|
import { workerJobType, type Worker } from "../manifest.ts";
|
|
9
9
|
import { type DecisionEvaluator, type LlmRuntime, runLlmJob } from "./llm.ts";
|
|
10
10
|
import { runInJobContext } from "../execContext.ts";
|
|
11
|
+
import type { JobExecContext } from "../execContext.ts";
|
|
12
|
+
import { readLineage } from "../lineage.ts";
|
|
13
|
+
import { LineageStore } from "./lineage-store.ts";
|
|
11
14
|
|
|
12
15
|
/** The subset of the engine SDK the LLM decision-rails need: evaluate a DMN decision,
|
|
13
16
|
* whose `output` comes back as a JSON string (the orchestration-cluster contract). */
|
|
@@ -86,6 +89,60 @@ function withJobLog(app: AppApi, job: EngineJob): AppApi {
|
|
|
86
89
|
return { ...app, log: app.log.child(bindings) };
|
|
87
90
|
}
|
|
88
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Build the ambient {@link JobExecContext} a worker runs its handler within. Beyond the
|
|
94
|
+
* instance/element/jobType used for write-provenance, it resolves the lineage
|
|
95
|
+
* `rootRequestKey` (issue #254): the job's own `_urban.lineage` envelope root when present,
|
|
96
|
+
* else the instance's own key — so an instance with no envelope is treated as a root and any
|
|
97
|
+
* instance/message its handler spawns inherits that root with `causedByInstanceKey` set.
|
|
98
|
+
*/
|
|
99
|
+
function jobExecContext(job: EngineJob, jobType: string): JobExecContext {
|
|
100
|
+
return {
|
|
101
|
+
instanceKey: job.processInstanceKey,
|
|
102
|
+
elementId: job.elementId,
|
|
103
|
+
jobType,
|
|
104
|
+
rootRequestKey: readLineage(job.variables)?.rootRequestKey ?? job.processInstanceKey,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Build the framework lineage projection store over the app's DEFAULT data source, or `undefined`
|
|
110
|
+
* when the app configures none. The store records the `_urban.lineage` edge every activated job
|
|
111
|
+
* reveals (issue #254), so intent→progress lineage materialises for free — but it is a sidecar on
|
|
112
|
+
* the app's own source (like write-provenance), so an app with no datasource simply records
|
|
113
|
+
* nothing. Absent- and error-safe: any failure to provision degrades to no lineage recording, never
|
|
114
|
+
* a failed worker mount.
|
|
115
|
+
*/
|
|
116
|
+
function tryLineageStore(app: AppApi, log: RuntimeContext["host"]["log"]): LineageStore | undefined {
|
|
117
|
+
if (!app.data.hasDefaultSource()) {
|
|
118
|
+
// No default data source configured at all — lineage is a sidecar on the app's own source, so
|
|
119
|
+
// this is the expected absent case (like write-provenance): record nothing, silently.
|
|
120
|
+
log("debug", "lineage: no default data source; lineage recording disabled");
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
const source = app.data.source();
|
|
125
|
+
const store = new LineageStore(source.db);
|
|
126
|
+
store.ensureSchema();
|
|
127
|
+
return store;
|
|
128
|
+
} catch (err) {
|
|
129
|
+
// A default IS configured but resolving or provisioning it failed — a missing named source
|
|
130
|
+
// (`no such data source`) or a schema/DB error. That is a genuine fault, not the absent case,
|
|
131
|
+
// so surface it at `warn` rather than disguising it as "no source".
|
|
132
|
+
log("warn", "lineage: failed to provision projection store; lineage recording disabled", { error: String(err) });
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Record an activated job's lineage edge, never letting a projection write break the job. */
|
|
138
|
+
function recordJobLineage(store: LineageStore, job: EngineJob, log: RuntimeContext["host"]["log"]): void {
|
|
139
|
+
try {
|
|
140
|
+
store.recordFromJob(job);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
log("warn", "lineage: failed to record job edge", { jobType: job.jobType, error: String(err) });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
89
146
|
/**
|
|
90
147
|
* Resolve a handler for `jobType` from a loaded module, in priority order:
|
|
91
148
|
* 1. `handlers[jobType]` (a map keyed by job type — the multi-type module case)
|
|
@@ -116,6 +173,11 @@ export async function mountWorkers(ctx: RuntimeContext, app: AppApi): Promise<Wo
|
|
|
116
173
|
const subs: WorkerSubscription[] = [];
|
|
117
174
|
const jobTypes: string[] = [];
|
|
118
175
|
|
|
176
|
+
// The framework lineage projection (issue #254): each activated job records the `_urban.lineage`
|
|
177
|
+
// edge its instance carries, so intent→progress lineage materialises for free. Absent when the
|
|
178
|
+
// app configures no default data source.
|
|
179
|
+
const lineageStore = tryLineageStore(app, ctx.host.log);
|
|
180
|
+
|
|
119
181
|
// Cache module loads so a multi-type handler module is imported once.
|
|
120
182
|
const moduleCache = new Map<string, Record<string, unknown>>();
|
|
121
183
|
const joinRoot = (p: string): string =>
|
|
@@ -157,11 +219,13 @@ export async function mountWorkers(ctx: RuntimeContext, app: AppApi): Promise<Wo
|
|
|
157
219
|
env: (n) => app.env(n),
|
|
158
220
|
evaluateDecision: app.sdk ? sdkDecisionEvaluator(app.sdk) : undefined,
|
|
159
221
|
};
|
|
160
|
-
const wrapped: JobHandler = (job) =>
|
|
161
|
-
|
|
162
|
-
|
|
222
|
+
const wrapped: JobHandler = (job) => {
|
|
223
|
+
if (lineageStore) recordJobLineage(lineageStore, job, ctx.host.log);
|
|
224
|
+
return runInJobContext(
|
|
225
|
+
jobExecContext(job, jobType),
|
|
163
226
|
() => runLlmJob(job.variables, binding, rt),
|
|
164
227
|
);
|
|
228
|
+
};
|
|
165
229
|
const sub = await ctx.engine.registerWorker(jobType, wrapped, {
|
|
166
230
|
workerName: `${ctx.manifest.id}:${jobType}`,
|
|
167
231
|
});
|
|
@@ -178,11 +242,13 @@ export async function mountWorkers(ctx: RuntimeContext, app: AppApi): Promise<Wo
|
|
|
178
242
|
`(expected handlers["${jobType}"], a named export, or a default function)`,
|
|
179
243
|
);
|
|
180
244
|
}
|
|
181
|
-
const wrapped: JobHandler = (job) =>
|
|
182
|
-
|
|
183
|
-
|
|
245
|
+
const wrapped: JobHandler = (job) => {
|
|
246
|
+
if (lineageStore) recordJobLineage(lineageStore, job, ctx.host.log);
|
|
247
|
+
return runInJobContext(
|
|
248
|
+
jobExecContext(job, jobType),
|
|
184
249
|
() => handler(job, withJobLog(app, job)),
|
|
185
250
|
);
|
|
251
|
+
};
|
|
186
252
|
const sub = await ctx.engine.registerWorker(jobType, wrapped, {
|
|
187
253
|
workerName: `${ctx.manifest.id}:${jobType}`,
|
|
188
254
|
});
|
|
@@ -11,6 +11,9 @@ import {
|
|
|
11
11
|
type NanoSdkJobWorkerConfig,
|
|
12
12
|
} from "./nanosdk.ts";
|
|
13
13
|
import { BpmnError } from "../core/host.ts";
|
|
14
|
+
import { __resetExecStoreForTests, installExecStore, runInJobContext } from "../core/execContext.ts";
|
|
15
|
+
import { readLineage } from "../core/lineage.ts";
|
|
16
|
+
import { createNodeHost } from "../adapters/node.ts";
|
|
14
17
|
|
|
15
18
|
/** A fake nano-sdk client that records calls and lets a test drive its job worker. */
|
|
16
19
|
function fakeSdkClient(overrides: Partial<NanoSdkClient> = {}): NanoSdkClient & {
|
|
@@ -133,7 +136,8 @@ test("createInstance routes through the SDK and coerces the key", async () => {
|
|
|
133
136
|
const engine = new SdkEngineClient(client);
|
|
134
137
|
const res = await engine.createInstance({ processDefinitionId: "p", variables: { a: 1 } });
|
|
135
138
|
assert.equal(res.processInstanceKey, "99");
|
|
136
|
-
|
|
139
|
+
// Caller variables are preserved (the SDK also auto-threads a `_urban.lineage` envelope, tested below).
|
|
140
|
+
assert.equal(res.variables?.a, 1);
|
|
137
141
|
assert.ok(client.calls.includes("createProcessInstance"));
|
|
138
142
|
});
|
|
139
143
|
|
|
@@ -162,7 +166,7 @@ test("cancelInstance routes through the SDK", async () => {
|
|
|
162
166
|
assert.ok(client.calls.includes("cancelProcessInstance") || seen !== undefined);
|
|
163
167
|
});
|
|
164
168
|
|
|
165
|
-
test("publishMessage defaults correlationKey
|
|
169
|
+
test("publishMessage defaults correlationKey and auto-threads lineage onto variables", async () => {
|
|
166
170
|
let seen: Record<string, unknown> | undefined;
|
|
167
171
|
const client = fakeSdkClient({
|
|
168
172
|
publishMessage: async (input) => {
|
|
@@ -172,7 +176,12 @@ test("publishMessage defaults correlationKey/variables", async () => {
|
|
|
172
176
|
});
|
|
173
177
|
const engine = new SdkEngineClient(client);
|
|
174
178
|
await engine.publishMessage({ name: "m" });
|
|
175
|
-
assert.
|
|
179
|
+
assert.equal(seen?.name, "m");
|
|
180
|
+
assert.equal(seen?.correlationKey, "");
|
|
181
|
+
// With no ambient job context this is a top-level publish: a fresh root envelope is minted.
|
|
182
|
+
const env = readLineage(seen?.variables);
|
|
183
|
+
assert.ok(env, "a lineage envelope is threaded onto the message variables");
|
|
184
|
+
assert.equal(env?.causedByInstanceKey, undefined);
|
|
176
185
|
});
|
|
177
186
|
|
|
178
187
|
test("searchUserTasks passes zero-wait consistency and maps items", async () => {
|
|
@@ -670,3 +679,88 @@ test("createNanoSdkEngineClient uses an injected client factory with the resolve
|
|
|
670
679
|
assert.deepEqual(seen, { restAddress: "http://x/v2", token: "t", transport: "falcon" });
|
|
671
680
|
assert.ok(engine);
|
|
672
681
|
});
|
|
682
|
+
|
|
683
|
+
// --- Lineage auto-threading (issue #254) ---------------------------------------
|
|
684
|
+
|
|
685
|
+
/** A fake client that captures the variables handed to createProcessInstance/publishMessage. */
|
|
686
|
+
function capturingClient(): NanoSdkClient & { createVars?: Record<string, unknown>; msgVars?: Record<string, unknown> } {
|
|
687
|
+
const base = fakeSdkClient();
|
|
688
|
+
const cap: NanoSdkClient & { createVars?: Record<string, unknown>; msgVars?: Record<string, unknown> } = {
|
|
689
|
+
...base,
|
|
690
|
+
async createProcessInstance(input) {
|
|
691
|
+
cap.createVars = input.variables;
|
|
692
|
+
return { processInstanceKey: 99, variables: input.variables };
|
|
693
|
+
},
|
|
694
|
+
async publishMessage(input) {
|
|
695
|
+
cap.msgVars = input.variables;
|
|
696
|
+
return { key: 1 };
|
|
697
|
+
},
|
|
698
|
+
};
|
|
699
|
+
return cap;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
test("createInstance mints a fresh root envelope for a top-level request (no ambient)", async () => {
|
|
703
|
+
__resetExecStoreForTests();
|
|
704
|
+
const client = capturingClient();
|
|
705
|
+
const engine = new SdkEngineClient(client);
|
|
706
|
+
await engine.createInstance({ processDefinitionId: "p", variables: { a: 1 } });
|
|
707
|
+
const env = readLineage(client.createVars);
|
|
708
|
+
assert.ok(env, "an envelope was threaded onto the variables");
|
|
709
|
+
assert.notEqual(env?.rootRequestKey, "");
|
|
710
|
+
assert.equal(env?.causedByInstanceKey, undefined, "a fresh root has no cause");
|
|
711
|
+
assert.equal(client.createVars?.a, 1, "caller variables are preserved");
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
test("createInstance propagates the ambient lineage and stamps causedByInstanceKey", async () => {
|
|
715
|
+
__resetExecStoreForTests();
|
|
716
|
+
const host = createNodeHost({ cwd: process.cwd(), log: () => {} });
|
|
717
|
+
installExecStore(() => host.createAsyncStore?.());
|
|
718
|
+
const client = capturingClient();
|
|
719
|
+
const engine = new SdkEngineClient(client);
|
|
720
|
+
try {
|
|
721
|
+
await runInJobContext({ instanceKey: "pi-parent", rootRequestKey: "root-1", jobType: "w" }, () =>
|
|
722
|
+
engine.createInstance({ processDefinitionId: "child" }),
|
|
723
|
+
);
|
|
724
|
+
} finally {
|
|
725
|
+
__resetExecStoreForTests();
|
|
726
|
+
}
|
|
727
|
+
assert.deepEqual(readLineage(client.createVars), {
|
|
728
|
+
rootRequestKey: "root-1",
|
|
729
|
+
causedByInstanceKey: "pi-parent",
|
|
730
|
+
});
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
test("createInstance leaves an explicit caller-supplied envelope untouched (override wins)", async () => {
|
|
734
|
+
__resetExecStoreForTests();
|
|
735
|
+
const host = createNodeHost({ cwd: process.cwd(), log: () => {} });
|
|
736
|
+
installExecStore(() => host.createAsyncStore?.());
|
|
737
|
+
const client = capturingClient();
|
|
738
|
+
const engine = new SdkEngineClient(client);
|
|
739
|
+
try {
|
|
740
|
+
await runInJobContext({ instanceKey: "pi-parent", rootRequestKey: "root-1", jobType: "w" }, () =>
|
|
741
|
+
engine.createInstance({
|
|
742
|
+
processDefinitionId: "child",
|
|
743
|
+
variables: { _urban: { lineage: { rootRequestKey: "explicit" } } },
|
|
744
|
+
}),
|
|
745
|
+
);
|
|
746
|
+
} finally {
|
|
747
|
+
__resetExecStoreForTests();
|
|
748
|
+
}
|
|
749
|
+
assert.deepEqual(readLineage(client.createVars), { rootRequestKey: "explicit", causedByInstanceKey: undefined });
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
test("publishMessage threads lineage so message-started instances inherit it", async () => {
|
|
753
|
+
__resetExecStoreForTests();
|
|
754
|
+
const host = createNodeHost({ cwd: process.cwd(), log: () => {} });
|
|
755
|
+
installExecStore(() => host.createAsyncStore?.());
|
|
756
|
+
const client = capturingClient();
|
|
757
|
+
const engine = new SdkEngineClient(client);
|
|
758
|
+
try {
|
|
759
|
+
await runInJobContext({ instanceKey: "pi-9", rootRequestKey: "root-9", jobType: "w" }, () =>
|
|
760
|
+
engine.publishMessage({ name: "start" }),
|
|
761
|
+
);
|
|
762
|
+
} finally {
|
|
763
|
+
__resetExecStoreForTests();
|
|
764
|
+
}
|
|
765
|
+
assert.deepEqual(readLineage(client.msgVars), { rootRequestKey: "root-9", causedByInstanceKey: "pi-9" });
|
|
766
|
+
});
|