@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
@@ -110,6 +110,7 @@ export interface ProjectField {
110
110
  | "prodArgs"
111
111
  | "currentArgs"
112
112
  | "currentPrerunArgs" // Derived args for staging/prerun rendering (from prerunArgs() or args())
113
+ | "inputsValid" // Model API v2: errors found in currentArgs, none when the block may run, with the fingerprint of those args; present while currentArgs are
113
114
  | "prodChainCtx" // Pre-built production context chain node (accumulated prodCtx from all blocks above)
114
115
  | "prodCtx"
115
116
  | "prodUiCtx"
@@ -122,7 +123,10 @@ export interface ProjectField {
122
123
  | "stagingOutput"
123
124
  | "stagingCtxPrevious"
124
125
  | "stagingUiCtxPrevious"
125
- | "stagingOutputPrevious";
126
+ | "stagingOutputPrevious"
127
+ // Root action context of the current render. The project holds it so it outlives the render.
128
+ | "prodActionContext"
129
+ | "stagingActionContext";
126
130
  }
127
131
 
128
132
  export const FieldsToDuplicate: Set<ProjectField["fieldName"]> = new Set([
@@ -132,6 +136,7 @@ export const FieldsToDuplicate: Set<ProjectField["fieldName"]> = new Set([
132
136
  "prodArgs",
133
137
  "currentArgs",
134
138
  "currentPrerunArgs",
139
+ "inputsValid",
135
140
  "prodCtx",
136
141
  "prodUiCtx",
137
142
  "prodOutput",
@@ -142,7 +147,7 @@ export function projectFieldName(blockId: string, fieldName: ProjectField["field
142
147
  }
143
148
 
144
149
  const projectFieldPattern =
145
- /^(?<blockId>.*)-(?<fieldName>blockPack|blockSettings|blockStorage|inputsValid|prodArgs|currentArgs|currentPrerunArgs|prodChainCtx|prodCtx|prodUiCtx|prodOutput|prodCtxPrevious|prodUiCtxPrevious|prodOutputPrevious|stagingCtx|stagingUiCtx|stagingOutput|stagingCtxPrevious|stagingUiCtxPrevious|stagingOutputPrevious)$/;
150
+ /^(?<blockId>.*)-(?<fieldName>blockPack|blockSettings|blockStorage|inputsValid|prodArgs|currentArgs|currentPrerunArgs|prodChainCtx|prodCtx|prodUiCtx|prodOutput|prodCtxPrevious|prodUiCtxPrevious|prodOutputPrevious|stagingCtx|stagingUiCtx|stagingOutput|stagingCtxPrevious|stagingUiCtxPrevious|stagingOutputPrevious|prodActionContext|stagingActionContext)$/;
146
151
 
147
152
  export function parseProjectField(name: string): ProjectField | undefined {
148
153
  const match = name.match(projectFieldPattern);
@@ -1,7 +1,9 @@
1
- import { field, poll, TestHelpers, toGlobalResourceId } from "@milaboratories/pl-client";
1
+ import { field, Pl, poll, TestHelpers, toGlobalResourceId } from "@milaboratories/pl-client";
2
+ import { canonicalJsonBytes } from "@milaboratories/ts-helpers";
2
3
  import { getQuickJS } from "quickjs-emscripten";
3
4
  import { expect, test } from "vitest";
4
5
  import { outputRef } from "../model/args";
6
+ import { argsFingerprint } from "../model/args_derivation";
5
7
  import { ProjectHelper } from "../model/project_helper";
6
8
  import { blockArgsAuthorKey, projectFieldName } from "../model/project_model";
7
9
  import { TestBPPreparer } from "../test/block_packs";
@@ -622,3 +624,105 @@ test("v3 blocks: migrateBlockPack does not crash when args() returns undefined",
622
624
  });
623
625
  });
624
626
  });
627
+
628
+ test("v3 blocks: every args derivation records the errors found in the args, none when there are none", async () => {
629
+ const quickJs = await getQuickJS();
630
+
631
+ await TestHelpers.withTempRoot(async (pl) => {
632
+ const prj = await pl.withWriteTx("CreatingProject", async (tx) => {
633
+ const prjRef = await createProject(tx);
634
+ tx.createField(field(tx.clientRoot, "prj"), "Dynamic", prjRef);
635
+ await tx.commit();
636
+ return await toGlobalResourceId(prjRef);
637
+ });
638
+
639
+ await pl.withWriteTx("AddBlock", async (tx) => {
640
+ const mut = await ProjectMutator.load(new ProjectHelper(quickJs), tx, prj);
641
+ mut.addBlock(
642
+ { id: "enter1", label: "Enter Numbers V3", renderingMode: "Heavy" },
643
+ { storageMode: "fromModel", blockPack: await TestBPPreparer.prepare(BPSpecEnterV3) },
644
+ );
645
+ mut.setStates([
646
+ {
647
+ modelAPIVersion: 2,
648
+ blockId: "enter1",
649
+ payload: { operation: "update-block-data", value: { numbers: [3, 1, 2] } },
650
+ },
651
+ ]);
652
+ mut.save();
653
+ await tx.commit();
654
+ });
655
+
656
+ await poll(pl, async (tx) => {
657
+ const prjR = await tx.get(prj);
658
+ const record = await prjR.get(projectFieldName("enter1", "inputsValid"));
659
+ expect(jsonOf(record.data.data)).toStrictEqual({
660
+ argsFingerprint: argsFingerprint({ numbers: [1, 2, 3] }),
661
+ errors: [],
662
+ });
663
+ });
664
+ });
665
+ });
666
+
667
+ test("v3 blocks: args without an args errors record of their own are kept and run, as before the record", async () => {
668
+ const quickJs = await getQuickJS();
669
+
670
+ await TestHelpers.withTempRoot(async (pl) => {
671
+ const prj = await pl.withWriteTx("CreatingProject", async (tx) => {
672
+ const prjRef = await createProject(tx);
673
+ tx.createField(field(tx.clientRoot, "prj"), "Dynamic", prjRef);
674
+ await tx.commit();
675
+ return await toGlobalResourceId(prjRef);
676
+ });
677
+
678
+ await pl.withWriteTx("AddBlock", async (tx) => {
679
+ const mut = await ProjectMutator.load(new ProjectHelper(quickJs), tx, prj);
680
+ mut.addBlock(
681
+ { id: "enter1", label: "Enter Numbers V3", renderingMode: "Heavy" },
682
+ { storageMode: "fromModel", blockPack: await TestBPPreparer.prepare(BPSpecEnterV3) },
683
+ );
684
+ mut.setStates([
685
+ {
686
+ modelAPIVersion: 2,
687
+ blockId: "enter1",
688
+ payload: { operation: "update-block-data", value: { numbers: [3, 1, 2] } },
689
+ },
690
+ ]);
691
+ mut.save();
692
+ await tx.commit();
693
+ });
694
+
695
+ // What a middle layer that does not record args errors leaves behind: current args it wrote
696
+ // itself, and no record of their args errors.
697
+ await pl.withWriteTx("WriteArgsWithoutRecord", async (tx) => {
698
+ tx.removeField(field(prj, projectFieldName("enter1", "inputsValid")));
699
+ tx.setField(
700
+ field(prj, projectFieldName("enter1", "currentArgs")),
701
+ tx.createValue(Pl.JsonObject, canonicalJsonBytes({ numbers: [9] })),
702
+ );
703
+ await tx.commit();
704
+ });
705
+
706
+ await pl.withWriteTx("Run", async (tx) => {
707
+ const mut = await ProjectMutator.load(new ProjectHelper(quickJs), tx, prj);
708
+ mut.renderProduction(["enter1"]);
709
+ mut.save();
710
+ await tx.commit();
711
+ });
712
+
713
+ await poll(pl, async (tx) => {
714
+ const prjR = await tx.get(prj);
715
+ const currentArgs = await prjR.get(projectFieldName("enter1", "currentArgs"));
716
+ expect(jsonOf(currentArgs.data.data)).toStrictEqual({ numbers: [9] });
717
+ const prodArgs = await prjR.get(projectFieldName("enter1", "prodArgs"));
718
+ expect(jsonOf(prodArgs.data.data)).toStrictEqual({ numbers: [9] });
719
+ });
720
+ });
721
+ });
722
+
723
+ // Internals
724
+
725
+ function jsonOf(data: Uint8Array | undefined): unknown {
726
+ if (data === undefined) throw new Error("The field holds no data");
727
+ return JSON.parse(Buffer.from(data).toString());
728
+ }
@@ -22,6 +22,7 @@ import {
22
22
  } from "@milaboratories/pl-client";
23
23
  import {
24
24
  createRenderHeavyBlock,
25
+ type BlockActionContextBaggage,
25
26
  createBContextFromUpstreams,
26
27
  createBContextEnd,
27
28
  } from "./template/render_block";
@@ -77,6 +78,14 @@ import {
77
78
  } from "@milaboratories/ts-helpers";
78
79
  import { gzipSync } from "node:zlib";
79
80
  import type { ProjectHelper } from "../model/project_helper";
81
+ import {
82
+ derivedArgsValue,
83
+ argsErrorsFieldChange,
84
+ argsErrorsOfBlock,
85
+ type DerivedArgs,
86
+ type ArgsErrorsFieldChange,
87
+ } from "../model/args_derivation";
88
+ import type { ArgsIssue } from "@milaboratories/pl-model-common";
80
89
  import {
81
90
  extractConfig,
82
91
  UiError,
@@ -273,6 +282,24 @@ class BlockInfo {
273
282
  return this.currentPrerunArgsC();
274
283
  }
275
284
 
285
+ private readonly argsErrorsC = cached(
286
+ () => `${this.fields.inputsValid?.modCount}:${this.fields.currentArgs?.modCount}`,
287
+ () =>
288
+ argsErrorsOfBlock({
289
+ modelAPIVersion: this.config.modelAPIVersion,
290
+ inputsValid: this.fields.inputsValid,
291
+ currentArgs: this.currentArgs,
292
+ }),
293
+ );
294
+
295
+ /**
296
+ * The errors the block's `argsIssues()` found in its current args. A block whose model API
297
+ * version has no args issues, or that has no current args, has none.
298
+ */
299
+ get argsErrors(): readonly ArgsIssue[] {
300
+ return this.argsErrorsC();
301
+ }
302
+
276
303
  get stagingRendered(): boolean {
277
304
  return this.fields.stagingCtx !== undefined;
278
305
  }
@@ -706,6 +733,15 @@ export class ProjectMutator {
706
733
  return { ref, value: jsonBytes, status: "Ready" };
707
734
  }
708
735
 
736
+ /** Baggage of the root action context of a block render. */
737
+ private blockActionContextBaggage(blockId: string, info: BlockInfo): BlockActionContextBaggage {
738
+ return {
739
+ name: this.getBlock(blockId).label,
740
+ blockPack: JSON.stringify(info.source),
741
+ blockId,
742
+ };
743
+ }
744
+
709
745
  private getBlock(blockId: string): Block {
710
746
  for (const block of allBlocks(this.struct)) if (block.id === blockId) return block;
711
747
  throw new Error("block not found");
@@ -756,6 +792,32 @@ export class ProjectMutator {
756
792
  return deleted;
757
793
  }
758
794
 
795
+ /**
796
+ * Brings the block's `inputsValid` field in line with what its args derivation found: the run
797
+ * errors of its args, none when it may run with them, and no field when it has no args.
798
+ */
799
+ private setArgsErrors(blockId: string, derivedArgs: DerivedArgs): void {
800
+ this.applyArgsErrorsChange(
801
+ blockId,
802
+ argsErrorsFieldChange(this.getBlockInfo(blockId).fields.inputsValid, derivedArgs),
803
+ );
804
+ }
805
+
806
+ private applyArgsErrorsChange(blockId: string, change: ArgsErrorsFieldChange): void {
807
+ switch (change.type) {
808
+ case "keep":
809
+ return;
810
+ case "delete":
811
+ this.deleteBlockFields(blockId, "inputsValid");
812
+ return;
813
+ case "write": {
814
+ const ref = this.tx.createValue(Pl.JsonObject, change.data);
815
+ this.setBlockField(blockId, "inputsValid", ref, "Ready", change.data);
816
+ return;
817
+ }
818
+ }
819
+ }
820
+
759
821
  private updateLastModified() {
760
822
  this.lastModified = Date.now();
761
823
  this.lastModifiedChanged = true;
@@ -782,6 +844,7 @@ export class ProjectMutator {
782
844
  this.setBlockFieldObj(blockId, "stagingCtxPrevious", fields.stagingCtx!);
783
845
  this.setBlockFieldObj(blockId, "stagingUiCtxPrevious", fields.stagingUiCtx!);
784
846
  }
847
+ this.deleteBlockFields(blockId, "stagingActionContext");
785
848
  if (this.deleteBlockFields(blockId, "stagingOutput", "stagingCtx", "stagingUiCtx"))
786
849
  this.resetStagingRefreshTimestamp();
787
850
  }
@@ -797,7 +860,14 @@ export class ProjectMutator {
797
860
  this.setBlockFieldObj(blockId, "prodCtxPrevious", fields.prodCtx!);
798
861
  this.setBlockFieldObj(blockId, "prodUiCtxPrevious", fields.prodUiCtx!);
799
862
  }
800
- this.deleteBlockFields(blockId, "prodOutput", "prodCtx", "prodUiCtx", "prodArgs");
863
+ this.deleteBlockFields(
864
+ blockId,
865
+ "prodOutput",
866
+ "prodCtx",
867
+ "prodUiCtx",
868
+ "prodArgs",
869
+ "prodActionContext",
870
+ );
801
871
  }
802
872
 
803
873
  /** Running blocks are reset, settled ones (Ready or finished Error) moved to limbo. Returns if
@@ -826,6 +896,7 @@ export class ProjectMutator {
826
896
  return true;
827
897
  } else {
828
898
  // reset - clean up any partial/inconsistent production stat
899
+ this.deleteBlockFields(blockId, "prodActionContext");
829
900
  return this.deleteBlockFields(
830
901
  blockId,
831
902
  "prodOutput",
@@ -908,21 +979,16 @@ export class ProjectMutator {
908
979
  }
909
980
 
910
981
  // Derive args from storage - set or clear currentArgs based on derivation result
911
- const deriveArgsResult = this.projectHelper.deriveArgsFromStorage(
912
- blockConfig,
913
- initialStorageJson,
914
- );
982
+ const derivedArgs = this.projectHelper.deriveArgsFromStorage(blockConfig, initialStorageJson);
915
983
  // args() legitimately returns undefined when inputs are incomplete/invalid;
916
984
  // treat that like a derivation failure and clear the field (never JSON-encode undefined).
917
- if (!deriveArgsResult.error && deriveArgsResult.value !== undefined) {
918
- this.setBlockFieldObj(
919
- blockId,
920
- "currentArgs",
921
- this.createJsonFieldValue(deriveArgsResult.value),
922
- );
985
+ const args = derivedArgsValue(derivedArgs);
986
+ if (args !== undefined) {
987
+ this.setBlockFieldObj(blockId, "currentArgs", this.createJsonFieldValue(args));
923
988
  } else {
924
989
  this.deleteBlockFields(blockId, "currentArgs");
925
990
  }
991
+ this.setArgsErrors(blockId, derivedArgs);
926
992
  }
927
993
 
928
994
  /** Optimally sets inputs for multiple blocks in one go */
@@ -974,11 +1040,12 @@ export class ProjectMutator {
974
1040
  );
975
1041
 
976
1042
  // Derive args from storage (VM extracts data internally)
977
- const derivedArgsResult = this.projectHelper.deriveArgsFromStorage(
1043
+ const derivedArgs = this.projectHelper.deriveArgsFromStorage(
978
1044
  blockConfig,
979
1045
  updatedStorageJson,
980
1046
  );
981
- args = derivedArgsResult.error ? undefined : derivedArgsResult.value;
1047
+ args = derivedArgsValue(derivedArgs);
1048
+ this.setArgsErrors(req.blockId, derivedArgs);
982
1049
  } else {
983
1050
  this.setBlockFieldObj(req.blockId, "blockStorage", this.createGzJsonFieldValue(req.state));
984
1051
  if (req.state !== null && typeof req.state === "object" && "args" in req.state) {
@@ -1166,12 +1233,17 @@ export class ProjectMutator {
1166
1233
  const tpl = info.getTemplate(this.tx);
1167
1234
 
1168
1235
  // Use currentPrerunArgs for staging rendering
1169
- const results = createRenderHeavyBlock(this.tx, tpl, {
1170
- args: prerunArgsRef,
1171
- blockId: this.tx.createValue(Pl.JsonString, JSON.stringify(blockId)),
1172
- isProduction: this.tx.createValue(Pl.JsonBool, JSON.stringify(false)),
1173
- context: ctx,
1174
- });
1236
+ const results = createRenderHeavyBlock(
1237
+ this.tx,
1238
+ tpl,
1239
+ {
1240
+ args: prerunArgsRef,
1241
+ blockId: this.tx.createValue(Pl.JsonString, JSON.stringify(blockId)),
1242
+ isProduction: this.tx.createValue(Pl.JsonBool, JSON.stringify(false)),
1243
+ context: ctx,
1244
+ },
1245
+ this.blockActionContextBaggage(blockId, info),
1246
+ );
1175
1247
 
1176
1248
  // Here we set the staging ctx to the input context of the staging workflow, not the output because exports
1177
1249
  // of one staging context should stay within the same block, and not travel downstream.
@@ -1182,6 +1254,8 @@ export class ProjectMutator {
1182
1254
  // thus creating a certain discrepancy between staging workflow context behavior and desktop's result pool.
1183
1255
  this.setBlockField(blockId, "stagingUiCtx", this.exportCtx(results.context), "NotReady");
1184
1256
  this.setBlockField(blockId, "stagingOutput", results.result, "NotReady");
1257
+ if (results.actionContext !== undefined)
1258
+ this.setBlockField(blockId, "stagingActionContext", results.actionContext, "NotReady");
1185
1259
  }
1186
1260
 
1187
1261
  private renderProductionFor(blockId: string) {
@@ -1194,6 +1268,13 @@ export class ProjectMutator {
1194
1268
  throw new Error(`Can't render production for block ${blockId}: currentArgs not set`);
1195
1269
  }
1196
1270
 
1271
+ // Nor if its argsIssues() found errors in them
1272
+ const argsErrors = info.argsErrors;
1273
+ if (argsErrors.length > 0) {
1274
+ const messages = argsErrors.map(({ message }) => message).join("; ");
1275
+ throw new Error(`Can't render production for block ${blockId}: ${messages}`);
1276
+ }
1277
+
1197
1278
  const ctx = this.createProdCtx(this.getPendingProductionGraph().nodes.get(blockId)!.upstream);
1198
1279
 
1199
1280
  if (this.getBlock(blockId).renderingMode === "Light")
@@ -1201,12 +1282,17 @@ export class ProjectMutator {
1201
1282
 
1202
1283
  const tpl = info.getTemplate(this.tx);
1203
1284
 
1204
- const results = createRenderHeavyBlock(this.tx, tpl, {
1205
- args: info.fields.currentArgs.ref!,
1206
- blockId: this.tx.createValue(Pl.JsonString, JSON.stringify(blockId)),
1207
- isProduction: this.tx.createValue(Pl.JsonBool, JSON.stringify(true)),
1208
- context: ctx,
1209
- });
1285
+ const results = createRenderHeavyBlock(
1286
+ this.tx,
1287
+ tpl,
1288
+ {
1289
+ args: info.fields.currentArgs.ref!,
1290
+ blockId: this.tx.createValue(Pl.JsonString, JSON.stringify(blockId)),
1291
+ isProduction: this.tx.createValue(Pl.JsonBool, JSON.stringify(true)),
1292
+ context: ctx,
1293
+ },
1294
+ this.blockActionContextBaggage(blockId, info),
1295
+ );
1210
1296
  this.setBlockField(
1211
1297
  blockId,
1212
1298
  "prodCtx",
@@ -1215,6 +1301,8 @@ export class ProjectMutator {
1215
1301
  );
1216
1302
  this.setBlockField(blockId, "prodUiCtx", this.exportCtx(results.context), "NotReady");
1217
1303
  this.setBlockField(blockId, "prodOutput", results.result, "NotReady");
1304
+ if (results.actionContext !== undefined)
1305
+ this.setBlockField(blockId, "prodActionContext", results.actionContext, "NotReady");
1218
1306
 
1219
1307
  // saving inputs for which we rendered the production
1220
1308
  this.setBlockFieldObj(blockId, "prodArgs", info.fields.currentArgs);
@@ -1266,11 +1354,9 @@ export class ProjectMutator {
1266
1354
  prerunArgs = this.projectHelper.derivePrerunArgsFromStorage(blockConfig, storageToWrite);
1267
1355
 
1268
1356
  // Derive args from storage (VM extracts data internally)
1269
- const deriveArgsResult = this.projectHelper.deriveArgsFromStorage(
1270
- blockConfig,
1271
- storageToWrite,
1272
- );
1273
- args = deriveArgsResult.error ? undefined : deriveArgsResult.value;
1357
+ const derivedArgs = this.projectHelper.deriveArgsFromStorage(blockConfig, storageToWrite);
1358
+ args = derivedArgsValue(derivedArgs);
1359
+ this.setArgsErrors(blockId, derivedArgs);
1274
1360
  } else if (spec.storageMode === "legacy") {
1275
1361
  // Model API v1: use legacyState from spec
1276
1362
  const parsedState = JSON.parse(spec.legacyState);
@@ -1560,18 +1646,16 @@ export class ProjectMutator {
1560
1646
  this.deleteBlockFields(blockId, "currentPrerunArgs");
1561
1647
  }
1562
1648
 
1563
- const deriveArgsResult = this.projectHelper.deriveArgsFromStorage(newConfig, storageJson);
1649
+ const derivedArgs = this.projectHelper.deriveArgsFromStorage(newConfig, storageJson);
1564
1650
  // args() legitimately returns undefined when inputs are incomplete/invalid;
1565
1651
  // treat that like a derivation failure and clear the field (never JSON-encode undefined).
1566
- if (!deriveArgsResult.error && deriveArgsResult.value !== undefined) {
1567
- this.setBlockFieldObj(
1568
- blockId,
1569
- "currentArgs",
1570
- this.createJsonFieldValue(deriveArgsResult.value),
1571
- );
1652
+ const args = derivedArgsValue(derivedArgs);
1653
+ if (args !== undefined) {
1654
+ this.setBlockFieldObj(blockId, "currentArgs", this.createJsonFieldValue(args));
1572
1655
  } else {
1573
1656
  this.deleteBlockFields(blockId, "currentArgs");
1574
1657
  }
1658
+ this.setArgsErrors(blockId, derivedArgs);
1575
1659
  };
1576
1660
 
1577
1661
  if (newClearState !== undefined) {
@@ -1587,6 +1671,9 @@ export class ProjectMutator {
1587
1671
  } else {
1588
1672
  // V1: Use setStates with legacy state format
1589
1673
  persistBlockPack();
1674
+ // A V1 model tells whether the block may run by itself; args errors of an earlier V2 model
1675
+ // no longer apply.
1676
+ this.deleteBlockFields(blockId, "inputsValid");
1590
1677
  this.setStates([{ modelAPIVersion: 1, blockId, state: newClearState.state }]);
1591
1678
  }
1592
1679
  } else {
@@ -1617,6 +1704,9 @@ export class ProjectMutator {
1617
1704
  } else {
1618
1705
  // Legacy blocks (modelAPIVersion 1): persist block pack, set prerunArgs = currentArgs
1619
1706
  persistBlockPack();
1707
+ // A V1 model tells whether the block may run by itself; args errors of an earlier V2 model
1708
+ // no longer apply.
1709
+ this.deleteBlockFields(blockId, "inputsValid");
1620
1710
  if (info.fields.currentArgs !== undefined) {
1621
1711
  this.setBlockFieldObj(blockId, "currentPrerunArgs", info.fields.currentArgs);
1622
1712
  }
@@ -1737,6 +1827,7 @@ export class ProjectMutator {
1737
1827
  // skipping finished blocks
1738
1828
  continue;
1739
1829
 
1830
+ this.deleteBlockFields(blockId, "prodActionContext");
1740
1831
  if (this.deleteBlockFields(blockId, "prodOutput", "prodCtx", "prodUiCtx", "prodArgs")) {
1741
1832
  // was actually stopped
1742
1833
  stopped.push(blockId);
@@ -0,0 +1,178 @@
1
+ import type { PlClient, SignedResourceId } from "@milaboratories/pl-client";
2
+ import { ActionContextLinkCapability } from "./template/render_block";
3
+ import {
4
+ ContinuePolling,
5
+ field,
6
+ isNullSignedResourceId,
7
+ parseSignedResourceId,
8
+ poll,
9
+ TestHelpers,
10
+ toGlobalResourceId,
11
+ } from "@milaboratories/pl-client";
12
+ import { getQuickJS } from "quickjs-emscripten";
13
+ import { expect, test } from "vitest";
14
+ import { ProjectHelper } from "../model/project_helper";
15
+ import { projectFieldName } from "../model/project_model";
16
+ import { BPSpecEnterV041NotPrepared, TestBPPreparer } from "../test/block_packs";
17
+ import { readBlockActionContextTree } from "../middle_layer/block_action_context";
18
+ import { createProject, ProjectMutator } from "./project";
19
+
20
+ type ActionContextBaggage = Record<string, string>;
21
+
22
+ /** Reads the action context that prodActionContext points to, or undefined if the field is absent.
23
+ * Polls until the field reference is resolved: it resolves asynchronously after the commit. */
24
+ async function readActionContext(
25
+ pl: PlClient,
26
+ prj: Awaited<ReturnType<typeof toGlobalResourceId>>,
27
+ blockId: string,
28
+ ): Promise<
29
+ { contextId: string; contextRef: SignedResourceId; baggage: ActionContextBaggage } | undefined
30
+ > {
31
+ return await poll(pl, async ({ tx }) => {
32
+ const project = await tx.getResourceData(prj, true);
33
+ const actionContextField = project.fields.find(
34
+ (f) => f.name === projectFieldName(blockId, "prodActionContext"),
35
+ );
36
+ if (actionContextField === undefined) return undefined;
37
+
38
+ const contextRef = actionContextField.value;
39
+ if (isNullSignedResourceId(contextRef)) throw new ContinuePolling();
40
+ const context = await tx.getResourceData(contextRef, false);
41
+ expect(context.type.name).toEqual("ActionContext");
42
+ return {
43
+ contextId: String(parseSignedResourceId(contextRef).globalId),
44
+ contextRef,
45
+ baggage: JSON.parse(Buffer.from(context.data!).toString()) as ActionContextBaggage,
46
+ };
47
+ });
48
+ }
49
+
50
+ /** Returns the render an action context belongs to: the resource of the Running transition that the
51
+ * workflow controller records when it evaluates the render. The key names the decimal resource ID.
52
+ * Polls until the transition lands: reports are applied asynchronously after the commit. */
53
+ async function contextRender(pl: PlClient, contextRef: SignedResourceId): Promise<string> {
54
+ return await poll(pl, async ({ tx }) => {
55
+ const kv = await tx.listKeyValuesString(contextRef);
56
+ const running = kv.find((e) => e.key.startsWith("transition/render/"));
57
+ if (running === undefined) throw new ContinuePolling();
58
+ expect(JSON.parse(running.value)).toMatchObject({ state: "Running" });
59
+ return running.key.split("/")[2];
60
+ });
61
+ }
62
+
63
+ test("block action context outlives its renderer and is replaced on re-render", async () => {
64
+ //
65
+ // Scenario:
66
+ // 1. Create a project and render production of one block.
67
+ // 2. Wait until the production output is final.
68
+ // 3. Without a link capability the render has no action context. Otherwise read the action
69
+ // context through prodActionContext and check its baggage.
70
+ // 4. The context has the Running transition of the render. Wait until the render is garbage
71
+ // collected; the context must stay readable.
72
+ // 5. Re-render production: prodActionContext must point at a new context with a new run-id.
73
+ // 6. Render and stop production in one transaction: prodActionContext must be removed.
74
+ //
75
+
76
+ const quickJs = await getQuickJS();
77
+
78
+ await TestHelpers.withTempRoot(async (pl) => {
79
+ // 1. Create a project and render production of one block.
80
+ const prj = await pl.withWriteTx("CreatingProject", async (tx) => {
81
+ const prjRef = await createProject(tx);
82
+ tx.createField(field(tx.clientRoot, "prj"), "Dynamic", prjRef);
83
+ await tx.commit();
84
+ return await toGlobalResourceId(prjRef);
85
+ });
86
+
87
+ const renderBlock = async (numbers: number[], addBlock: boolean) =>
88
+ await pl.withWriteTx("RenderBlock", async (tx) => {
89
+ const mut = await ProjectMutator.load(new ProjectHelper(quickJs), tx, prj);
90
+ if (addBlock)
91
+ mut.addBlock(
92
+ { id: "block1", label: "Block1", renderingMode: "Heavy" },
93
+ {
94
+ storageMode: "legacy",
95
+ legacyState: JSON.stringify({ args: { numbers } }),
96
+ blockPack: await TestBPPreparer.prepare(BPSpecEnterV041NotPrepared),
97
+ },
98
+ );
99
+ else
100
+ mut.setStates([
101
+ { modelAPIVersion: 1, blockId: "block1", state: { args: { numbers }, uiState: {} } },
102
+ ]);
103
+ mut.renderProduction(["block1"], true);
104
+ mut.save();
105
+ await tx.commit();
106
+ });
107
+
108
+ await renderBlock([1, 2, 3], true);
109
+
110
+ // 2. Wait until the production output is final.
111
+ await poll(pl, async (tx) => {
112
+ const prjR = await tx.get(prj);
113
+ await prjR.get(projectFieldName("block1", "prodOutput")).then((r) => r.final());
114
+ });
115
+
116
+ // 3. Read the action context through prodActionContext and check its baggage.
117
+ if (!pl.hasCapability(ActionContextLinkCapability)) {
118
+ expect(await readActionContext(pl, prj, "block1")).toBeUndefined();
119
+ return;
120
+ }
121
+ const first = (await readActionContext(pl, prj, "block1"))!;
122
+ expect(first).toBeDefined();
123
+ expect(Object.keys(first.baggage).sort()).toEqual(["block-id", "block-pack", "name", "run-id"]);
124
+ expect(first.baggage["block-id"]).toEqual("block1");
125
+ expect(first.baggage["name"]).toEqual("Block1");
126
+ expect(JSON.parse(first.baggage["block-pack"])).toBeTypeOf("object");
127
+ expect(first.baggage["run-id"]).toMatch(/^[0-9a-f-]{36}$/);
128
+
129
+ // 4. The Running transition names the render; the context outlives it. The render's own end is
130
+ // awaited through its Completed report, which every reader can see: reading the render
131
+ // itself needs a signed reference that no field of the project holds once it is done.
132
+ const firstRender = await contextRender(pl, first.contextRef);
133
+ await poll(pl, async ({ tx }) => {
134
+ const kv = await tx.listKeyValuesString(first.contextRef);
135
+ const completed = kv.some(
136
+ (e) =>
137
+ e.key.startsWith(`transition/render/${firstRender}/`) &&
138
+ JSON.parse(e.value).state === "Completed",
139
+ );
140
+ if (!completed) throw new ContinuePolling();
141
+ });
142
+ const afterRenderEnd = (await readActionContext(pl, prj, "block1"))!;
143
+ expect(afterRenderEnd.contextId).toEqual(first.contextId);
144
+ expect(afterRenderEnd.baggage).toEqual(first.baggage);
145
+
146
+ // 4b. The read-back sees the same context with its baggage, and names the render.
147
+ const tree = (await readBlockActionContextTree(pl, prj, "block1", "prod"))!;
148
+ expect(tree).toBeDefined();
149
+ expect(tree.contextId).toEqual(first.contextId);
150
+ expect(tree.baggage).toEqual(first.baggage);
151
+ expect(tree.resource).toEqual(firstRender);
152
+ expect(tree.transitions.map((t) => [t.namespace, t.resource, t.state])).toContainEqual([
153
+ "render",
154
+ firstRender,
155
+ "Running",
156
+ ]);
157
+
158
+ // 5. Re-render production: prodActionContext must point at a new context with a new run-id.
159
+ await renderBlock([4, 5, 6], false);
160
+ const second = (await readActionContext(pl, prj, "block1"))!;
161
+ expect(second.contextId).not.toEqual(first.contextId);
162
+ expect(second.baggage["run-id"]).not.toEqual(first.baggage["run-id"]);
163
+ expect(await contextRender(pl, second.contextRef)).not.toEqual(firstRender);
164
+
165
+ // 6. Render and stop production in one transaction: prodActionContext must be removed.
166
+ await pl.withWriteTx("RenderAndStop", async (tx) => {
167
+ const mut = await ProjectMutator.load(new ProjectHelper(quickJs), tx, prj);
168
+ mut.setStates([
169
+ { modelAPIVersion: 1, blockId: "block1", state: { args: { numbers: [7] }, uiState: {} } },
170
+ ]);
171
+ mut.renderProduction(["block1"], true);
172
+ mut.stopProduction("block1");
173
+ mut.save();
174
+ await tx.commit();
175
+ });
176
+ expect(await readActionContext(pl, prj, "block1")).toBeUndefined();
177
+ });
178
+ });