@milaboratories/pl-middle-layer 1.75.0 → 1.76.1

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 (48) 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_action_context.cjs +57 -0
  5. package/dist/middle_layer/block_action_context.cjs.map +1 -0
  6. package/dist/middle_layer/block_action_context.d.ts +47 -0
  7. package/dist/middle_layer/block_action_context.d.ts.map +1 -0
  8. package/dist/middle_layer/block_action_context.js +57 -0
  9. package/dist/middle_layer/block_action_context.js.map +1 -0
  10. package/dist/middle_layer/build_stamp.cjs +1 -1
  11. package/dist/middle_layer/build_stamp.js +1 -1
  12. package/dist/middle_layer/index.d.ts +2 -1
  13. package/dist/middle_layer/project.cjs +8 -0
  14. package/dist/middle_layer/project.cjs.map +1 -1
  15. package/dist/middle_layer/project.d.ts +6 -0
  16. package/dist/middle_layer/project.d.ts.map +1 -1
  17. package/dist/middle_layer/project.js +8 -0
  18. package/dist/middle_layer/project.js.map +1 -1
  19. package/dist/model/project_model.cjs +1 -1
  20. package/dist/model/project_model.cjs.map +1 -1
  21. package/dist/model/project_model.d.ts +1 -1
  22. package/dist/model/project_model.js +1 -1
  23. package/dist/model/project_model.js.map +1 -1
  24. package/dist/mutator/project.cjs +19 -4
  25. package/dist/mutator/project.cjs.map +1 -1
  26. package/dist/mutator/project.d.ts.map +1 -1
  27. package/dist/mutator/project.js +19 -4
  28. package/dist/mutator/project.js.map +1 -1
  29. package/dist/mutator/template/render_block.cjs +35 -2
  30. package/dist/mutator/template/render_block.cjs.map +1 -1
  31. package/dist/mutator/template/render_block.js +35 -4
  32. package/dist/mutator/template/render_block.js.map +1 -1
  33. package/dist/mutator/template/render_template.cjs +16 -1
  34. package/dist/mutator/template/render_template.cjs.map +1 -1
  35. package/dist/mutator/template/render_template.d.ts +12 -1
  36. package/dist/mutator/template/render_template.d.ts.map +1 -1
  37. package/dist/mutator/template/render_template.js +16 -2
  38. package/dist/mutator/template/render_template.js.map +1 -1
  39. package/package.json +7 -7
  40. package/src/middle_layer/block_action_context.test.ts +148 -0
  41. package/src/middle_layer/block_action_context.ts +162 -0
  42. package/src/middle_layer/index.ts +6 -0
  43. package/src/middle_layer/project.ts +13 -0
  44. package/src/model/project_model.ts +5 -2
  45. package/src/mutator/project.ts +47 -13
  46. package/src/mutator/project_action_context.test.ts +178 -0
  47. package/src/mutator/template/render_block.ts +70 -5
  48. package/src/mutator/template/render_template.ts +22 -2
@@ -43,6 +43,8 @@ import { setTimeout } from "node:timers/promises";
43
43
  import { frontendData } from "./frontend_path";
44
44
  import type { NavigationState } from "@milaboratories/pl-model-common";
45
45
  import { getBlockParameters, blockOutputs } from "./block";
46
+ import type { BlockActionContextMode, BlockActionContextNode } from "./block_action_context";
47
+ import { readBlockActionContextTree } from "./block_action_context";
46
48
  import { getBlockPackInfo } from "./util";
47
49
  import type { FrontendData } from "../model/frontend";
48
50
  import type { ProjectId, ProjectStructure } from "../model/project_model";
@@ -818,6 +820,17 @@ export class Project {
818
820
  * Returns a computable, that can be used to retrieve and watch full block state,
819
821
  * including outputs, arguments, ui state.
820
822
  * */
823
+ /**
824
+ * Reads the block action context tree of a block render (see readBlockActionContextTree): current state, not
825
+ * watched. Undefined when the block has no action context.
826
+ */
827
+ public async getBlockActionContext(
828
+ blockId: string,
829
+ mode: BlockActionContextMode = "prod",
830
+ ): Promise<BlockActionContextNode | undefined> {
831
+ return await readBlockActionContextTree(this.env.pl, this.rid, blockId, mode);
832
+ }
833
+
821
834
  public getBlockState(blockId: string): Computable<BlockStateInternalV3> {
822
835
  return this.getBlockComputables(blockId).fullState;
823
836
  }
@@ -123,7 +123,10 @@ export interface ProjectField {
123
123
  | "stagingOutput"
124
124
  | "stagingCtxPrevious"
125
125
  | "stagingUiCtxPrevious"
126
- | "stagingOutputPrevious";
126
+ | "stagingOutputPrevious"
127
+ // Root action context of the current render. The project holds it so it outlives the render.
128
+ | "prodActionContext"
129
+ | "stagingActionContext";
127
130
  }
128
131
 
129
132
  export const FieldsToDuplicate: Set<ProjectField["fieldName"]> = new Set([
@@ -144,7 +147,7 @@ export function projectFieldName(blockId: string, fieldName: ProjectField["field
144
147
  }
145
148
 
146
149
  const projectFieldPattern =
147
- /^(?<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)$/;
148
151
 
149
152
  export function parseProjectField(name: string): ProjectField | undefined {
150
153
  const match = name.match(projectFieldPattern);
@@ -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";
@@ -732,6 +733,15 @@ export class ProjectMutator {
732
733
  return { ref, value: jsonBytes, status: "Ready" };
733
734
  }
734
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
+
735
745
  private getBlock(blockId: string): Block {
736
746
  for (const block of allBlocks(this.struct)) if (block.id === blockId) return block;
737
747
  throw new Error("block not found");
@@ -834,6 +844,7 @@ export class ProjectMutator {
834
844
  this.setBlockFieldObj(blockId, "stagingCtxPrevious", fields.stagingCtx!);
835
845
  this.setBlockFieldObj(blockId, "stagingUiCtxPrevious", fields.stagingUiCtx!);
836
846
  }
847
+ this.deleteBlockFields(blockId, "stagingActionContext");
837
848
  if (this.deleteBlockFields(blockId, "stagingOutput", "stagingCtx", "stagingUiCtx"))
838
849
  this.resetStagingRefreshTimestamp();
839
850
  }
@@ -849,7 +860,14 @@ export class ProjectMutator {
849
860
  this.setBlockFieldObj(blockId, "prodCtxPrevious", fields.prodCtx!);
850
861
  this.setBlockFieldObj(blockId, "prodUiCtxPrevious", fields.prodUiCtx!);
851
862
  }
852
- this.deleteBlockFields(blockId, "prodOutput", "prodCtx", "prodUiCtx", "prodArgs");
863
+ this.deleteBlockFields(
864
+ blockId,
865
+ "prodOutput",
866
+ "prodCtx",
867
+ "prodUiCtx",
868
+ "prodArgs",
869
+ "prodActionContext",
870
+ );
853
871
  }
854
872
 
855
873
  /** Running blocks are reset, settled ones (Ready or finished Error) moved to limbo. Returns if
@@ -878,6 +896,7 @@ export class ProjectMutator {
878
896
  return true;
879
897
  } else {
880
898
  // reset - clean up any partial/inconsistent production stat
899
+ this.deleteBlockFields(blockId, "prodActionContext");
881
900
  return this.deleteBlockFields(
882
901
  blockId,
883
902
  "prodOutput",
@@ -1214,12 +1233,17 @@ export class ProjectMutator {
1214
1233
  const tpl = info.getTemplate(this.tx);
1215
1234
 
1216
1235
  // Use currentPrerunArgs for staging rendering
1217
- const results = createRenderHeavyBlock(this.tx, tpl, {
1218
- args: prerunArgsRef,
1219
- blockId: this.tx.createValue(Pl.JsonString, JSON.stringify(blockId)),
1220
- isProduction: this.tx.createValue(Pl.JsonBool, JSON.stringify(false)),
1221
- context: ctx,
1222
- });
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
+ );
1223
1247
 
1224
1248
  // Here we set the staging ctx to the input context of the staging workflow, not the output because exports
1225
1249
  // of one staging context should stay within the same block, and not travel downstream.
@@ -1230,6 +1254,8 @@ export class ProjectMutator {
1230
1254
  // thus creating a certain discrepancy between staging workflow context behavior and desktop's result pool.
1231
1255
  this.setBlockField(blockId, "stagingUiCtx", this.exportCtx(results.context), "NotReady");
1232
1256
  this.setBlockField(blockId, "stagingOutput", results.result, "NotReady");
1257
+ if (results.actionContext !== undefined)
1258
+ this.setBlockField(blockId, "stagingActionContext", results.actionContext, "NotReady");
1233
1259
  }
1234
1260
 
1235
1261
  private renderProductionFor(blockId: string) {
@@ -1256,12 +1282,17 @@ export class ProjectMutator {
1256
1282
 
1257
1283
  const tpl = info.getTemplate(this.tx);
1258
1284
 
1259
- const results = createRenderHeavyBlock(this.tx, tpl, {
1260
- args: info.fields.currentArgs.ref!,
1261
- blockId: this.tx.createValue(Pl.JsonString, JSON.stringify(blockId)),
1262
- isProduction: this.tx.createValue(Pl.JsonBool, JSON.stringify(true)),
1263
- context: ctx,
1264
- });
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
+ );
1265
1296
  this.setBlockField(
1266
1297
  blockId,
1267
1298
  "prodCtx",
@@ -1270,6 +1301,8 @@ export class ProjectMutator {
1270
1301
  );
1271
1302
  this.setBlockField(blockId, "prodUiCtx", this.exportCtx(results.context), "NotReady");
1272
1303
  this.setBlockField(blockId, "prodOutput", results.result, "NotReady");
1304
+ if (results.actionContext !== undefined)
1305
+ this.setBlockField(blockId, "prodActionContext", results.actionContext, "NotReady");
1273
1306
 
1274
1307
  // saving inputs for which we rendered the production
1275
1308
  this.setBlockFieldObj(blockId, "prodArgs", info.fields.currentArgs);
@@ -1794,6 +1827,7 @@ export class ProjectMutator {
1794
1827
  // skipping finished blocks
1795
1828
  continue;
1796
1829
 
1830
+ this.deleteBlockFields(blockId, "prodActionContext");
1797
1831
  if (this.deleteBlockFields(blockId, "prodOutput", "prodCtx", "prodUiCtx", "prodArgs")) {
1798
1832
  // was actually stopped
1799
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
+ });
@@ -1,7 +1,7 @@
1
1
  import type { AnyRef, PlTransaction, ResourceRef, ResourceType } from "@milaboratories/pl-client";
2
2
  import { field, Pl } from "@milaboratories/pl-client";
3
3
  import { randomUUID } from "node:crypto";
4
- import { createRenderTemplate } from "./render_template";
4
+ import { createRenderTemplateWithResource } from "./render_template";
5
5
 
6
6
  export const BContextEnd: ResourceType = { name: "BContextEnd", version: "1" };
7
7
  export const BContext: ResourceType = { name: "BContext", version: "1" };
@@ -30,12 +30,63 @@ export type HeavyBlockOutputs = {
30
30
 
31
31
  export const HeavyBlockOutputNames: (keyof HeavyBlockOutputs)[] = ["context", "result"];
32
32
 
33
+ /** Baggage of the root action context of a block render. See the block action context spec. */
34
+ export type BlockActionContextBaggage = {
35
+ /** Display name of the block. */
36
+ name: string;
37
+ /** JSON of the block pack spec. */
38
+ blockPack: string;
39
+ blockId: string;
40
+ };
41
+
42
+ /** Resource type of a block action context. See ActionContextV1Type in the backend. */
43
+ export const ActionContextType: ResourceType = { name: "ActionContext", version: "1" };
44
+
45
+ /** The capability under which the backend accepts the "actionContext" link on a render. */
46
+ export const ActionContextLinkCapability = "optionalFieldsInSchemas:v1";
47
+
48
+ /**
49
+ * Creates the root action context of a block render: an ActionContext resource carrying the
50
+ * baggage, with a new random `run-id`, since each render is one run of the block and the contexts
51
+ * the templates create copy it. Undefined without baggage, or on a backend that cannot take the link.
52
+ */
53
+ function createBlockActionContext(
54
+ tx: PlTransaction,
55
+ baggage: BlockActionContextBaggage | undefined,
56
+ ): ResourceRef | undefined {
57
+ if (baggage === undefined || !tx.hasCapability(ActionContextLinkCapability)) return undefined;
58
+ return tx.createEphemeral(
59
+ ActionContextType,
60
+ JSON.stringify({
61
+ name: baggage.name,
62
+ "block-pack": baggage.blockPack,
63
+ "block-id": baggage.blockId,
64
+ "run-id": randomUUID(),
65
+ }),
66
+ );
67
+ }
68
+
69
+ /**
70
+ * Creates the heavy block render, and with `baggage` its root action context in the same
71
+ * transaction. Returns `actionContext`: the render is garbage collected after its outputs resolve,
72
+ * so the caller must hold the context to keep it.
73
+ */
33
74
  export function createRenderHeavyBlock(
34
75
  tx: PlTransaction,
35
76
  tpl: AnyRef,
36
77
  inputs: HeavyBlockInputs,
37
- ): HeavyBlockOutputs {
38
- return createRenderTemplate(tx, tpl, true, inputs, HeavyBlockOutputNames);
78
+ baggage?: BlockActionContextBaggage,
79
+ ): HeavyBlockOutputs & { actionContext?: ResourceRef } {
80
+ const actionContext = createBlockActionContext(tx, baggage);
81
+ const { outputs } = createRenderTemplateWithResource(
82
+ tx,
83
+ tpl,
84
+ true,
85
+ inputs,
86
+ HeavyBlockOutputNames,
87
+ actionContext,
88
+ );
89
+ return actionContext === undefined ? outputs : { ...outputs, actionContext };
39
90
  }
40
91
 
41
92
  export type LightBlockInputs = {
@@ -51,12 +102,26 @@ export type LightBlockOutput = {
51
102
 
52
103
  export const LightBlockOutputNames: (keyof LightBlockOutput)[] = ["result"];
53
104
 
105
+ /**
106
+ * Creates the light block render, with the same action context as a heavy one, so template code
107
+ * of either kind knows its block and run.
108
+ */
54
109
  export function createRenderLightBlock(
55
110
  tx: PlTransaction,
56
111
  tpl: ResourceRef,
57
112
  inputs: LightBlockInputs,
58
- ): LightBlockOutput {
59
- return createRenderTemplate(tx, tpl, true, inputs, LightBlockOutputNames);
113
+ baggage?: BlockActionContextBaggage,
114
+ ): LightBlockOutput & { actionContext?: ResourceRef } {
115
+ const actionContext = createBlockActionContext(tx, baggage);
116
+ const { outputs } = createRenderTemplateWithResource(
117
+ tx,
118
+ tpl,
119
+ true,
120
+ inputs,
121
+ LightBlockOutputNames,
122
+ actionContext,
123
+ );
124
+ return actionContext === undefined ? outputs : { ...outputs, actionContext };
60
125
  }
61
126
 
62
127
  export function createBContextEnd(tx: PlTransaction): ResourceRef {
@@ -1,4 +1,4 @@
1
- import type { AnyRef, FieldRef, PlTransaction } from "@milaboratories/pl-client";
1
+ import type { AnyRef, FieldRef, PlTransaction, ResourceRef } from "@milaboratories/pl-client";
2
2
  import { field, Pl } from "@milaboratories/pl-client";
3
3
  import type { ResourceType } from "@platforma-sdk/model";
4
4
 
@@ -27,6 +27,24 @@ export function createRenderTemplate<O extends string>(
27
27
  inputs: Pl.PlRecord,
28
28
  outputNames: O[],
29
29
  ): Record<O, FieldRef> {
30
+ return createRenderTemplateWithResource(tx, tpl, ephemeral, inputs, outputNames).outputs;
31
+ }
32
+
33
+ /**
34
+ * Same as {@link createRenderTemplate}, and also returns the render resource itself.
35
+ *
36
+ * With `actionContext`, the render's "actionContext" service field points to it. The link is created
37
+ * before the inputs are locked: the backend accepts it only in the creating transaction, ahead of
38
+ * any explicit input lock.
39
+ */
40
+ export function createRenderTemplateWithResource<O extends string>(
41
+ tx: PlTransaction,
42
+ tpl: AnyRef,
43
+ ephemeral: boolean,
44
+ inputs: Pl.PlRecord,
45
+ outputNames: O[],
46
+ actionContext?: AnyRef,
47
+ ): { render: ResourceRef; outputs: Record<O, FieldRef> } {
30
48
  if (outputNames.length === 0) throw new Error("Zero output names provided");
31
49
  const rId = ephemeral ? tx.createEphemeral(EphRenderTemplate) : tx.createStruct(RenderTemplate);
32
50
 
@@ -35,7 +53,9 @@ export function createRenderTemplate<O extends string>(
35
53
 
36
54
  tx.createField(tplField, "Input", tpl);
37
55
  tx.createField(inputsField, "Input", Pl.createPlMap(tx, inputs, ephemeral));
56
+ if (actionContext !== undefined)
57
+ tx.createField(field(rId, "actionContext"), "Service", actionContext);
38
58
  tx.lockInputs(rId);
39
59
 
40
- return Pl.futureRecord(tx, rId, outputNames, "Output", "outputs/");
60
+ return { render: rId, outputs: Pl.futureRecord(tx, rId, outputNames, "Output", "outputs/") };
41
61
  }