@milaboratories/pl-middle-layer 1.74.19 → 1.76.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 (87) hide show
  1. package/dist/index.cjs +1 -0
  2. package/dist/index.d.ts +3 -2
  3. package/dist/index.js +2 -2
  4. package/dist/middle_layer/block.cjs.map +1 -1
  5. package/dist/middle_layer/block.js.map +1 -1
  6. package/dist/middle_layer/block_action_context.cjs +57 -0
  7. package/dist/middle_layer/block_action_context.cjs.map +1 -0
  8. package/dist/middle_layer/block_action_context.d.ts +47 -0
  9. package/dist/middle_layer/block_action_context.d.ts.map +1 -0
  10. package/dist/middle_layer/block_action_context.js +57 -0
  11. package/dist/middle_layer/block_action_context.js.map +1 -0
  12. package/dist/middle_layer/build_stamp.cjs +1 -1
  13. package/dist/middle_layer/build_stamp.js +1 -1
  14. package/dist/middle_layer/column_links.cjs +106 -0
  15. package/dist/middle_layer/column_links.cjs.map +1 -0
  16. package/dist/middle_layer/column_links.js +104 -0
  17. package/dist/middle_layer/column_links.js.map +1 -0
  18. package/dist/middle_layer/index.d.ts +2 -1
  19. package/dist/middle_layer/middle_layer.cjs +1 -0
  20. package/dist/middle_layer/middle_layer.cjs.map +1 -1
  21. package/dist/middle_layer/middle_layer.js +1 -0
  22. package/dist/middle_layer/middle_layer.js.map +1 -1
  23. package/dist/middle_layer/project.cjs +53 -19
  24. package/dist/middle_layer/project.cjs.map +1 -1
  25. package/dist/middle_layer/project.d.ts +6 -0
  26. package/dist/middle_layer/project.d.ts.map +1 -1
  27. package/dist/middle_layer/project.js +54 -20
  28. package/dist/middle_layer/project.js.map +1 -1
  29. package/dist/middle_layer/project_overview.cjs +56 -24
  30. package/dist/middle_layer/project_overview.cjs.map +1 -1
  31. package/dist/middle_layer/project_overview.js +55 -25
  32. package/dist/middle_layer/project_overview.js.map +1 -1
  33. package/dist/model/args_derivation.cjs +153 -0
  34. package/dist/model/args_derivation.cjs.map +1 -0
  35. package/dist/model/args_derivation.d.ts +21 -0
  36. package/dist/model/args_derivation.d.ts.map +1 -0
  37. package/dist/model/args_derivation.js +148 -0
  38. package/dist/model/args_derivation.js.map +1 -0
  39. package/dist/model/project_helper.cjs +34 -8
  40. package/dist/model/project_helper.cjs.map +1 -1
  41. package/dist/model/project_helper.d.ts +18 -5
  42. package/dist/model/project_helper.d.ts.map +1 -1
  43. package/dist/model/project_helper.js +34 -8
  44. package/dist/model/project_helper.js.map +1 -1
  45. package/dist/model/project_model.cjs +2 -1
  46. package/dist/model/project_model.cjs.map +1 -1
  47. package/dist/model/project_model.d.ts +1 -1
  48. package/dist/model/project_model.js +2 -1
  49. package/dist/model/project_model.js.map +1 -1
  50. package/dist/mutator/project.cjs +73 -12
  51. package/dist/mutator/project.cjs.map +1 -1
  52. package/dist/mutator/project.d.ts +1 -0
  53. package/dist/mutator/project.d.ts.map +1 -1
  54. package/dist/mutator/project.js +73 -12
  55. package/dist/mutator/project.js.map +1 -1
  56. package/dist/mutator/template/render_block.cjs +35 -2
  57. package/dist/mutator/template/render_block.cjs.map +1 -1
  58. package/dist/mutator/template/render_block.js +35 -4
  59. package/dist/mutator/template/render_block.js.map +1 -1
  60. package/dist/mutator/template/render_template.cjs +16 -1
  61. package/dist/mutator/template/render_template.cjs.map +1 -1
  62. package/dist/mutator/template/render_template.d.ts +12 -1
  63. package/dist/mutator/template/render_template.d.ts.map +1 -1
  64. package/dist/mutator/template/render_template.js +16 -2
  65. package/dist/mutator/template/render_template.js.map +1 -1
  66. package/package.json +16 -16
  67. package/src/middle_layer/block.ts +4 -1
  68. package/src/middle_layer/block_action_context.test.ts +148 -0
  69. package/src/middle_layer/block_action_context.ts +162 -0
  70. package/src/middle_layer/column_links.test.ts +350 -0
  71. package/src/middle_layer/column_links.ts +152 -0
  72. package/src/middle_layer/index.ts +6 -0
  73. package/src/middle_layer/middle_layer.ts +2 -0
  74. package/src/middle_layer/project.ts +52 -5
  75. package/src/middle_layer/project_overview.test.ts +200 -0
  76. package/src/middle_layer/project_overview.ts +82 -28
  77. package/src/model/args.test.ts +39 -1
  78. package/src/model/args_derivation.test.ts +326 -0
  79. package/src/model/args_derivation.ts +194 -0
  80. package/src/model/project_helper.ts +55 -25
  81. package/src/model/project_model.ts +7 -2
  82. package/src/mutator/project-v3.test.ts +105 -1
  83. package/src/mutator/project.ts +128 -37
  84. package/src/mutator/project_action_context.test.ts +178 -0
  85. package/src/mutator/template/render_block.ts +70 -5
  86. package/src/mutator/template/render_template.ts +22 -2
  87. package/src/test/fake_project.ts +222 -0
@@ -0,0 +1,162 @@
1
+ import type {
2
+ KeyValueString,
3
+ OptionalSignedResourceId,
4
+ SignedResourceId,
5
+ } from "@milaboratories/pl-client";
6
+ import { isNullSignedResourceId, parseSignedResourceId } from "@milaboratories/pl-client";
7
+ import { projectFieldName } from "../model/project_model";
8
+
9
+ /** Which render of a block the action context tree belongs to. */
10
+ export type BlockActionContextMode = "prod" | "staging";
11
+
12
+ /** An action transition: transition/<namespace>/<resource>/<ns> in an action context. */
13
+ export type BlockActionTransition = {
14
+ namespace: string;
15
+ /** The resource that reported, e.g. the render or a command it created. */
16
+ resource: string;
17
+ /** Server clock in nanoseconds when the transition was recorded, as a decimal string. */
18
+ timestampNs: string;
19
+ state: string;
20
+ detail: string;
21
+ reason: string;
22
+ };
23
+
24
+ /**
25
+ * A reading: reading/<namespace>/<resource>/<key> in an action context. The backend sets it asynchronously:
26
+ * of two readings of one key, the one from the transaction opened later stays.
27
+ */
28
+ export type BlockActionReading = {
29
+ namespace: string;
30
+ resource: string;
31
+ key: string;
32
+ /** Opaque bytes as UTF-8 text; the reporter owns the format. */
33
+ value: string;
34
+ };
35
+
36
+ /** One action context: the action context of one render and the renders it created. */
37
+ export type BlockActionContextNode = {
38
+ /**
39
+ * Global resource ID of this ActionContext resource itself, as a decimal string. Not the ID of the
40
+ * render it describes: that is {@link BlockActionContextNode.resource}. Pass it back to the MCP
41
+ * tool to fetch this node's subtree.
42
+ */
43
+ contextId: string;
44
+ /**
45
+ * The render the context belongs to: the resource of its first transition under namespace "render",
46
+ * which the workflow controller records when it evaluates the render.
47
+ * Undefined until that transition is applied, or on a backend without block action context reports.
48
+ */
49
+ resource?: string;
50
+ /** Context baggage: name, block-pack, block-id, run-id and so on. */
51
+ baggage: Record<string, string>;
52
+ /** In time order. */
53
+ transitions: BlockActionTransition[];
54
+ readings: BlockActionReading[];
55
+ children: BlockActionContextNode[];
56
+ };
57
+
58
+ const childFieldPrefix = "child/";
59
+
60
+ /**
61
+ * Reads the action context tree of a block render: the root action context the project holds in
62
+ * <blockId>-prodActionContext or <blockId>-stagingActionContext, and every child context under it.
63
+ * Reads the current state in one transaction; it does not watch for changes.
64
+ *
65
+ * @returns undefined when the block has no action context: it was not rendered yet, or the backend
66
+ * does not support block action context.
67
+ */
68
+ /**
69
+ * The part of a read transaction the reader uses: a {@link PlTransaction} satisfies it, and a test
70
+ * can serve it without a backend.
71
+ */
72
+ export interface ActionContextReadTx {
73
+ getResourceData(
74
+ rId: SignedResourceId,
75
+ loadFields: true,
76
+ ): Promise<{
77
+ readonly data?: Uint8Array;
78
+ readonly fields: readonly { readonly name: string; readonly value: OptionalSignedResourceId }[];
79
+ }>;
80
+ listKeyValuesString(rId: SignedResourceId): Promise<KeyValueString[]>;
81
+ }
82
+
83
+ /** The part of a client the reader uses: a {@link PlClient} satisfies it. */
84
+ export interface ActionContextReader {
85
+ withReadTx<T>(name: string, body: (tx: ActionContextReadTx) => Promise<T>): Promise<T>;
86
+ }
87
+
88
+ export async function readBlockActionContextTree(
89
+ pl: ActionContextReader,
90
+ projectRid: SignedResourceId,
91
+ blockId: string,
92
+ mode: BlockActionContextMode,
93
+ ): Promise<BlockActionContextNode | undefined> {
94
+ return await pl.withReadTx("readBlockActionContextTree", async (tx) => {
95
+ const project = await tx.getResourceData(projectRid, true);
96
+ const fieldName = projectFieldName(
97
+ blockId,
98
+ mode === "prod" ? "prodActionContext" : "stagingActionContext",
99
+ );
100
+ const actionContextField = project.fields.find((f) => f.name === fieldName);
101
+ if (actionContextField === undefined || isNullSignedResourceId(actionContextField.value))
102
+ return undefined;
103
+
104
+ return await readActionContextNode(tx, actionContextField.value);
105
+ });
106
+ }
107
+
108
+ async function readActionContextNode(
109
+ tx: ActionContextReadTx,
110
+ contextRef: SignedResourceId,
111
+ ): Promise<BlockActionContextNode> {
112
+ const [context, kvs] = await Promise.all([
113
+ tx.getResourceData(contextRef, true),
114
+ tx.listKeyValuesString(contextRef),
115
+ ]);
116
+
117
+ const baggage =
118
+ context.data === undefined || context.data.length === 0
119
+ ? {}
120
+ : (JSON.parse(Buffer.from(context.data).toString()) as Record<string, string>);
121
+
122
+ const transitions: BlockActionTransition[] = [];
123
+ const readings: BlockActionReading[] = [];
124
+ for (const { key, value } of kvs) {
125
+ const parts = key.split("/");
126
+ if (parts[0] === "transition" && parts.length === 4) {
127
+ const parsed = JSON.parse(value) as { state?: string; detail?: string; reason?: string };
128
+ transitions.push({
129
+ namespace: parts[1],
130
+ resource: parts[2],
131
+ timestampNs: parts[3],
132
+ state: parsed.state ?? "",
133
+ detail: parsed.detail ?? "",
134
+ reason: parsed.reason ?? "",
135
+ });
136
+ } else if (parts[0] === "reading" && parts.length === 4) {
137
+ readings.push({ namespace: parts[1], resource: parts[2], key: parts[3], value });
138
+ }
139
+ }
140
+ transitions.sort((a, b) => compareDecimal(a.timestampNs, b.timestampNs));
141
+
142
+ const childRefs: SignedResourceId[] = [];
143
+ for (const f of context.fields) {
144
+ if (f.name.startsWith(childFieldPrefix) && !isNullSignedResourceId(f.value))
145
+ childRefs.push(f.value);
146
+ }
147
+ const children = await Promise.all(childRefs.map((ref) => readActionContextNode(tx, ref)));
148
+
149
+ return {
150
+ contextId: String(parseSignedResourceId(contextRef).globalId),
151
+ resource: transitions.find((t) => t.namespace === "render")?.resource,
152
+ baggage,
153
+ transitions,
154
+ readings,
155
+ children,
156
+ };
157
+ }
158
+
159
+ /** Compares non-negative decimal integers given as strings. */
160
+ function compareDecimal(a: string, b: string): number {
161
+ return a.length !== b.length ? a.length - b.length : a < b ? -1 : a > b ? 1 : 0;
162
+ }
@@ -0,0 +1,350 @@
1
+ import {
2
+ Annotation,
3
+ createColumnValueId,
4
+ createGlobalPObjectId,
5
+ type PColumnSpec,
6
+ type ColumnLink,
7
+ } from "@milaboratories/pl-model-common";
8
+ import type { PlTreeState } from "@milaboratories/pl-tree";
9
+ import { mapValues } from "es-toolkit";
10
+ import { getQuickJS } from "quickjs-emscripten";
11
+ import { beforeAll, describe, expect, test } from "vitest";
12
+ import { ProjectHelper } from "../model/project_helper";
13
+ import { FakeProject } from "../test/fake_project";
14
+ import {
15
+ blockDependencies,
16
+ blockDependents,
17
+ labelBlockColumnLinks,
18
+ type ColumnLinksEnv,
19
+ } from "./column_links";
20
+
21
+ let env: ColumnLinksEnv;
22
+
23
+ beforeAll(async () => {
24
+ const projectHelper = new ProjectHelper(await getQuickJS());
25
+ env = { projectHelper, logger: projectHelper.logger };
26
+ });
27
+
28
+ const condition = createGlobalPObjectId("samples", "condition");
29
+ const pick = (value: string, column = condition) => createColumnValueId({ column, value });
30
+
31
+ describe("blockDependencies and blockDependents", () => {
32
+ test("check each reference against the last run of its producer, on both sides", async () => {
33
+ const prj = new FakeProject();
34
+ prj.block("samples", {
35
+ currentArgs: prj.args({}),
36
+ prodArgs: prj.args({}),
37
+ prodCtx: prj.value("BContext", {}),
38
+ prodUiCtx: prj.frame({ condition: { spec: SPEC, cells: ["treated", "mock"] } }),
39
+ });
40
+ prj.block("x", { currentArgs: prj.args({ levels: [pick("treated"), pick("control")] }) });
41
+ const tree = prj.tree();
42
+
43
+ const link: ColumnLink = {
44
+ producer: { blockId: "samples", name: "condition" },
45
+ holderBlockId: "x",
46
+ ref: condition,
47
+ values: ["treated", "control"],
48
+ columnLabel: "Condition",
49
+ check: { type: "missingValues", values: ["control"] },
50
+ };
51
+ expect(await dependenciesOf(tree, "x")).toEqual([link]);
52
+ expect(await dependentsOf(tree, "samples")).toEqual([link]);
53
+ });
54
+
55
+ test("a producer with no last run is not run, labelled from what it prepares", async () => {
56
+ const prj = new FakeProject();
57
+ prj.block("samples", {
58
+ currentArgs: prj.args({}),
59
+ stagingCtx: prj.value("BContext", {}),
60
+ stagingUiCtx: prj.frame({ condition: { spec: SPEC, cells: ["treated"] } }),
61
+ });
62
+ prj.block("x", { currentArgs: prj.args({ target: pick("treated") }) });
63
+ const tree = prj.tree();
64
+
65
+ expect(await dependenciesOf(tree, "x")).toMatchObject([
66
+ { columnLabel: "Condition", check: { type: "notRun" } },
67
+ ]);
68
+ expect(await dependentsOf(tree, "samples")).toMatchObject([
69
+ { holderBlockId: "x", check: { type: "notRun" } },
70
+ ]);
71
+ });
72
+
73
+ test("a producer with a last run whose results failed to render is unavailable", async () => {
74
+ const prj = new FakeProject();
75
+ prj.block("samples", {
76
+ prodArgs: prj.args({}),
77
+ prodCtx: prj.value("BContext", {}),
78
+ prodUiCtx: { error: prj.error("render failed") },
79
+ });
80
+ prj.block("x", { currentArgs: prj.args({ target: pick("treated") }) });
81
+ const tree = prj.tree();
82
+
83
+ expect(await dependenciesOf(tree, "x")).toMatchObject([{ check: { type: "unavailable" } }]);
84
+ expect(await dependentsOf(tree, "samples")).toMatchObject([{ check: { type: "unavailable" } }]);
85
+ });
86
+
87
+ test("a producer with a last run whose results are not there yet is pending", async () => {
88
+ const prj = new FakeProject();
89
+ prj.block("samples", {
90
+ prodArgs: prj.args({}),
91
+ prodCtx: prj.value("BContext", {}),
92
+ prodUiCtx: "unresolved",
93
+ });
94
+ prj.block("x", { currentArgs: prj.args({ target: pick("treated") }) });
95
+
96
+ expect(await dependenciesOf(prj.tree(), "x")).toMatchObject([{ check: { type: "pending" } }]);
97
+ });
98
+
99
+ test("a failed result list that never locks settles the links", async () => {
100
+ const prj = new FakeProject();
101
+ prj.block("samples", {
102
+ prodArgs: prj.args({}),
103
+ prodCtx: prj.value("BContext", {}),
104
+ prodUiCtx: prj.frame({}, { state: "failed" }),
105
+ });
106
+ prj.block("x", { currentArgs: prj.args({ target: pick("treated") }) });
107
+ const tree = prj.tree();
108
+ const dependencies = blockDependencies(tree.entry(), "x", env);
109
+ const dependents = blockDependents(tree.entry(), "samples", env);
110
+
111
+ expect(await dependencies.getFullValue()).toMatchObject({
112
+ stable: true,
113
+ value: [{ check: { type: "unavailable" } }],
114
+ });
115
+ expect(await dependents.getFullValue()).toMatchObject({
116
+ stable: true,
117
+ value: [{ check: { type: "unavailable" } }],
118
+ });
119
+ });
120
+
121
+ test("an output of a result list still open is pending until the list locks", async () => {
122
+ const prj = new FakeProject();
123
+ const frame = prj.frame({}, { state: "open" });
124
+ prj.block("samples", { prodArgs: prj.args({}), prodUiCtx: frame });
125
+ prj.block("x", { currentArgs: prj.args({ target: pick("treated") }) });
126
+ const tree = prj.tree();
127
+ const dependencies = blockDependencies(tree.entry(), "x", env);
128
+
129
+ expect(await dependencies.getFullValue()).toMatchObject({
130
+ stable: false,
131
+ value: [{ check: { type: "pending" } }],
132
+ });
133
+
134
+ prj.extend(frame, { condition: { spec: SPEC, cells: ["treated"] } }, { state: "locked" });
135
+ tree.updateFromResourceData(prj.changes());
136
+
137
+ expect(await dependencies.getFullValue()).toMatchObject({
138
+ stable: true,
139
+ value: [{ check: { type: "ok" } }],
140
+ });
141
+ });
142
+
143
+ test("a block without current args is read from the args of its last run", async () => {
144
+ const prj = new FakeProject();
145
+ prj.block("samples", {
146
+ prodArgs: prj.args({}),
147
+ prodUiCtx: prj.frame({ condition: { spec: SPEC, cells: ["treated"] } }),
148
+ });
149
+ prj.block("x", { prodArgs: prj.args({ target: pick("treated") }) });
150
+
151
+ expect(await dependenciesOf(prj.tree(), "x")).toMatchObject([{ check: { type: "ok" } }]);
152
+ });
153
+
154
+ test("a block whose argsIssues found errors keeps its links, read from its current args", async () => {
155
+ const prj = new FakeProject();
156
+ const args = { target: pick("treated"), threshold: undefined };
157
+ prj.block("samples", {
158
+ currentArgs: prj.args({}),
159
+ prodArgs: prj.args({}),
160
+ prodUiCtx: prj.frame({ condition: { spec: SPEC, cells: ["treated"] } }),
161
+ });
162
+ prj.block("x", {
163
+ currentArgs: prj.args(args),
164
+ inputsValid: prj.argsErrors([{ severity: "error", message: "Set a threshold" }], args),
165
+ });
166
+ const tree = prj.tree();
167
+
168
+ expect(await dependenciesOf(tree, "x")).toMatchObject([
169
+ { producer: { blockId: "samples" }, ref: condition, check: { type: "ok" } },
170
+ ]);
171
+ expect(await dependentsOf(tree, "samples")).toMatchObject([
172
+ { holderBlockId: "x", ref: condition, check: { type: "ok" } },
173
+ ]);
174
+ });
175
+
176
+ test("recalculate when the args of a block change", async () => {
177
+ const prj = new FakeProject();
178
+ prj.block("samples", {
179
+ currentArgs: prj.args({}),
180
+ prodArgs: prj.args({}),
181
+ prodUiCtx: prj.frame({ condition: { spec: SPEC, cells: ["treated"] } }),
182
+ });
183
+ prj.block("x", { currentArgs: prj.args({ target: pick("control") }) });
184
+ const tree = prj.tree();
185
+ const dependencies = blockDependencies(tree.entry(), "x", env);
186
+
187
+ expect(await dependencies.getValue()).toMatchObject([
188
+ { check: { type: "missingValues", values: ["control"] } },
189
+ ]);
190
+
191
+ prj.block("x", { currentArgs: prj.args({ target: pick("treated") }) });
192
+ tree.updateFromResourceData(prj.changes());
193
+
194
+ expect(await dependencies.getValue()).toMatchObject([{ check: { type: "ok" } }]);
195
+ });
196
+ });
197
+
198
+ describe("what the column links of a block watch", () => {
199
+ const donor = createGlobalPObjectId("donors", "donor");
200
+
201
+ /** Two producers, each referenced by one block below them, the second block also by a third. */
202
+ function twoChains() {
203
+ const prj = new FakeProject();
204
+ const samplesFrame = prj.frame(
205
+ { condition: { spec: SPEC, cells: ["treated"] } },
206
+ { state: "open" },
207
+ );
208
+ prj.block("samples", {
209
+ currentArgs: prj.args({}),
210
+ prodArgs: prj.args({}),
211
+ prodUiCtx: samplesFrame,
212
+ });
213
+ const donorsArgs = prj.args({});
214
+ prj.block("donors", {
215
+ currentArgs: donorsArgs,
216
+ prodArgs: prj.args({}),
217
+ prodUiCtx: prj.frame({ donor: { spec: SPEC, cells: ["d1"] } }),
218
+ });
219
+ prj.block("x", { currentArgs: prj.args({ target: pick("treated") }) });
220
+ prj.block("y", { currentArgs: prj.args({ target: pick("d1", donor) }) });
221
+ const tree = prj.tree();
222
+ const watched = {
223
+ xDependencies: blockDependencies(tree.entry(), "x", env),
224
+ yDependencies: blockDependencies(tree.entry(), "y", env),
225
+ samplesDependents: blockDependents(tree.entry(), "samples", env),
226
+ donorsDependents: blockDependents(tree.entry(), "donors", env),
227
+ };
228
+ return { prj, tree, samplesFrame, donorsArgs, watched };
229
+ }
230
+
231
+ const changed = (watched: Record<string, { isChanged(): boolean }>) =>
232
+ mapValues(watched, (computable) => computable.isChanged());
233
+
234
+ test("a new run of one producer leaves the links of blocks not referencing it alone", async () => {
235
+ const { prj, tree, donorsArgs, watched } = twoChains();
236
+ await Promise.all(Object.values(watched).map((c) => c.getValue()));
237
+
238
+ prj.block("donors", {
239
+ currentArgs: donorsArgs,
240
+ prodArgs: prj.args({ rerun: true }),
241
+ prodUiCtx: prj.frame({ donor: { spec: SPEC, cells: ["d2"] } }),
242
+ });
243
+ tree.updateFromResourceData(prj.changes());
244
+
245
+ expect(changed(watched)).toEqual({
246
+ xDependencies: false,
247
+ yDependencies: true,
248
+ samplesDependents: false,
249
+ donorsDependents: true,
250
+ });
251
+ });
252
+
253
+ test("a column added to a run leaves the links to its other columns alone", async () => {
254
+ const { prj, tree, samplesFrame, watched } = twoChains();
255
+ await Promise.all(Object.values(watched).map((c) => c.getValue()));
256
+
257
+ prj.extend(samplesFrame, { batch: { spec: SPEC, cells: ["b1"] } }, { state: "open" });
258
+ tree.updateFromResourceData(prj.changes());
259
+
260
+ expect(changed(watched)).toEqual({
261
+ xDependencies: false,
262
+ yDependencies: false,
263
+ samplesDependents: false,
264
+ donorsDependents: false,
265
+ });
266
+ });
267
+
268
+ test("an args edit of a block reaches its dependencies and the dependents of the blocks above", async () => {
269
+ const { prj, tree, watched } = twoChains();
270
+ await Promise.all(Object.values(watched).map((c) => c.getValue()));
271
+
272
+ prj.block("x", { currentArgs: prj.args({ target: pick("mock") }) });
273
+ tree.updateFromResourceData(prj.changes());
274
+
275
+ expect(changed(watched)).toEqual({
276
+ xDependencies: true,
277
+ yDependencies: false,
278
+ samplesDependents: true,
279
+ donorsDependents: true,
280
+ });
281
+ });
282
+ });
283
+
284
+ describe("labelBlockColumnLinks", () => {
285
+ const dependencies: ColumnLink[] = [
286
+ {
287
+ producer: { blockId: "samples", name: "condition" },
288
+ holderBlockId: "x",
289
+ ref: condition,
290
+ values: null,
291
+ columnLabel: null,
292
+ check: { type: "ok" },
293
+ },
294
+ ];
295
+ const dependents: ColumnLink[] = [
296
+ {
297
+ producer: { blockId: "x", name: "condition" },
298
+ holderBlockId: "untitled",
299
+ ref: condition,
300
+ values: null,
301
+ columnLabel: null,
302
+ check: { type: "ok" },
303
+ },
304
+ ];
305
+
306
+ test("labels the blocks the links name by their titles in the overview", () => {
307
+ const blocks = [
308
+ { id: "samples", title: "Samples & data" },
309
+ { id: "untitled", title: undefined },
310
+ ];
311
+
312
+ expect(labelBlockColumnLinks({ blockId: "x", dependencies, dependents }, blocks)).toEqual({
313
+ blockId: "x",
314
+ dependencies,
315
+ dependents,
316
+ labels: { samples: "Samples & data" },
317
+ });
318
+ });
319
+
320
+ test("gives no links while a side is not computed", () => {
321
+ expect(
322
+ labelBlockColumnLinks({ blockId: "x", dependencies, dependents: undefined }, []),
323
+ ).toBeUndefined();
324
+ expect(
325
+ labelBlockColumnLinks({ blockId: "x", dependencies: undefined, dependents }, []),
326
+ ).toBeUndefined();
327
+ });
328
+ });
329
+
330
+ // Internals
331
+
332
+ const SPEC: PColumnSpec = {
333
+ kind: "PColumn",
334
+ name: "condition",
335
+ valueType: "String",
336
+ axesSpec: [{ name: "sample", type: "String" }],
337
+ annotations: { [Annotation.Label]: "Condition" },
338
+ };
339
+
340
+ async function dependenciesOf(tree: PlTreeState, blockId: string) {
341
+ const value = await blockDependencies(tree.entry(), blockId, env).getValue();
342
+ if (value === undefined) throw new Error(`No dependencies of ${blockId}`);
343
+ return value;
344
+ }
345
+
346
+ async function dependentsOf(tree: PlTreeState, blockId: string) {
347
+ const value = await blockDependents(tree.entry(), blockId, env).getValue();
348
+ if (value === undefined) throw new Error(`No dependents of ${blockId}`);
349
+ return value;
350
+ }
@@ -0,0 +1,152 @@
1
+ import { Computable } from "@milaboratories/computable";
2
+ import type { PlTreeEntry, PlTreeNodeAccessor } from "@milaboratories/pl-tree";
3
+ import { notEmpty } from "@milaboratories/ts-helpers";
4
+ import {
5
+ computeDependencies,
6
+ computeDependents,
7
+ type BlockColumnLinks,
8
+ type ArgsKind,
9
+ type ColumnLinksSource,
10
+ type ProducerRun,
11
+ type ColumnLink,
12
+ } from "@milaboratories/pl-model-common";
13
+ import type { BlockStateOverview } from "@milaboratories/pl-model-middle-layer";
14
+ import type { ProjectStructure } from "../model/project_model";
15
+ import { ProjectStructureKey, projectFieldName } from "../model/project_model";
16
+ import { allBlocks } from "../model/project_model_util";
17
+ import type { MiddleLayerEnvironment } from "./middle_layer";
18
+
19
+ /** What the column links computables read besides the project tree. */
20
+ export type ColumnLinksEnv = Pick<MiddleLayerEnvironment, "projectHelper" | "logger">;
21
+
22
+ /**
23
+ * Outputs of other blocks that the args of the block reference, checked against what the last
24
+ * runs of their producers export; `undefined` when they could not be computed. Reads the order
25
+ * of the blocks, the args of the block, and of each producer above it that the args reference:
26
+ * whether it has a last run, the results of that run as far as the referenced columns go, and,
27
+ * only to label a column the run does not export, the results the producer prepares before it
28
+ * is run.
29
+ */
30
+ export function blockDependencies(
31
+ prjEntry: PlTreeEntry,
32
+ blockId: string,
33
+ env: ColumnLinksEnv,
34
+ ): Computable<ColumnLink[] | undefined> {
35
+ return Computable.make(
36
+ (ctx) => computeDependencies(columnLinksSource(ctx.accessor(prjEntry).node(), env), blockId),
37
+ { recover: forgetFailed(env, "dependencies") },
38
+ );
39
+ }
40
+
41
+ /**
42
+ * Blocks whose args reference outputs of the block, each reference checked against what the
43
+ * last run of the block exports; `undefined` when they could not be computed. Reads the order of
44
+ * the blocks, the args of the block and of every block below it, and of the block's own runs:
45
+ * whether it has a last run, the results of that run as far as the referenced columns go, and,
46
+ * only to label a column the run does not export, the results it prepares before it is run.
47
+ */
48
+ export function blockDependents(
49
+ prjEntry: PlTreeEntry,
50
+ blockId: string,
51
+ env: ColumnLinksEnv,
52
+ ): Computable<ColumnLink[] | undefined> {
53
+ return Computable.make(
54
+ (ctx) => computeDependents(columnLinksSource(ctx.accessor(prjEntry).node(), env), blockId),
55
+ { recover: forgetFailed(env, "dependents") },
56
+ );
57
+ }
58
+
59
+ /**
60
+ * Column links of a block with the blocks they name labelled by their titles in the project
61
+ * overview, the names the project shows; a block the overview has no title for stays unlabelled.
62
+ * `undefined` while either side is not computed.
63
+ */
64
+ export function labelBlockColumnLinks(
65
+ links: {
66
+ blockId: string;
67
+ dependencies: undefined | ColumnLink[];
68
+ dependents: undefined | ColumnLink[];
69
+ },
70
+ blocks: readonly Pick<BlockStateOverview, "id" | "title">[],
71
+ ): BlockColumnLinks | undefined {
72
+ const { blockId, dependencies, dependents } = links;
73
+ if (dependencies === undefined || dependents === undefined) return undefined;
74
+ const named = new Set([
75
+ ...dependencies.map((link) => link.producer.blockId),
76
+ ...dependents.map((link) => link.holderBlockId),
77
+ ]);
78
+ const labels: Record<string, string> = {};
79
+ for (const { id, title } of blocks) {
80
+ if (named.has(id) && title !== undefined) labels[id] = title;
81
+ }
82
+ return { blockId, dependencies, dependents, labels };
83
+ }
84
+
85
+ // Internals
86
+
87
+ /** Logs why a side of the column links failed; the side is then not computed. */
88
+ function forgetFailed(env: ColumnLinksEnv, side: "dependencies" | "dependents") {
89
+ return (cause: unknown[]): undefined => {
90
+ env.logger.error(new Error(`Error in column links: ${side}`, { cause }));
91
+ return undefined;
92
+ };
93
+ }
94
+
95
+ function columnLinksSource(
96
+ prj: PlTreeNodeAccessor,
97
+ env: ColumnLinksEnv,
98
+ ): ColumnLinksSource<PlTreeNodeAccessor> {
99
+ const structure = notEmpty(prj.getKeyValueAsJson<ProjectStructure>(ProjectStructureKey));
100
+ return {
101
+ blockIds: [...allBlocks(structure)].map(({ id }) => id),
102
+ referencesOf: (blockId, kind) => {
103
+ const args = readArgs(prj, blockId, kind);
104
+ if (args === undefined) return undefined;
105
+ return env.projectHelper.getColumnReferences(args.id, () => args.getDataAsJson());
106
+ },
107
+ lastRunOf: (blockId) => readLastRun(prj, blockId),
108
+ stagingOf: (blockId) => readStaging(prj, blockId),
109
+ };
110
+ }
111
+
112
+ /** The project field holding a block's args of each kind. */
113
+ const ARGS_FIELD = { current: "currentArgs", lastRun: "prodArgs" } as const satisfies Record<
114
+ ArgsKind,
115
+ string
116
+ >;
117
+
118
+ function readArgs(
119
+ prj: PlTreeNodeAccessor,
120
+ blockId: string,
121
+ kind: ArgsKind,
122
+ ): undefined | PlTreeNodeAccessor {
123
+ return prj.traverse({
124
+ field: projectFieldName(blockId, ARGS_FIELD[kind]),
125
+ assertFieldType: "Dynamic",
126
+ stableIfNotFound: true,
127
+ });
128
+ }
129
+
130
+ /**
131
+ * A block has a last run while it holds the args of that run. The results of the run fail to
132
+ * render when their field, or the result list itself, has an error.
133
+ */
134
+ function readLastRun(prj: PlTreeNodeAccessor, blockId: string): ProducerRun<PlTreeNodeAccessor> {
135
+ if (readArgs(prj, blockId, "lastRun") === undefined) return { type: "notRun" };
136
+ const ctx = prj.traverseOrError({
137
+ field: projectFieldName(blockId, "prodUiCtx"),
138
+ stableIfNotFound: true,
139
+ });
140
+ if (ctx === undefined) return { type: "rendering" };
141
+ if (!ctx.ok || ctx.value.getError() !== undefined) return { type: "failed" };
142
+ return { type: "rendered", ctx: ctx.value };
143
+ }
144
+
145
+ /** The results a block prepares before it is run; `undefined` while there are none, or they failed. */
146
+ function readStaging(prj: PlTreeNodeAccessor, blockId: string): undefined | PlTreeNodeAccessor {
147
+ const ctx = prj.traverseOrError({
148
+ field: projectFieldName(blockId, "stagingUiCtx"),
149
+ stableIfNotFound: true,
150
+ });
151
+ return ctx !== undefined && ctx.ok ? ctx.value : undefined;
152
+ }
@@ -15,3 +15,9 @@ export type {
15
15
  TemplateId,
16
16
  TemplateListEntry,
17
17
  } from "./template_list";
18
+ export type {
19
+ BlockActionReading,
20
+ BlockActionContextMode,
21
+ BlockActionContextNode,
22
+ BlockActionTransition,
23
+ } from "./block_action_context";
@@ -1976,6 +1976,8 @@ export class MiddleLayer {
1976
1976
  runtimeCapabilities.addSupportedRequirement("requiresModelAPIVersion", 2);
1977
1977
  runtimeCapabilities.addSupportedRequirement("requiresCreatePTable", 2);
1978
1978
  runtimeCapabilities.addSupportedRequirement("requiresPFramesVersion", REQUIRES_PFRAMES_VERSION);
1979
+ // The project overview and production rendering refuse to run args argsIssues found errors in
1980
+ runtimeCapabilities.addSupportedRequirement("requiresArgsIssues");
1979
1981
  registerServiceCapabilities((flag, value) =>
1980
1982
  runtimeCapabilities.addSupportedRequirement(flag, value),
1981
1983
  );