@smthrs/plan 1.0.0-rc.7 → 1.0.0-rc.9

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 (68) hide show
  1. package/README.md +3 -1
  2. package/dist/cjs/Effects.d.ts +29 -11
  3. package/dist/cjs/Effects.d.ts.map +1 -1
  4. package/dist/cjs/Effects.js.map +2 -2
  5. package/dist/cjs/FileSet.d.ts +121 -51
  6. package/dist/cjs/FileSet.d.ts.map +1 -1
  7. package/dist/cjs/FileSet.js.map +2 -2
  8. package/dist/cjs/GraphBuildError.d.ts +2 -4
  9. package/dist/cjs/GraphBuildError.d.ts.map +1 -1
  10. package/dist/cjs/GraphBuildError.js.map +2 -2
  11. package/dist/cjs/KeyMaterial.d.ts +13 -8
  12. package/dist/cjs/KeyMaterial.d.ts.map +1 -1
  13. package/dist/cjs/KeyMaterial.js.map +2 -2
  14. package/dist/cjs/Plan.d.ts +72 -23
  15. package/dist/cjs/Plan.d.ts.map +1 -1
  16. package/dist/cjs/Plan.js +3 -0
  17. package/dist/cjs/Plan.js.map +2 -2
  18. package/dist/cjs/PlanDiff.d.ts +26 -7
  19. package/dist/cjs/PlanDiff.d.ts.map +1 -1
  20. package/dist/cjs/PlanDiff.js +29 -4
  21. package/dist/cjs/PlanDiff.js.map +2 -2
  22. package/dist/cjs/StepKey.d.ts +38 -17
  23. package/dist/cjs/StepKey.d.ts.map +1 -1
  24. package/dist/cjs/StepKey.js +24 -22
  25. package/dist/cjs/StepKey.js.map +2 -2
  26. package/dist/cjs/internal/node.d.ts.map +1 -1
  27. package/dist/esm/Effects.d.ts +29 -11
  28. package/dist/esm/Effects.d.ts.map +1 -1
  29. package/dist/esm/Effects.js +18 -7
  30. package/dist/esm/Effects.js.map +1 -1
  31. package/dist/esm/FileSet.d.ts +121 -51
  32. package/dist/esm/FileSet.d.ts.map +1 -1
  33. package/dist/esm/FileSet.js +97 -35
  34. package/dist/esm/FileSet.js.map +1 -1
  35. package/dist/esm/GraphBuildError.d.ts +2 -4
  36. package/dist/esm/GraphBuildError.d.ts.map +1 -1
  37. package/dist/esm/GraphBuildError.js +2 -3
  38. package/dist/esm/GraphBuildError.js.map +1 -1
  39. package/dist/esm/KeyMaterial.d.ts +13 -8
  40. package/dist/esm/KeyMaterial.d.ts.map +1 -1
  41. package/dist/esm/KeyMaterial.js +11 -4
  42. package/dist/esm/KeyMaterial.js.map +1 -1
  43. package/dist/esm/Plan.d.ts +72 -23
  44. package/dist/esm/Plan.d.ts.map +1 -1
  45. package/dist/esm/Plan.js +61 -16
  46. package/dist/esm/Plan.js.map +1 -1
  47. package/dist/esm/PlanDiff.d.ts +26 -7
  48. package/dist/esm/PlanDiff.d.ts.map +1 -1
  49. package/dist/esm/PlanDiff.js +55 -9
  50. package/dist/esm/PlanDiff.js.map +1 -1
  51. package/dist/esm/StepKey.d.ts +38 -17
  52. package/dist/esm/StepKey.d.ts.map +1 -1
  53. package/dist/esm/StepKey.js +41 -16
  54. package/dist/esm/StepKey.js.map +1 -1
  55. package/dist/esm/internal/node.d.ts.map +1 -1
  56. package/docs/README.md +3 -1
  57. package/docs/api.md +1 -1
  58. package/docs/guides/diff-two-plans.md +138 -74
  59. package/docs/installation.md +5 -1
  60. package/docs/quickstart.md +1 -1
  61. package/package.json +4 -4
  62. package/src/Effects.ts +29 -11
  63. package/src/FileSet.ts +121 -51
  64. package/src/GraphBuildError.ts +2 -4
  65. package/src/KeyMaterial.ts +13 -8
  66. package/src/Plan.ts +66 -23
  67. package/src/PlanDiff.ts +56 -11
  68. package/src/StepKey.ts +66 -40
package/src/Plan.ts CHANGED
@@ -34,12 +34,17 @@ import * as StepKey from "./StepKey.ts"
34
34
  * What a node does to the world, declared. Paths only — measuring them is
35
35
  * run-time work, so a digest here would break the no-I/O law.
36
36
  *
37
+ * Decoding fails with a `SchemaError` when a path is not workspace-relative.
38
+ * {@link compile} also refuses a node whose `removes` overlaps its `writes`
39
+ * with an `invalid_effects` {@link PlanError}.
40
+ *
37
41
  * @since 0.1.0
38
42
  * @category schemas
39
- * @slop
40
43
  */
41
44
  export const NodeEffects = Schema.Struct({
45
+ /** Paths the node reads. A read orders the node after every writer of an overlapping path. */
42
46
  reads: Schema.Array(FileSet.ReadDeclaration),
47
+ /** Paths the node writes. Two writers of an overlapping path form a conflict. */
43
48
  writes: Schema.Array(FileSet.Declaration),
44
49
  /**
45
50
  * Paths the node declares it will DELETE. A removal mutates the world exactly
@@ -47,6 +52,7 @@ export const NodeEffects = Schema.Struct({
47
52
  * conflict pass and the reader-after-writer pass see them as one set.
48
53
  */
49
54
  removes: Schema.optional(Schema.Array(FileSet.Pattern)),
55
+ /** How strictly the sandbox enforces these declarations. */
50
56
  boundaryMode: FileSet.BoundaryMode
51
57
  })
52
58
 
@@ -55,16 +61,18 @@ export const NodeEffects = Schema.Struct({
55
61
  *
56
62
  * @since 0.1.0
57
63
  * @category models
58
- * @slop
59
64
  */
60
65
  export type NodeEffects = typeof NodeEffects.Type
61
66
 
62
67
  /**
63
- * The plan-time write-conflict strategies of the effect taxonomy.
68
+ * The plan-time verdict for two writers of an overlapping path:
69
+ * `serialize` gives the later writer an ordering edge, `lane` records a lane
70
+ * annotation on both writers and adds no edge, and `fail` refuses the plan
71
+ * with `overlap_forbidden`. For a pair, `fail` dominates `lane`, which
72
+ * dominates `serialize`. The same schema as `Effects.ConflictStrategy`.
64
73
  *
65
74
  * @since 0.1.0
66
75
  * @category schemas
67
- * @slop
68
76
  */
69
77
  export const PairStrategy = Effects.ConflictStrategy
70
78
 
@@ -73,18 +81,17 @@ export const PairStrategy = Effects.ConflictStrategy
73
81
  *
74
82
  * @since 0.1.0
75
83
  * @category models
76
- * @slop
77
84
  */
78
85
  export type PairStrategy = typeof PairStrategy.Type
79
86
 
80
87
  /**
81
88
  * The runtime half of a conflict annotation: what the scheduler
82
89
  * does when the overlap the plan predicted actually bites, or when a
83
- * scheduled node's inputs are invalidated under it.
90
+ * scheduled node's inputs are invalidated under it. For a pair,
91
+ * `stop-merge` dominates `delay-rebase`.
84
92
  *
85
93
  * @since 0.1.0
86
94
  * @category schemas
87
- * @slop
88
95
  */
89
96
  export const RuntimeStrategy = Schema.Literals(["delay-rebase", "stop-merge"])
90
97
 
@@ -93,7 +100,6 @@ export const RuntimeStrategy = Schema.Literals(["delay-rebase", "stop-merge"])
93
100
  *
94
101
  * @since 0.1.0
95
102
  * @category models
96
- * @slop
97
103
  */
98
104
  export type RuntimeStrategy = typeof RuntimeStrategy.Type
99
105
 
@@ -104,9 +110,11 @@ const NodeKind = Schema.Literals(["step", "agent", "merge"])
104
110
  * One resolved overlap between two writers that no dependency path already
105
111
  * orders. Conflict is a property of the PAIR, not of one declaration.
106
112
  *
113
+ * `with` names the other node, `paths` lists the overlapping declarations,
114
+ * and `strategy` and `runtime` are the verdicts resolved for the pair.
115
+ *
107
116
  * @since 0.1.0
108
117
  * @category schemas
109
- * @slop
110
118
  */
111
119
  export const ConflictAnnotation = Schema.Struct({
112
120
  with: Schema.NonEmptyString,
@@ -120,7 +128,6 @@ export const ConflictAnnotation = Schema.Struct({
120
128
  *
121
129
  * @since 0.1.0
122
130
  * @category models
123
- * @slop
124
131
  */
125
132
  export type ConflictAnnotation = typeof ConflictAnnotation.Type
126
133
 
@@ -138,9 +145,11 @@ export type ConflictAnnotation = typeof ConflictAnnotation.Type
138
145
  * a later elaboration can resolve a pair against them without re-reading the
139
146
  * flow source.
140
147
  *
148
+ * `generation` is 0 for nodes from {@link compile} and grows by one with each
149
+ * {@link append}.
150
+ *
141
151
  * @since 0.1.0
142
152
  * @category schemas
143
- * @slop
144
153
  */
145
154
  export const PlanNode = Schema.Struct({
146
155
  id: Schema.NonEmptyString,
@@ -161,7 +170,6 @@ export const PlanNode = Schema.Struct({
161
170
  *
162
171
  * @since 0.1.0
163
172
  * @category models
164
- * @slop
165
173
  */
166
174
  export type PlanNode = typeof PlanNode.Type
167
175
 
@@ -175,9 +183,11 @@ export type PlanNode = typeof PlanNode.Type
175
183
  * `baseDigest` is the digest at generation 0: what a human approved and what
176
184
  * a `RUNNING` run pins. `digest` advances with every appended elaboration.
177
185
  *
186
+ * Decoding checks only the shape. Use {@link verify} to check that keys,
187
+ * edges, and digests match the declarations.
188
+ *
178
189
  * @since 0.1.0
179
190
  * @category schemas
180
- * @slop
181
191
  */
182
192
  export const Plan = Schema.Struct({
183
193
  planId: Schema.NonEmptyString,
@@ -193,7 +203,6 @@ export const Plan = Schema.Struct({
193
203
  *
194
204
  * @since 0.1.0
195
205
  * @category models
196
- * @slop
197
206
  */
198
207
  export type Plan = typeof Plan.Type
199
208
 
@@ -202,13 +211,17 @@ export type Plan = typeof Plan.Type
202
211
  *
203
212
  * @since 0.1.0
204
213
  * @category models
205
- * @slop
206
214
  */
207
215
  export interface NodeDraft {
216
+ /** A non-empty id, unique within the plan. */
208
217
  readonly id: string
218
+ /** The key inputs. Its `Ref` inputs name the node's material dependencies. */
209
219
  readonly material: KeyMaterial.KeyMaterial
220
+ /** The declared reads, writes, and removals. They replace `material.effects`. */
210
221
  readonly effects: NodeEffects
222
+ /** `step`, `agent`, or `merge`. Defaults to `step`. */
211
223
  readonly kind?: PlanNode["kind"] | undefined
224
+ /** A safe integer scheduling priority. Defaults to 0. */
212
225
  readonly priority?: number | undefined
213
226
  /** This declaration's preferred plan-time verdict for a detected overlap. */
214
227
  readonly conflictStrategy?: PairStrategy | undefined
@@ -217,11 +230,22 @@ export interface NodeDraft {
217
230
  }
218
231
 
219
232
  /**
220
- * A graph the compiler refuses.
233
+ * A graph the compiler refuses. `code` names the rule:
234
+ *
235
+ * - `cycle`: material or inferred edges form a cycle.
236
+ * - `unknown_dependency`: a `Ref` names a node that is not in the plan.
237
+ * - `duplicate_node`: two nodes share an id.
238
+ * - `overlap_forbidden`: two unordered writers overlap and the pair's
239
+ * strategy is `fail`.
240
+ * - `invalid_effects`: a node both writes and removes one path.
241
+ * - `invalid_node`: a draft field, the plan id, or the flow name is malformed.
242
+ * - `invalid_plan`: {@link verify} found a plan that does not match its
243
+ * declarations.
244
+ * - `graph_too_large`: the plan exceeds {@link maximumPlanNodes} or the
245
+ * effect-analysis work budget.
221
246
  *
222
247
  * @since 0.1.0
223
248
  * @category errors
224
- * @slop
225
249
  */
226
250
  export class PlanError extends Schema.TaggedError<PlanError>()("@smthrs/plan/PlanError", {
227
251
  code: Schema.Literals([
@@ -245,7 +269,6 @@ export class PlanError extends Schema.TaggedError<PlanError>()("@smthrs/plan/Pla
245
269
  *
246
270
  * @since 1.0.0
247
271
  * @category limits
248
- * @slop
249
272
  */
250
273
  export const maximumPlanNodes = 10_000
251
274
 
@@ -671,9 +694,15 @@ const keyNodes = (
671
694
  * {@link maximumPlanNodes} and fail with `graph_too_large` above that limit
672
695
  * or the effect-analysis work budget. Analysis yields periodically.
673
696
  *
697
+ * The options and drafts are copied when `compile` is called, so later
698
+ * caller mutation does not reach the plan. The result is deeply frozen.
699
+ *
700
+ * @param options - `planId` and `flow` name the plan; `nodes` are the drafts.
701
+ * @returns The generation-0 plan, whose `baseDigest` equals its `digest`.
702
+ * Fails with {@link PlanError} for a refused graph, `KeyMaterialError` or
703
+ * `SchemaError` when a key cannot be derived.
674
704
  * @since 0.1.0
675
705
  * @category constructors
676
- * @slop
677
706
  */
678
707
  export const compile = (options: {
679
708
  readonly planId: string
@@ -710,9 +739,16 @@ export const compile = (options: {
710
739
  * Re-ordering after a reconciliation happens by re-keying *future* steps,
711
740
  * never by rewriting history.
712
741
  *
742
+ * A plan not produced by this module is checked with {@link verify} first.
743
+ *
744
+ * @param plan - The plan to grow.
745
+ * @param drafts - At least one new node. Ids must not repeat existing ones;
746
+ * `Ref` inputs may name existing nodes.
747
+ * @returns The plan with one more generation and a new `digest`. Fails with
748
+ * {@link PlanError} (`invalid_node` for an empty `drafts`, `invalid_plan` for a
749
+ * plan that fails verification), `KeyMaterialError`, or `SchemaError`.
713
750
  * @since 0.1.0
714
751
  * @category constructors
715
- * @slop
716
752
  */
717
753
  export const append = (
718
754
  plan: Plan,
@@ -832,9 +868,10 @@ export const verify = (
832
868
  * The nodes added by the newest generation: what {@link module:PlanStore}
833
869
  * appends and what the `subgraph-appended` journal record names.
834
870
  *
871
+ * @param plan - A plan.
872
+ * @returns The nodes whose generation equals `plan.generation`, in order.
835
873
  * @since 0.1.0
836
874
  * @category accessors
837
- * @slop
838
875
  */
839
876
  export const generationNodes = (plan: Plan): ReadonlyArray<PlanNode> =>
840
877
  plan.nodes.filter((node) => node.generation === plan.generation)
@@ -844,9 +881,11 @@ export const generationNodes = (plan: Plan): ReadonlyArray<PlanNode> =>
844
881
  * {@link verify}: such values take verify's trusted fast path and need no
845
882
  * second schema validation.
846
883
  *
884
+ * @param input - Any value.
885
+ * @returns `true` only for a plan returned by {@link compile},
886
+ * {@link append}, or {@link verify}; a structurally equal copy returns `false`.
847
887
  * @since 1.0.0
848
888
  * @category guards
849
- * @slop
850
889
  */
851
890
  export const isVerified = (input: unknown): input is Plan =>
852
891
  typeof input === "object" && input !== null && frozenPlans.has(input as Plan)
@@ -858,9 +897,13 @@ export const isVerified = (input: unknown): input is Plan =>
858
897
  * plan's integrity from the already-verified prefix instead of decoding and
859
898
  * re-verifying every stored row.
860
899
  *
900
+ * For a generation-0 plan no generation precedes the newest, so this is the
901
+ * digest of an empty node list.
902
+ *
903
+ * @param plan - A plan.
904
+ * @returns The approval digest of the nodes older than `plan.generation`.
861
905
  * @since 1.0.0
862
906
  * @category accessors
863
- * @slop
864
907
  */
865
908
  export const prefixDigest = (
866
909
  plan: Plan
package/src/PlanDiff.ts CHANGED
@@ -6,10 +6,10 @@
6
6
  * 'what is different about this run' is a set comparison, not archaeology."
7
7
  * This module is the set comparison.
8
8
  *
9
- * The **verdict** is the key: two nodes with the same id and the same key are
10
- * the same step and nothing else needs saying. The **attribution** —
11
- * `changed: ["input[1]"]` — is a report for a human, derived by comparing the
12
- * declarations field by field. It is deliberately not part of any digest.
9
+ * The **verdict** is the key: two nodes with the same key are the same step,
10
+ * under the same id or a moved one, and nothing else needs saying. The
11
+ * **attribution** — `changed: ["input[1]"]` — is a report for a human, derived
12
+ * by comparing the declarations field by field. It is deliberately not part of any digest.
13
13
  *
14
14
  * @since 0.1.0
15
15
  */
@@ -23,11 +23,13 @@ import type * as Planned from "./Planned.ts"
23
23
  *
24
24
  * @since 0.1.0
25
25
  * @category models
26
- * @slop
27
26
  */
28
27
  export interface Rekeyed {
28
+ /** The node's id in the newer plan. */
29
29
  readonly id: string
30
+ /** The step key the paired node carried in the older plan. */
30
31
  readonly from: string
32
+ /** The step key the node carries in the newer plan. */
31
33
  readonly to: string
32
34
  /**
33
35
  * Field labels such as `"kind"`, `"body"`, `"layers"`, `"capabilities"`,
@@ -44,12 +46,15 @@ export interface Rekeyed {
44
46
  *
45
47
  * @since 0.1.0
46
48
  * @category models
47
- * @slop
48
49
  */
49
50
  export interface PlanDiff {
51
+ /** Ids, in the newer plan, of nodes with no counterpart in the older plan. */
50
52
  readonly added: ReadonlyArray<string>
53
+ /** Ids, in the older plan, of nodes with no counterpart in the newer plan. */
51
54
  readonly removed: ReadonlyArray<string>
55
+ /** Nodes paired by id whose step key changed, with the fields that changed it. */
52
56
  readonly rekeyed: ReadonlyArray<Rekeyed>
57
+ /** Ids, in the newer plan, of nodes whose step key is unchanged, including nodes whose id moved. */
53
58
  readonly unchanged: ReadonlyArray<string>
54
59
  }
55
60
 
@@ -157,24 +162,64 @@ const changedFields = (
157
162
  /**
158
163
  * Compares a plan against the last plan for the same flow.
159
164
  *
165
+ * Nodes pair in three passes, each over the nodes no earlier pass paired:
166
+ * the same id with the same key, then the same key under any id, then the
167
+ * same id with a different key. The second pass exists because flow node ids
168
+ * can be positional: inserting a step shifts the ids after it while their
169
+ * keys stay equal, and that is one added step, not a re-key of every later
170
+ * one (#3905).
171
+ *
172
+ * `added`, `rekeyed`, and `unchanged` name nodes by their id in `next`;
173
+ * `removed` names nodes by their id in `previous`. A moved id can therefore
174
+ * appear once in `removed` or `added` and once in `unchanged`.
175
+ *
176
+ * @param previous - The last plan recorded for the flow.
177
+ * @param next - The plan to compare against it.
178
+ * @returns The added, removed, re-keyed, and unchanged node ids, each in its
179
+ * plan's node order.
160
180
  * @since 0.1.0
161
181
  * @category constructors
162
- * @slop
163
182
  */
164
183
  export const diff = (previous: Plan.Plan, next: Plan.Plan): PlanDiff => {
184
+ // pairs maps a next node to the previous node it is the same step as.
185
+ const pairs = new Map<Plan.PlanNode, Plan.PlanNode>()
186
+ const paired = new Set<Plan.PlanNode>()
187
+ const pair = (node: Plan.PlanNode, original: Plan.PlanNode) => {
188
+ pairs.set(node, original)
189
+ paired.add(original)
190
+ }
165
191
  const before = new Map(previous.nodes.map((node) => [node.id, node]))
166
- const after = new Map(next.nodes.map((node) => [node.id, node]))
192
+ for (const node of next.nodes) {
193
+ const original = before.get(node.id)
194
+ if (original !== undefined && original.key === node.key) pair(node, original)
195
+ }
196
+ const byKey = new Map<string, Array<Plan.PlanNode>>()
197
+ for (const node of previous.nodes) {
198
+ if (paired.has(node)) continue
199
+ const same = byKey.get(node.key)
200
+ if (same === undefined) byKey.set(node.key, [node])
201
+ else same.push(node)
202
+ }
203
+ for (const node of next.nodes) {
204
+ if (pairs.has(node)) continue
205
+ const original = byKey.get(node.key)?.shift()
206
+ if (original !== undefined) pair(node, original)
207
+ }
167
208
  const rekeyedIds = new Set<string>()
168
209
  for (const node of next.nodes) {
210
+ if (pairs.has(node)) continue
169
211
  const original = before.get(node.id)
170
- if (original !== undefined && original.key !== node.key) rekeyedIds.add(node.id)
212
+ if (original !== undefined && !paired.has(original)) {
213
+ pair(node, original)
214
+ rekeyedIds.add(node.id)
215
+ }
171
216
  }
172
217
  const added: Array<string> = []
173
218
  const rekeyed: Array<Rekeyed> = []
174
219
  const unchanged: Array<string> = []
175
220
  const sameField = fieldComparison()
176
221
  for (const node of next.nodes) {
177
- const original = before.get(node.id)
222
+ const original = pairs.get(node)
178
223
  if (original === undefined) {
179
224
  added.push(node.id)
180
225
  continue
@@ -192,7 +237,7 @@ export const diff = (previous: Plan.Plan, next: Plan.Plan): PlanDiff => {
192
237
  }
193
238
  return {
194
239
  added,
195
- removed: previous.nodes.filter((node) => !after.has(node.id)).map((node) => node.id),
240
+ removed: previous.nodes.filter((node) => !paired.has(node)).map((node) => node.id),
196
241
  rekeyed,
197
242
  unchanged
198
243
  }
package/src/StepKey.ts CHANGED
@@ -43,7 +43,6 @@ import type * as KeyMaterial from "./KeyMaterial.ts"
43
43
  *
44
44
  * @since 0.1.0
45
45
  * @category models
46
- * @slop
47
46
  */
48
47
  export type StepKey = StoredKey
49
48
 
@@ -72,7 +71,6 @@ type GraphReference = "ref" | "pending" | "ref-projected"
72
71
  *
73
72
  * @since 0.1.0
74
73
  * @category models
75
- * @slop
76
74
  */
77
75
  export interface DigestInput {
78
76
  readonly [DigestInputTypeId]: typeof DigestInputTypeId
@@ -94,9 +92,12 @@ export interface DigestInput {
94
92
  * Nominally tags a precomputed digest so it is hashed as a digest reference
95
93
  * rather than a literal value.
96
94
  *
95
+ * @param digest - The precomputed digest.
96
+ * @param reference - Which graph reference resolved to the digest, and the
97
+ * projection path for `ref-projected`. Omit for a plain digest.
98
+ * @returns The tagged digest input.
97
99
  * @since 0.1.0
98
100
  * @category constructors
99
- * @slop
100
101
  */
101
102
  export const digestInput = (
102
103
  digest: string,
@@ -112,11 +113,13 @@ export const digestInput = (
112
113
  })
113
114
 
114
115
  /**
115
- * Type guard for values produced by {@link digestInput}.
116
+ * Type guard for values produced by {@link digestInput}. A plain object
117
+ * shaped like `{ digest: "..." }` is not one.
116
118
  *
119
+ * @param value - Any value.
120
+ * @returns `true` only for a value carrying the private digest-input brand.
117
121
  * @since 0.1.0
118
122
  * @category guards
119
- * @slop
120
123
  */
121
124
  export const isDigestInput = (value: unknown): value is DigestInput =>
122
125
  typeof value === "object" && value !== null && DigestInputTypeId in value &&
@@ -146,7 +149,6 @@ export const isDigestInput = (value: unknown): value is DigestInput =>
146
149
  *
147
150
  * @since 0.1.0
148
151
  * @category models
149
- * @slop
150
152
  */
151
153
  export type EnvironmentIdentity = typeof EnvironmentIdentity.Type
152
154
 
@@ -180,9 +182,13 @@ export const EnvironmentIdentity = Schema.Union([
180
182
  * digest. Entries are sound only while each settled `from` value is immutable;
181
183
  * callers must create a fresh memo when those values can change.
182
184
  *
185
+ * `digest(from, path, compute)` runs `compute` once per address and shares its
186
+ * result with concurrent callers. A failed or interrupted computation is not
187
+ * kept: the next caller recomputes, and a waiter whose leader was interrupted
188
+ * becomes the new leader instead of inheriting the interrupt.
189
+ *
183
190
  * @since 0.1.0
184
191
  * @category models
185
- * @slop
186
192
  */
187
193
  export interface DigestMemo {
188
194
  readonly digest: (
@@ -196,37 +202,42 @@ export interface DigestMemo {
196
202
  * Creates an empty projected-value digest memo. Projection addresses are
197
203
  * JSON-encoded `[from, path]` tuples so segment boundaries are unambiguous.
198
204
  *
205
+ * @returns A memo with no entries.
199
206
  * @since 0.1.0
200
207
  * @category constructors
201
- * @slop
202
208
  */
203
209
  export const makeDigestMemo = (): DigestMemo => {
204
210
  const entries = new Map<string, Deferred.Deferred<StepKey, Schema.SchemaError>>()
211
+ // Registering the entry and installing its exit handler run uninterruptibly,
212
+ // so an interrupt can never land between them and strand a Deferred that no
213
+ // fiber will complete. The wait and the computation stay interruptible.
205
214
  const digest: DigestMemo["digest"] = (from, path, compute) =>
206
- Effect.suspend(() => {
207
- const address = JSON.stringify([from, path])
208
- const existing = entries.get(address)
209
- if (existing !== undefined) {
210
- return Effect.flatMap(Effect.exit(Deferred.await(existing)), (exit) => {
211
- if (Exit.isSuccess(exit)) return Effect.succeed(exit.value)
212
- // A waiter did not request the leader's cancellation. It competes to
213
- // become the replacement leader instead of inheriting that interrupt.
214
- return Cause.hasInterrupts(exit.cause) ? digest(from, path, compute) : Effect.failCause(exit.cause)
215
- })
216
- }
217
- const pending = Deferred.makeUnsafe<StepKey, Schema.SchemaError>()
218
- entries.set(address, pending)
219
- return compute.pipe(
220
- Effect.onExit((exit) =>
221
- Effect.gen(function*() {
222
- if (Exit.isFailure(exit) && entries.get(address) === pending) {
223
- entries.delete(address)
224
- }
225
- yield* Deferred.done(pending, exit)
226
- })
215
+ Effect.uninterruptibleMask((restore) =>
216
+ Effect.suspend(() => {
217
+ const address = JSON.stringify([from, path])
218
+ const existing = entries.get(address)
219
+ if (existing !== undefined) {
220
+ return restore(Effect.flatMap(Effect.exit(Deferred.await(existing)), (exit) => {
221
+ if (Exit.isSuccess(exit)) return Effect.succeed(exit.value)
222
+ // A waiter did not request the leader's cancellation. It competes to
223
+ // become the replacement leader instead of inheriting that interrupt.
224
+ return Cause.hasInterrupts(exit.cause) ? digest(from, path, compute) : Effect.failCause(exit.cause)
225
+ }))
226
+ }
227
+ const pending = Deferred.makeUnsafe<StepKey, Schema.SchemaError>()
228
+ entries.set(address, pending)
229
+ return restore(compute).pipe(
230
+ Effect.onExit((exit) =>
231
+ Effect.gen(function*() {
232
+ if (Exit.isFailure(exit) && entries.get(address) === pending) {
233
+ entries.delete(address)
234
+ }
235
+ yield* Deferred.done(pending, exit)
236
+ })
237
+ )
227
238
  )
228
- )
229
- })
239
+ })
240
+ )
230
241
  return { digest }
231
242
  }
232
243
 
@@ -235,7 +246,6 @@ export const makeDigestMemo = (): DigestMemo => {
235
246
  *
236
247
  * @since 0.1.0
237
248
  * @category models
238
- * @slop
239
249
  */
240
250
  export interface ContentIdentity {
241
251
  readonly body: unknown
@@ -256,7 +266,6 @@ export interface ContentIdentity {
256
266
  *
257
267
  * @since 0.1.0
258
268
  * @category models
259
- * @slop
260
269
  */
261
270
  export interface OrdinalIdentity {
262
271
  readonly runId: string
@@ -266,11 +275,18 @@ export interface OrdinalIdentity {
266
275
  }
267
276
 
268
277
  /**
269
- * Stable failures while resolving graph-local dependency references.
278
+ * Stable failures while deriving a key. `code` names the rule:
279
+ *
280
+ * - `invalid_environment`: an {@link EnvironmentIdentity} that is neither
281
+ * declared nor run-scoped.
282
+ * - `missing_dependency`: a `Ref` or `Pending` input names a node with no
283
+ * recorded digest, or, at dispatch, a `Ref` names one with no settled
284
+ * result.
285
+ * - `non_content_material`: a content-key constructor received material whose
286
+ * tier is not `sealed`.
270
287
  *
271
288
  * @since 0.1.0
272
289
  * @category errors
273
- * @slop
274
290
  */
275
291
  export class KeyMaterialError extends Schema.TaggedError<KeyMaterialError>()("@smthrs/plan/KeyMaterialError", {
276
292
  code: Schema.Literals(["invalid_environment", "missing_dependency", "non_content_material"]),
@@ -418,9 +434,11 @@ export const environmentIdentity = (
418
434
  * declarations are normalized before serialization; write declarations remain
419
435
  * part of the identity even when the step writes nothing.
420
436
  *
437
+ * @param identity - The step's content material.
438
+ * @returns The content key. Fails with `invalid_environment` for a malformed
439
+ * environment, or `SchemaError` when the material cannot be serialized.
421
440
  * @since 0.1.0
422
441
  * @category constructors
423
- * @slop
424
442
  */
425
443
  export const content = (
426
444
  identity: ContentIdentity
@@ -442,9 +460,10 @@ export const content = (
442
460
  * Produces a run-local ordinal key for compensable, irreversible, or unsealed
443
461
  * work. These keys intentionally cannot be reused across runs.
444
462
  *
463
+ * @param identity - The run id, optional parent scope, ordinal, and tier.
464
+ * @returns The ordinal key.
445
465
  * @since 0.1.0
446
466
  * @category constructors
447
- * @slop
448
467
  */
449
468
  export const ordinal = (
450
469
  identity: OrdinalIdentity
@@ -461,9 +480,13 @@ export const ordinal = (
461
480
  * and never enter the hashed value — rename a node and nothing re-keys; change
462
481
  * what a node consumes and everything downstream of it does.
463
482
  *
483
+ * @param material - The node's key material; its tier must be `sealed`.
484
+ * @param dependencyDigests - The key of every node the material references,
485
+ * by node id.
486
+ * @returns The content key. Fails with `non_content_material` for a
487
+ * non-sealed tier and `missing_dependency` for an unresolved reference.
464
488
  * @since 0.1.0
465
489
  * @category constructors
466
- * @slop
467
490
  */
468
491
  export const fromKeyMaterial = (
469
492
  material: KeyMaterial.KeyMaterial,
@@ -632,7 +655,6 @@ const orderingOnly = "ordering-only"
632
655
  *
633
656
  * @since 0.1.0
634
657
  * @category utilities
635
- * @slop
636
658
  */
637
659
  export const project = (value: unknown, path: ReadonlyArray<string>): unknown => {
638
660
  let current = value
@@ -667,9 +689,13 @@ export const project = (value: unknown, path: ReadonlyArray<string>): unknown =>
667
689
  * halt rule guarantees it: a dependent of failed or skipped work never
668
690
  * dispatches, so a `Ref` always resolves against a success.
669
691
  *
692
+ * @param options - The material, the dependencies' settled results, the
693
+ * measured hermetic boundary, and an optional environment and memo.
694
+ * @returns The dispatch key. Fails with `non_content_material` for a
695
+ * non-sealed tier, `missing_dependency` for a `Ref` absent from `results`,
696
+ * or `invalid_environment` for a malformed environment.
670
697
  * @since 0.1.0
671
698
  * @category constructors
672
- * @slop
673
699
  */
674
700
  export const dispatchIdentity = (options: {
675
701
  readonly material: KeyMaterial.KeyMaterial