@milaboratories/pl-middle-layer 1.66.4 → 1.66.6

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.
@@ -18,6 +18,7 @@ import {
18
18
  isResourceRef,
19
19
  Pl,
20
20
  PlClient,
21
+ readyOrDuplicateOrError,
21
22
  } from "@milaboratories/pl-client";
22
23
  import {
23
24
  createRenderHeavyBlock,
@@ -87,6 +88,16 @@ import type { BlockPackInfo } from "../model/block_pack";
87
88
 
88
89
  type FieldStatus = "NotReady" | "Ready" | "Error";
89
90
 
91
+ /**
92
+ * A production core field is "settled" once its result is immutable — `Ready` or a finished
93
+ * `Error`. `NotReady` (still computing) is the only unsettled status; a missing field is unsettled.
94
+ * Settled production is safe to keep/share; only running production is dropped. The resource-level
95
+ * equivalent is {@link readyOrDuplicateOrError}.
96
+ */
97
+ function isProductionFieldSettled(status: FieldStatus | undefined): boolean {
98
+ return status === "Ready" || status === "Error";
99
+ }
100
+
90
101
  interface BlockFieldState {
91
102
  modCount: number;
92
103
  ref?: AnyRef;
@@ -521,6 +532,43 @@ export class ProjectMutator {
521
532
  return this.actualProductionGraph;
522
533
  }
523
534
 
535
+ /**
536
+ * Blocks whose production must NOT be shared with a copy of the project: those still computing
537
+ * ("NotReady"), plus their downstream closure. Settled production — `Ready` or a finished
538
+ * `Error` — is kept: it is immutable, so sharing by reference is safe, and sharing an errored
539
+ * project (to show it failed) is a valid use-case. Only running production would tie the copy to
540
+ * the source's live computation.
541
+ *
542
+ * The closure keeps the chain consistent: a finished block's `prodCtx` is built from its
543
+ * upstreams' `prodCtx` (see {@link createProdCtx}), so keeping it while dropping a running
544
+ * upstream would leave it referencing an absent context — a break {@link check} misses. Settled
545
+ * blocks are never seeds, so their downstream is retained.
546
+ *
547
+ * Blocks without production are ignored. Returns empty when nothing is running.
548
+ */
549
+ public productionBlocksToDropForCopy(): Set<string> {
550
+ const dropSet = new Set<string>();
551
+ for (const [blockId, info] of this.blockInfos) {
552
+ const hasProduction =
553
+ info.fields.prodOutput !== undefined || info.fields.prodCtx !== undefined;
554
+ if (!hasProduction) continue;
555
+ // Drop only running production; keep both settled states (Ready and Error).
556
+ const running =
557
+ !isProductionFieldSettled(info.fields.prodOutput?.status) ||
558
+ !isProductionFieldSettled(info.fields.prodCtx?.status);
559
+ if (running) dropSet.add(blockId);
560
+ }
561
+ if (dropSet.size === 0) return dropSet;
562
+
563
+ // Snapshot before expanding, so the closure can't re-seed from its own additions. Seed only
564
+ // graph-present blocks: a partial block may lack prodArgs and not be a graph node, and
565
+ // traverse() throws on a missing root.
566
+ const graph = this.getActualProductionGraph();
567
+ const seeds = [...dropSet].filter((id) => graph.nodes.has(id));
568
+ for (const id of graph.traverseIds("downstream", ...seeds)) dropSet.add(id);
569
+ return dropSet;
570
+ }
571
+
524
572
  //
525
573
  // Generic helpers to interact with project state
526
574
  //
@@ -661,19 +709,23 @@ export class ProjectMutator {
661
709
  this.deleteBlockFields(blockId, "prodOutput", "prodCtx", "prodUiCtx", "prodArgs");
662
710
  }
663
711
 
664
- /** Running blocks are reset, already computed moved to limbo. Returns if
712
+ /** Running blocks are reset, settled ones (Ready or finished Error) moved to limbo. Returns if
665
713
  * either of the actions were actually performed.
666
714
  * This method ensures the block is left in a consistent state that passes check() constraints. */
667
715
  private resetOrLimboProduction(blockId: string): boolean {
668
716
  const fields = this.getBlockInfo(blockId).fields;
669
717
 
670
- // Check if we can safely move to limbo (both core production fields are ready)
671
- if (fields.prodOutput?.status === "Ready" && fields.prodCtx?.status === "Ready") {
718
+ // Move to limbo only if both core production fields have settled (a finished Error is kept, not
719
+ // reset sharing/keeping a failed block is valid); otherwise reset.
720
+ if (
721
+ isProductionFieldSettled(fields.prodOutput?.status) &&
722
+ isProductionFieldSettled(fields.prodCtx?.status)
723
+ ) {
672
724
  if (this.blocksInLimbo.has(blockId))
673
725
  // we are already in limbo
674
726
  return false;
675
727
 
676
- // limbo - keep the ready production results but clean up cache
728
+ // limbo - keep the settled production results but clean up cache
677
729
  this.blocksInLimbo.add(blockId);
678
730
  this.renderingStateChanged = true;
679
731
 
@@ -1164,8 +1216,11 @@ export class ProjectMutator {
1164
1216
  const diff = <T>(setA: Set<T>, setB: Set<T>): Set<T> =>
1165
1217
  new Set([...setA].filter((x) => !setB.has(x)));
1166
1218
 
1167
- // Check if we can safely move to limbo (both core production fields are ready)
1168
- if (fields.prodOutput?.status === "Ready" && fields.prodCtx?.status === "Ready") {
1219
+ // Keep settled production (Ready or finished Error); drop it only while still running.
1220
+ if (
1221
+ isProductionFieldSettled(fields.prodOutput?.status) &&
1222
+ isProductionFieldSettled(fields.prodCtx?.status)
1223
+ ) {
1169
1224
  if (this.blocksInLimbo.has(blockId))
1170
1225
  // we are already in limbo
1171
1226
  return FieldsToDuplicate;
@@ -1957,6 +2012,13 @@ export async function duplicateProject(
1957
2012
  tx: PlTransaction,
1958
2013
  sourceRid: SignedResourceId,
1959
2014
  options?: { label?: string },
2015
+ /**
2016
+ * Optional model access, used only to compute the precise drop set when the source has running
2017
+ * production (see below). Omitted, or a non-current schema → conservative all-or-nothing drop
2018
+ * (still correct, less selective). Interactive callers pass it for precision; callers without a
2019
+ * handy helper (sharing, CLI) omit it.
2020
+ */
2021
+ projectHelper?: ProjectHelper,
1960
2022
  ): Promise<ResourceRef> {
1961
2023
  // Read source resource data (with fields) and all KV pairs
1962
2024
  const sourceDataP = tx.getResourceData(sourceRid, true);
@@ -1980,6 +2042,77 @@ export async function duplicateProject(
1980
2042
  const newPrj = tx.createEphemeral(ProjectResourceType);
1981
2043
  tx.lock(newPrj);
1982
2044
 
2045
+ // Production fields (prodOutput/prodCtx/prodUiCtx/prodArgs) are copied by reference — the copy
2046
+ // points at the SAME backend resources. Safe only for *settled* production (Ready or finished
2047
+ // Error, both immutable); a running (NotReady) block would tie the copy to the source's live
2048
+ // computation. Errored production is kept on purpose — sharing a failed project is a valid case.
2049
+ //
2050
+ // Drop running blocks plus their downstream closure (a finished block's prodCtx is built from its
2051
+ // upstreams' — see createProdCtx — so keeping it while dropping a running upstream breaks the
2052
+ // chain). The closure needs the model-layer dependency graph (ProjectHelper + current schema):
2053
+ // - fast path: cheap resource-level scan; nothing running → copy all production;
2054
+ // - precise path: something running, graph available → drop running blocks + downstream closure;
2055
+ // - fallback: something running, no graph (no helper / unmigrated schema) → drop ALL production.
2056
+ // Conservative, but always chain-consistent.
2057
+ const prodFields = new Set<ProjectField["fieldName"]>([
2058
+ "prodOutput",
2059
+ "prodCtx",
2060
+ "prodUiCtx",
2061
+ "prodArgs",
2062
+ ]);
2063
+
2064
+ // Has this target settled (data will no longer change: Ready, deduplicated, or finished-error)?
2065
+ // Schema-agnostic, no model layer. Exactly the canonical readyOrDuplicateOrError predicate, which
2066
+ // matches the field-status rule in ProjectMutator.load (NotReady iff none of those hold).
2067
+ const isTargetSettled = async (rid: SignedResourceId | undefined): Promise<boolean> => {
2068
+ if (rid === undefined || isNullSignedResourceId(rid)) return false;
2069
+ try {
2070
+ const r = await tx.getResourceData(rid, false);
2071
+ return readyOrDuplicateOrError(r);
2072
+ } catch {
2073
+ // Gone/inaccessible → not settled: drop and re-derive in the copy rather than fail the whole
2074
+ // duplicate.
2075
+ return false;
2076
+ }
2077
+ };
2078
+
2079
+ // Collect each block's prodOutput/prodCtx targets (the blocks that have production).
2080
+ const prodCoreByBlock = new Map<
2081
+ string,
2082
+ { prodOutput?: SignedResourceId; prodCtx?: SignedResourceId }
2083
+ >();
2084
+ for (const f of sourceData.fields) {
2085
+ if (isNullSignedResourceId(f.value)) continue;
2086
+ const parsed = parseProjectField(f.name);
2087
+ if (parsed === undefined) continue;
2088
+ if (parsed.fieldName === "prodOutput" || parsed.fieldName === "prodCtx") {
2089
+ const core = prodCoreByBlock.get(parsed.blockId) ?? {};
2090
+ core[parsed.fieldName] = f.value;
2091
+ prodCoreByBlock.set(parsed.blockId, core);
2092
+ }
2093
+ }
2094
+ const anyRunning = (
2095
+ await Promise.all(
2096
+ [...prodCoreByBlock.values()].map(
2097
+ async (core) =>
2098
+ !((await isTargetSettled(core.prodOutput)) && (await isTargetSettled(core.prodCtx))),
2099
+ ),
2100
+ )
2101
+ ).some((running) => running);
2102
+
2103
+ let dropProduction: ReadonlySet<string>;
2104
+ if (!anyRunning) {
2105
+ dropProduction = new Set(); // fast path: all production settled (Ready/error) → copy as-is
2106
+ } else if (projectHelper !== undefined && schema === SchemaVersionCurrent) {
2107
+ // Precise path: drop running blocks + their downstream closure, computed from the source's
2108
+ // dependency graph. The copy shares the source's block ids, so the set applies directly.
2109
+ const srcMutator = await ProjectMutator.load(projectHelper, tx, sourceRid);
2110
+ dropProduction = srcMutator.productionBlocksToDropForCopy();
2111
+ } else {
2112
+ // Fallback: no graph available → drop all production (every block that has any).
2113
+ dropProduction = new Set(prodCoreByBlock.keys());
2114
+ }
2115
+
1983
2116
  // Copy KV pairs with adjustments
1984
2117
  const ts = String(Date.now());
1985
2118
  const kvSkipPrefixes = [BlockArgsAuthorKeyPrefix];
@@ -1999,7 +2132,8 @@ export async function duplicateProject(
1999
2132
  const meta: ProjectMeta = JSON.parse(value);
2000
2133
  tx.setKValue(newPrj, key, JSON.stringify({ ...meta, label: options.label }));
2001
2134
  } else {
2002
- // Copy as-is
2135
+ // Copy as-is. Limbo entries are left untouched: a limbo block's production is Ready (kept, so
2136
+ // never dropped), and a stale entry self-heals when the copy is opened.
2003
2137
  tx.setKValue(newPrj, key, value);
2004
2138
  }
2005
2139
  }
@@ -2017,6 +2151,13 @@ export async function duplicateProject(
2017
2151
  if (isNullSignedResourceId(f.value)) continue;
2018
2152
  const parsed = parseProjectField(f.name);
2019
2153
  if (parsed !== undefined && !FieldsToDuplicate.has(parsed.fieldName)) continue;
2154
+ // Drop production for running blocks and their downstream closure (see above).
2155
+ if (
2156
+ parsed !== undefined &&
2157
+ prodFields.has(parsed.fieldName) &&
2158
+ dropProduction.has(parsed.blockId)
2159
+ )
2160
+ continue;
2020
2161
  tx.createField(field(newPrj, f.name), "Dynamic", f.value);
2021
2162
  }
2022
2163