@smthrs/plan 0.0.0-stage → 1.0.0-rc.13

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 (209) hide show
  1. package/CHANGELOG.md +189 -0
  2. package/LICENSE +21 -0
  3. package/README.md +230 -2
  4. package/THIRD_PARTY_NOTICES.md +48 -0
  5. package/dist/cjs/CachePolicy.d.ts +131 -0
  6. package/dist/cjs/CachePolicy.d.ts.map +1 -0
  7. package/dist/cjs/CachePolicy.js +50 -0
  8. package/dist/cjs/CachePolicy.js.map +7 -0
  9. package/dist/cjs/Effects.d.ts +258 -0
  10. package/dist/cjs/Effects.d.ts.map +1 -0
  11. package/dist/cjs/Effects.js +76 -0
  12. package/dist/cjs/Effects.js.map +7 -0
  13. package/dist/cjs/FileSet.d.ts +366 -0
  14. package/dist/cjs/FileSet.d.ts.map +1 -0
  15. package/dist/cjs/FileSet.js +196 -0
  16. package/dist/cjs/FileSet.js.map +7 -0
  17. package/dist/cjs/GraphBuildError.d.ts +120 -0
  18. package/dist/cjs/GraphBuildError.d.ts.map +1 -0
  19. package/dist/cjs/GraphBuildError.js +71 -0
  20. package/dist/cjs/GraphBuildError.js.map +7 -0
  21. package/dist/cjs/KeyMaterial.d.ts +107 -0
  22. package/dist/cjs/KeyMaterial.d.ts.map +1 -0
  23. package/dist/cjs/KeyMaterial.js +64 -0
  24. package/dist/cjs/KeyMaterial.js.map +7 -0
  25. package/dist/cjs/Node.d.ts +685 -0
  26. package/dist/cjs/Node.d.ts.map +1 -0
  27. package/dist/cjs/Node.js +230 -0
  28. package/dist/cjs/Node.js.map +7 -0
  29. package/dist/cjs/Placement.d.ts +117 -0
  30. package/dist/cjs/Placement.d.ts.map +1 -0
  31. package/dist/cjs/Placement.js +46 -0
  32. package/dist/cjs/Placement.js.map +7 -0
  33. package/dist/cjs/Plan.d.ts +489 -0
  34. package/dist/cjs/Plan.d.ts.map +1 -0
  35. package/dist/cjs/Plan.js +511 -0
  36. package/dist/cjs/Plan.js.map +7 -0
  37. package/dist/cjs/PlanDiff.d.ts +77 -0
  38. package/dist/cjs/PlanDiff.d.ts.map +1 -0
  39. package/dist/cjs/PlanDiff.js +149 -0
  40. package/dist/cjs/PlanDiff.js.map +7 -0
  41. package/dist/cjs/Planned.d.ts +121 -0
  42. package/dist/cjs/Planned.d.ts.map +1 -0
  43. package/dist/cjs/Planned.js +72 -0
  44. package/dist/cjs/Planned.js.map +7 -0
  45. package/dist/cjs/Repetition.d.ts +31 -0
  46. package/dist/cjs/Repetition.d.ts.map +1 -0
  47. package/dist/cjs/Repetition.js +36 -0
  48. package/dist/cjs/Repetition.js.map +7 -0
  49. package/dist/cjs/Scheduling.d.ts +89 -0
  50. package/dist/cjs/Scheduling.d.ts.map +1 -0
  51. package/dist/cjs/Scheduling.js +76 -0
  52. package/dist/cjs/Scheduling.js.map +7 -0
  53. package/dist/cjs/StepKey.d.ts +354 -0
  54. package/dist/cjs/StepKey.d.ts.map +1 -0
  55. package/dist/cjs/StepKey.js +313 -0
  56. package/dist/cjs/StepKey.js.map +7 -0
  57. package/dist/cjs/index.d.ts +91 -0
  58. package/dist/cjs/index.d.ts.map +1 -0
  59. package/dist/cjs/index.js +59 -0
  60. package/dist/cjs/index.js.map +7 -0
  61. package/dist/cjs/internal/ConflictAnnotation.d.ts +59 -0
  62. package/dist/cjs/internal/ConflictAnnotation.d.ts.map +1 -0
  63. package/dist/cjs/internal/ConflictAnnotation.js +247 -0
  64. package/dist/cjs/internal/ConflictAnnotation.js.map +7 -0
  65. package/dist/cjs/internal/EffectCandidates.d.ts +19 -0
  66. package/dist/cjs/internal/EffectCandidates.d.ts.map +1 -0
  67. package/dist/cjs/internal/EffectCandidates.js +92 -0
  68. package/dist/cjs/internal/EffectCandidates.js.map +7 -0
  69. package/dist/cjs/internal/JsonMirror.d.ts +34 -0
  70. package/dist/cjs/internal/JsonMirror.d.ts.map +1 -0
  71. package/dist/cjs/internal/JsonMirror.js +195 -0
  72. package/dist/cjs/internal/JsonMirror.js.map +7 -0
  73. package/dist/cjs/internal/SchemaIdentity.d.ts +140 -0
  74. package/dist/cjs/internal/SchemaIdentity.d.ts.map +1 -0
  75. package/dist/cjs/internal/SchemaIdentity.js +718 -0
  76. package/dist/cjs/internal/SchemaIdentity.js.map +7 -0
  77. package/dist/cjs/internal/effects.d.ts +245 -0
  78. package/dist/cjs/internal/effects.d.ts.map +1 -0
  79. package/dist/cjs/internal/effects.js +290 -0
  80. package/dist/cjs/internal/effects.js.map +7 -0
  81. package/dist/cjs/internal/node.d.ts +526 -0
  82. package/dist/cjs/internal/node.d.ts.map +1 -0
  83. package/dist/cjs/internal/node.js +313 -0
  84. package/dist/cjs/internal/node.js.map +7 -0
  85. package/dist/cjs/package.json +1 -0
  86. package/dist/cjs/test/PlanFixtures.d.ts +127 -0
  87. package/dist/cjs/test/PlanFixtures.d.ts.map +1 -0
  88. package/dist/cjs/test/PlanFixtures.js +59 -0
  89. package/dist/cjs/test/PlanFixtures.js.map +7 -0
  90. package/dist/esm/CachePolicy.d.ts +131 -0
  91. package/dist/esm/CachePolicy.d.ts.map +1 -0
  92. package/dist/esm/CachePolicy.js +117 -0
  93. package/dist/esm/CachePolicy.js.map +1 -0
  94. package/dist/esm/Effects.d.ts +258 -0
  95. package/dist/esm/Effects.d.ts.map +1 -0
  96. package/dist/esm/Effects.js +178 -0
  97. package/dist/esm/Effects.js.map +1 -0
  98. package/dist/esm/FileSet.d.ts +366 -0
  99. package/dist/esm/FileSet.d.ts.map +1 -0
  100. package/dist/esm/FileSet.js +398 -0
  101. package/dist/esm/FileSet.js.map +1 -0
  102. package/dist/esm/GraphBuildError.d.ts +120 -0
  103. package/dist/esm/GraphBuildError.d.ts.map +1 -0
  104. package/dist/esm/GraphBuildError.js +145 -0
  105. package/dist/esm/GraphBuildError.js.map +1 -0
  106. package/dist/esm/KeyMaterial.d.ts +107 -0
  107. package/dist/esm/KeyMaterial.d.ts.map +1 -0
  108. package/dist/esm/KeyMaterial.js +92 -0
  109. package/dist/esm/KeyMaterial.js.map +1 -0
  110. package/dist/esm/Node.d.ts +685 -0
  111. package/dist/esm/Node.d.ts.map +1 -0
  112. package/dist/esm/Node.js +591 -0
  113. package/dist/esm/Node.js.map +1 -0
  114. package/dist/esm/Placement.d.ts +117 -0
  115. package/dist/esm/Placement.d.ts.map +1 -0
  116. package/dist/esm/Placement.js +65 -0
  117. package/dist/esm/Placement.js.map +1 -0
  118. package/dist/esm/Plan.d.ts +489 -0
  119. package/dist/esm/Plan.d.ts.map +1 -0
  120. package/dist/esm/Plan.js +718 -0
  121. package/dist/esm/Plan.js.map +1 -0
  122. package/dist/esm/PlanDiff.d.ts +77 -0
  123. package/dist/esm/PlanDiff.d.ts.map +1 -0
  124. package/dist/esm/PlanDiff.js +205 -0
  125. package/dist/esm/PlanDiff.js.map +1 -0
  126. package/dist/esm/Planned.d.ts +121 -0
  127. package/dist/esm/Planned.d.ts.map +1 -0
  128. package/dist/esm/Planned.js +147 -0
  129. package/dist/esm/Planned.js.map +1 -0
  130. package/dist/esm/Repetition.d.ts +31 -0
  131. package/dist/esm/Repetition.d.ts.map +1 -0
  132. package/dist/esm/Repetition.js +24 -0
  133. package/dist/esm/Repetition.js.map +1 -0
  134. package/dist/esm/Scheduling.d.ts +89 -0
  135. package/dist/esm/Scheduling.d.ts.map +1 -0
  136. package/dist/esm/Scheduling.js +82 -0
  137. package/dist/esm/Scheduling.js.map +1 -0
  138. package/dist/esm/StepKey.d.ts +354 -0
  139. package/dist/esm/StepKey.d.ts.map +1 -0
  140. package/dist/esm/StepKey.js +503 -0
  141. package/dist/esm/StepKey.js.map +1 -0
  142. package/dist/esm/index.d.ts +91 -0
  143. package/dist/esm/index.d.ts.map +1 -0
  144. package/dist/esm/index.js +91 -0
  145. package/dist/esm/index.js.map +1 -0
  146. package/dist/esm/internal/ConflictAnnotation.d.ts +59 -0
  147. package/dist/esm/internal/ConflictAnnotation.d.ts.map +1 -0
  148. package/dist/esm/internal/ConflictAnnotation.js +332 -0
  149. package/dist/esm/internal/ConflictAnnotation.js.map +1 -0
  150. package/dist/esm/internal/EffectCandidates.d.ts +19 -0
  151. package/dist/esm/internal/EffectCandidates.d.ts.map +1 -0
  152. package/dist/esm/internal/EffectCandidates.js +86 -0
  153. package/dist/esm/internal/EffectCandidates.js.map +1 -0
  154. package/dist/esm/internal/JsonMirror.d.ts +34 -0
  155. package/dist/esm/internal/JsonMirror.d.ts.map +1 -0
  156. package/dist/esm/internal/JsonMirror.js +204 -0
  157. package/dist/esm/internal/JsonMirror.js.map +1 -0
  158. package/dist/esm/internal/SchemaIdentity.d.ts +140 -0
  159. package/dist/esm/internal/SchemaIdentity.d.ts.map +1 -0
  160. package/dist/esm/internal/SchemaIdentity.js +1002 -0
  161. package/dist/esm/internal/SchemaIdentity.js.map +1 -0
  162. package/dist/esm/internal/effects.d.ts +245 -0
  163. package/dist/esm/internal/effects.d.ts.map +1 -0
  164. package/dist/esm/internal/effects.js +459 -0
  165. package/dist/esm/internal/effects.js.map +1 -0
  166. package/dist/esm/internal/node.d.ts +526 -0
  167. package/dist/esm/internal/node.d.ts.map +1 -0
  168. package/dist/esm/internal/node.js +565 -0
  169. package/dist/esm/internal/node.js.map +1 -0
  170. package/dist/esm/test/PlanFixtures.d.ts +127 -0
  171. package/dist/esm/test/PlanFixtures.d.ts.map +1 -0
  172. package/dist/esm/test/PlanFixtures.js +60 -0
  173. package/dist/esm/test/PlanFixtures.js.map +1 -0
  174. package/docs/README.md +167 -0
  175. package/docs/api.md +1485 -0
  176. package/docs/concepts/authoring-ast.md +204 -0
  177. package/docs/concepts/effects-and-conflicts.md +135 -0
  178. package/docs/concepts/plan-value.md +168 -0
  179. package/docs/concepts/step-keys.md +179 -0
  180. package/docs/guides/author-a-node-graph.md +182 -0
  181. package/docs/guides/compile-a-plan.md +146 -0
  182. package/docs/guides/declare-file-effects.md +143 -0
  183. package/docs/guides/diff-two-plans.md +310 -0
  184. package/docs/installation.md +75 -0
  185. package/docs/quickstart.md +128 -0
  186. package/docs/testing.md +73 -0
  187. package/docs/troubleshooting.md +314 -0
  188. package/package.json +255 -3
  189. package/src/CachePolicy.ts +143 -0
  190. package/src/Effects.ts +302 -0
  191. package/src/FileSet.ts +493 -0
  192. package/src/GraphBuildError.ts +156 -0
  193. package/src/KeyMaterial.ts +111 -0
  194. package/src/Node.ts +974 -0
  195. package/src/Placement.ts +99 -0
  196. package/src/Plan.ts +911 -0
  197. package/src/PlanDiff.ts +244 -0
  198. package/src/Planned.ts +208 -0
  199. package/src/Repetition.ts +33 -0
  200. package/src/Scheduling.ts +154 -0
  201. package/src/StepKey.ts +736 -0
  202. package/src/index.ts +103 -0
  203. package/src/internal/ConflictAnnotation.ts +374 -0
  204. package/src/internal/EffectCandidates.ts +89 -0
  205. package/src/internal/JsonMirror.ts +255 -0
  206. package/src/internal/SchemaIdentity.ts +1096 -0
  207. package/src/internal/effects.ts +543 -0
  208. package/src/internal/node.ts +859 -0
  209. package/src/test/PlanFixtures.ts +76 -0
package/src/StepKey.ts ADDED
@@ -0,0 +1,736 @@
1
+ /**
2
+ * The KeyMaterial → step-key compiler.
3
+ *
4
+ * Revived from the deleted `packages/smithers/flows/keys/src/StepKey.ts`. Two
5
+ * deliberate deviations from the original:
6
+ *
7
+ * 1. **It lives here, not in `@smthrs/keys`.** That package owns generic
8
+ * derivation and stored-key validation; a compiler that understands plan
9
+ * material belongs above it.
10
+ * 2. **It produces `@smthrs/keys` `StoredKey` values, not a second `sk1_` digest
11
+ * format.** The original minted its own prefix over a private `Digest`
12
+ * module. The engine dispatches under `StoredKey` (`FlowEngine/ActionKey.ts`),
13
+ * so a plan whose node keys were a *different* string format could never be
14
+ * the thing the cache is consulted against, and that is the whole point of
15
+ * a `Plan`. One key format, one hashing
16
+ * chokepoint (canonical JSON + injected `Crypto`), one namespace field
17
+ * (`kind`) distinguishing content keys from ordinal ones.
18
+ *
19
+ * Everything the original earned is kept: nominally branded digest inputs (a
20
+ * literal that merely *looks* like `{digest}` hashes as a literal), set
21
+ * normalization, the separate environment namespace, per-variant tagging of
22
+ * resolved references, and the hashed `version`.
23
+ *
24
+ * Governing contract: the step-key rules at
25
+ * https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/plan/docs/concepts/step-keys.md.
26
+ *
27
+ * @since 0.1.0
28
+ */
29
+
30
+ import { DerivedKey, type StoredKey } from "@smthrs/keys"
31
+ import * as Cause from "effect/Cause"
32
+ import type * as Crypto from "effect/Crypto"
33
+ import * as Deferred from "effect/Deferred"
34
+ import * as Effect from "effect/Effect"
35
+ import * as Exit from "effect/Exit"
36
+ import * as Schema from "effect/Schema"
37
+ import type * as FileSet from "./FileSet.ts"
38
+ import type * as KeyMaterial from "./KeyMaterial.ts"
39
+
40
+ /**
41
+ * A computed step key. Identical in representation to any other flow key —
42
+ * `key1_` plus a SHA-256 digest of the canonical serialization of the material.
43
+ *
44
+ * @since 0.1.0
45
+ * @category models
46
+ */
47
+ export type StepKey = StoredKey
48
+
49
+ /**
50
+ * The nominal brand carried by every {@link DigestInput}. Kept private: the
51
+ * only way to produce a value bearing it is {@link digestInput}, so a plain
52
+ * value that merely happens to look like `{ digest: "..." }` — actions pass
53
+ * content hashes around as ordinary data — can never be mistaken for a genuine
54
+ * upstream-result digest reference. This closes a step-key collision where
55
+ * shape-sniffing (`"digest" in value`) hashed the two identically.
56
+ *
57
+ * @since 0.1.0
58
+ * @category symbols
59
+ */
60
+ const DigestInputTypeId: unique symbol = Symbol.for("@smthrs/plan/StepKey/DigestInput")
61
+
62
+ /**
63
+ * Which kind of graph reference a {@link DigestInput} stands for. Declared
64
+ * once here so the field on `DigestInput` and the parameter of
65
+ * {@link digestInput} name one set and cannot drift apart.
66
+ */
67
+ type GraphReference = "ref" | "pending" | "ref-projected"
68
+
69
+ /**
70
+ * A precomputed digest supplied as a step input rather than a literal value.
71
+ *
72
+ * @since 0.1.0
73
+ * @category models
74
+ */
75
+ export interface DigestInput {
76
+ readonly [DigestInputTypeId]: typeof DigestInputTypeId
77
+ readonly digest: string
78
+ /**
79
+ * Which kind of graph reference resolved to this digest.
80
+ *
81
+ * Carrying the discriminator on the digest input keeps `normalizeInputs` the
82
+ * single normalizer: it wraps every variant once, and the variants stay
83
+ * mutually distinct because they differ in this field rather than in a
84
+ * hand-built object a second wrap would hide.
85
+ */
86
+ readonly reference?: GraphReference
87
+ /** The projection applied to the referenced result, for `ref-projected`. */
88
+ readonly path?: ReadonlyArray<string>
89
+ }
90
+
91
+ /**
92
+ * Nominally tags a precomputed digest so it is hashed as a digest reference
93
+ * rather than a literal value.
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.
99
+ * @since 0.1.0
100
+ * @category constructors
101
+ */
102
+ export const digestInput = (
103
+ digest: string,
104
+ reference?: {
105
+ readonly reference: GraphReference
106
+ readonly path?: ReadonlyArray<string>
107
+ }
108
+ ): DigestInput => ({
109
+ [DigestInputTypeId]: DigestInputTypeId,
110
+ digest,
111
+ ...(reference === undefined ? {} : { reference: reference.reference }),
112
+ ...(reference?.path === undefined ? {} : { path: reference.path })
113
+ })
114
+
115
+ /**
116
+ * Type guard for values produced by {@link digestInput}. A plain object
117
+ * shaped like `{ digest: "..." }` is not one.
118
+ *
119
+ * @param value - Any value.
120
+ * @returns `true` only for a value carrying the private digest-input brand.
121
+ * @since 0.1.0
122
+ * @category guards
123
+ */
124
+ export const isDigestInput = (value: unknown): value is DigestInput =>
125
+ typeof value === "object" && value !== null && DigestInputTypeId in value &&
126
+ (value as Record<PropertyKey, unknown>)[DigestInputTypeId] === DigestInputTypeId
127
+
128
+ /**
129
+ * The engine-resolved execution environment a content key is computed under,
130
+ * hashed in its OWN namespace rather than merged into the caller's `layers`
131
+ * and `capabilities`.
132
+ *
133
+ * Three properties matter and none survives a merge:
134
+ *
135
+ * 1. **Non-aliasing.** Concatenating environment material onto the caller's
136
+ * made `caller{fs:["a"]} + env{fs:["b"]}` hash identically to
137
+ * `caller{fs:["a","b"]} + env{}` — a cross-run collision in the very
138
+ * material whose purpose is preventing one.
139
+ * 2. **Declared vs undeclared.** `declared: false` is not the same value as
140
+ * `declared: true` with empty sets.
141
+ * 3. **Composition order.** Environment layers retain declaration order,
142
+ * because plugin and service composition order can change behavior. The
143
+ * caller-owned `ContentIdentity.layers` stays set-normalized.
144
+ *
145
+ * `runScope` is set only when `declared` is `false`: it pins the key to one
146
+ * run, so a step whose environment identity is unknown can never serve a
147
+ * cross-run hit. The union enforces this for typed callers; both
148
+ * {@link content} and {@link dispatchIdentity} validate it at run time.
149
+ *
150
+ * @since 0.1.0
151
+ * @category models
152
+ */
153
+ export type EnvironmentIdentity = typeof EnvironmentIdentity.Type
154
+
155
+ /**
156
+ * Validated, copyable runtime environment identity. Layer order matters;
157
+ * capability patterns are set-like. Unknown fields are not identity material.
158
+ * @since 1.0.0
159
+ * @category schemas
160
+ */
161
+ export const EnvironmentIdentity = Schema.Union([
162
+ Schema.Struct({
163
+ declared: Schema.Literal(true),
164
+ layers: Schema.Array(Schema.String),
165
+ capabilities: Schema.Record(Schema.String, Schema.Array(Schema.String)),
166
+ runScope: Schema.optionalKey(Schema.Undefined)
167
+ }),
168
+ Schema.Struct({
169
+ declared: Schema.Literal(false),
170
+ layers: Schema.Array(Schema.String),
171
+ capabilities: Schema.Record(Schema.String, Schema.Array(Schema.String)),
172
+ runScope: Schema.NonEmptyString
173
+ })
174
+ ])
175
+
176
+ /**
177
+ * Caller-owned memoization context for projected dependency-value digests.
178
+ *
179
+ * This is the flows counterpart of Bazel's `ActionKeyContext`: one context is
180
+ * passed through related key computations so shared input material is hashed
181
+ * once. Concurrent requests for the same projection share the in-flight
182
+ * digest. Entries are sound only while each settled `from` value is immutable;
183
+ * callers must create a fresh memo when those values can change.
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
+ *
190
+ * @since 0.1.0
191
+ * @category models
192
+ */
193
+ export interface DigestMemo {
194
+ readonly digest: (
195
+ from: string,
196
+ path: ReadonlyArray<string>,
197
+ compute: Effect.Effect<StepKey, Schema.SchemaError, Crypto.Crypto>
198
+ ) => Effect.Effect<StepKey, Schema.SchemaError, Crypto.Crypto>
199
+ }
200
+
201
+ /**
202
+ * Creates an empty projected-value digest memo. Projection addresses are
203
+ * JSON-encoded `[from, path]` tuples so segment boundaries are unambiguous.
204
+ *
205
+ * @returns A memo with no entries.
206
+ * @since 0.1.0
207
+ * @category constructors
208
+ */
209
+ export const makeDigestMemo = (): DigestMemo => {
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.
214
+ const digest: DigestMemo["digest"] = (from, path, compute) =>
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
+ )
238
+ )
239
+ })
240
+ )
241
+ return { digest }
242
+ }
243
+
244
+ /**
245
+ * Material describing a sealed or hermetic content-addressed step.
246
+ *
247
+ * @since 0.1.0
248
+ * @category models
249
+ */
250
+ export interface ContentIdentity {
251
+ readonly body: unknown
252
+ readonly inputs: Readonly<Record<string, unknown | DigestInput>>
253
+ readonly layers: ReadonlyArray<string>
254
+ readonly capabilities: Readonly<Record<string, ReadonlyArray<string>>>
255
+ readonly environment?: EnvironmentIdentity | undefined
256
+ readonly hermetic?: {
257
+ readonly readSet: ReadonlyArray<{ readonly path: string; readonly digest: string }>
258
+ readonly writeSet: ReadonlyArray<FileSet.Entry>
259
+ readonly removes?: ReadonlyArray<string> | undefined
260
+ readonly boundaryMode: FileSet.BoundaryMode
261
+ } | undefined
262
+ }
263
+
264
+ /**
265
+ * Material describing the run-local identity of a non-cacheable step.
266
+ *
267
+ * @since 0.1.0
268
+ * @category models
269
+ */
270
+ export interface OrdinalIdentity {
271
+ readonly runId: string
272
+ readonly parentScope?: string | undefined
273
+ readonly ordinal: number
274
+ readonly tier: "compensable" | "irreversible" | "unsealed"
275
+ }
276
+
277
+ /**
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`.
287
+ *
288
+ * @since 0.1.0
289
+ * @category errors
290
+ */
291
+ export class KeyMaterialError extends Schema.TaggedError<KeyMaterialError>()("@smthrs/plan/KeyMaterialError", {
292
+ code: Schema.Literals(["invalid_environment", "missing_dependency", "non_content_material"]),
293
+ message: Schema.String
294
+ }) {}
295
+
296
+ const validateEnvironment = (environment: EnvironmentIdentity): KeyMaterialError | undefined => {
297
+ const candidate = environment as { readonly declared?: unknown; readonly runScope?: unknown }
298
+ if (candidate.declared === false) {
299
+ if (typeof candidate.runScope !== "string" || candidate.runScope.length === 0) {
300
+ return new KeyMaterialError({
301
+ code: "invalid_environment",
302
+ message: "Undeclared environment identity requires a non-empty runScope"
303
+ })
304
+ }
305
+ return undefined
306
+ }
307
+ if (candidate.declared === true) {
308
+ return candidate.runScope === undefined
309
+ ? undefined
310
+ : new KeyMaterialError({
311
+ code: "invalid_environment",
312
+ message: "Declared environment identity must not include runScope"
313
+ })
314
+ }
315
+ return new KeyMaterialError({
316
+ code: "invalid_environment",
317
+ message: "Environment identity requires a boolean declared field"
318
+ })
319
+ }
320
+
321
+ const decodeEnvironment = (environment: EnvironmentIdentity) =>
322
+ Effect.try({
323
+ try: () => {
324
+ const invalid = validateEnvironment(environment)
325
+ if (invalid !== undefined) throw invalid
326
+ return Schema.decodeUnknownSync(EnvironmentIdentity)(environment, { onExcessProperty: "error" })
327
+ },
328
+ catch: (cause) =>
329
+ cause instanceof KeyMaterialError ? cause : new KeyMaterialError({
330
+ code: "invalid_environment",
331
+ message:
332
+ "Environment identity must contain only its declared fields, string layers, and string capability patterns"
333
+ })
334
+ })
335
+
336
+ const sortStrings = (values: ReadonlyArray<string>): Array<string> =>
337
+ [...new Set(values.map((value) => value.normalize("NFC")))].sort()
338
+
339
+ const normalizeInputs = (inputs: ContentIdentity["inputs"]): Record<string, unknown> =>
340
+ Object.fromEntries(
341
+ Object.entries(inputs).map(([name, value]) => [
342
+ name,
343
+ isDigestInput(value)
344
+ ? {
345
+ kind: "digest",
346
+ digest: value.digest,
347
+ ...(value.reference === undefined ? {} : { reference: value.reference }),
348
+ ...(value.path === undefined ? {} : { path: value.path })
349
+ }
350
+ : { kind: "literal", value }
351
+ ])
352
+ )
353
+
354
+ const normalizeCapabilities = (capabilities: ContentIdentity["capabilities"]): Record<string, Array<string>> =>
355
+ Object.fromEntries(Object.entries(capabilities).map(([group, patterns]) => [group, sortStrings(patterns)]))
356
+
357
+ const normalizeEnvironment = (environment: EnvironmentIdentity) => ({
358
+ declared: environment.declared,
359
+ layers: environment.layers.map((layer) => layer.normalize("NFC")),
360
+ capabilities: normalizeCapabilities(environment.capabilities),
361
+ ...(environment.runScope === undefined ? {} : { runScope: environment.runScope })
362
+ })
363
+
364
+ const compareCodeUnits = (left: string, right: string): number => left < right ? -1 : 1
365
+
366
+ type NormalizedWriteEntry =
367
+ | string
368
+ | { readonly _tag: "TreeArtifact"; readonly path: string }
369
+ | {
370
+ readonly _tag: "Glob"
371
+ readonly include: ReadonlyArray<string>
372
+ readonly exclude?: ReadonlyArray<string> | undefined
373
+ }
374
+
375
+ const writeEntryKey = (entry: NormalizedWriteEntry): string =>
376
+ typeof entry === "string"
377
+ ? JSON.stringify(["Path", entry])
378
+ : entry._tag === "TreeArtifact"
379
+ ? JSON.stringify([entry._tag, entry.path])
380
+ : JSON.stringify([entry._tag, entry.include, entry.exclude ?? null])
381
+
382
+ const normalizeHermetic = (hermetic: NonNullable<ContentIdentity["hermetic"]>) => {
383
+ const readSet = [...hermetic.readSet]
384
+ .map((entry) => ({ path: entry.path.normalize("NFC"), digest: entry.digest }))
385
+ .sort((left, right) => {
386
+ if (left.path !== right.path) return left.path < right.path ? -1 : 1
387
+ return left.digest < right.digest ? -1 : left.digest > right.digest ? 1 : 0
388
+ })
389
+ .filter((entry, index, entries) =>
390
+ index === 0 || entry.path !== entries[index - 1]!.path || entry.digest !== entries[index - 1]!.digest
391
+ )
392
+ const normalizedWrites = [...hermetic.writeSet].map((entry) => {
393
+ if (typeof entry === "string") return entry.normalize("NFC")
394
+ if (entry._tag === "TreeArtifact") return { ...entry, path: entry.path.normalize("NFC") }
395
+ return {
396
+ ...entry,
397
+ include: sortStrings(entry.include),
398
+ ...(entry.exclude === undefined ? {} : { exclude: sortStrings(entry.exclude) })
399
+ }
400
+ })
401
+ const writeSet = [...new Map(normalizedWrites.map((entry) => [writeEntryKey(entry), entry])).entries()]
402
+ .sort(([left], [right]) => compareCodeUnits(left, right))
403
+ .map(([, entry]) => entry)
404
+ return {
405
+ readSet,
406
+ writeSet,
407
+ ...(hermetic.removes === undefined ? {} : { removes: sortStrings(hermetic.removes) }),
408
+ boundaryMode: hermetic.boundaryMode
409
+ }
410
+ }
411
+
412
+ const decodeKey = Schema.decodeUnknownEffect(DerivedKey)
413
+
414
+ /**
415
+ * Fingerprint for binding one execution to its runtime environment, not an
416
+ * action dispatch key. Uses the same normalization as content/dispatch keys.
417
+ * An omitted environment is distinct from declared-empty and run-scoped ones.
418
+ * @since 1.0.0
419
+ * @category constructors
420
+ */
421
+ export const environmentIdentity = (
422
+ environment?: EnvironmentIdentity
423
+ ): Effect.Effect<StoredKey, KeyMaterialError | Schema.SchemaError, Crypto.Crypto> =>
424
+ Effect.gen(function*() {
425
+ const captured = environment === undefined ? undefined : yield* decodeEnvironment(environment)
426
+ return yield* decodeKey({
427
+ kind: "execution-environment/v1",
428
+ environment: captured === undefined ? null : normalizeEnvironment(captured)
429
+ })
430
+ })
431
+
432
+ /**
433
+ * Produces a cross-run reusable key for a sealed or hermetic step. Set-like
434
+ * declarations are normalized before serialization; write declarations remain
435
+ * part of the identity even when the step writes nothing.
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.
440
+ * @since 0.1.0
441
+ * @category constructors
442
+ */
443
+ export const content = (
444
+ identity: ContentIdentity
445
+ ): Effect.Effect<StepKey, KeyMaterialError | Schema.SchemaError, Crypto.Crypto> =>
446
+ Effect.gen(function*() {
447
+ const environment = identity.environment === undefined ? undefined : yield* decodeEnvironment(identity.environment)
448
+ return yield* decodeKey({
449
+ kind: "content",
450
+ body: identity.body,
451
+ inputs: normalizeInputs(identity.inputs),
452
+ layers: sortStrings(identity.layers),
453
+ capabilities: normalizeCapabilities(identity.capabilities),
454
+ ...(environment === undefined ? {} : { environment: normalizeEnvironment(environment) }),
455
+ ...(identity.hermetic === undefined ? {} : { hermetic: normalizeHermetic(identity.hermetic) })
456
+ })
457
+ })
458
+
459
+ /**
460
+ * Produces a run-local ordinal key for compensable, irreversible, or unsealed
461
+ * work. These keys intentionally cannot be reused across runs.
462
+ *
463
+ * @param identity - The run id, optional parent scope, ordinal, and tier.
464
+ * @returns The ordinal key.
465
+ * @since 0.1.0
466
+ * @category constructors
467
+ */
468
+ export const ordinal = (
469
+ identity: OrdinalIdentity
470
+ ): Effect.Effect<StepKey, Schema.SchemaError, Crypto.Crypto> => decodeKey({ kind: "ordinal", ...identity })
471
+
472
+ /**
473
+ * Resolves graph-local references to dependency digests and constructs a
474
+ * sealed content key. Use {@link planIdentity} to compile any effect tier.
475
+ *
476
+ * This is the handoff the Flow Builder Brief describes: the planner hands over
477
+ * a topologically ordered sequence of `{ nodeId, material }`, and for each one
478
+ * the compiler substitutes every `Ref`/`Pending` with the *already computed
479
+ * key of the referenced node*. Structural node ids are lookup addresses only
480
+ * and never enter the hashed value — rename a node and nothing re-keys; change
481
+ * what a node consumes and everything downstream of it does.
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.
488
+ * @since 0.1.0
489
+ * @category constructors
490
+ */
491
+ export const fromKeyMaterial = (
492
+ material: KeyMaterial.KeyMaterial,
493
+ dependencyDigests: Readonly<Record<string, string>>
494
+ ): Effect.Effect<StepKey, KeyMaterialError | Schema.SchemaError, Crypto.Crypto> => {
495
+ if (material.kind !== "sealed") {
496
+ return Effect.fail(
497
+ new KeyMaterialError({
498
+ code: "non_content_material",
499
+ message: `Cannot create a content key for ${material.kind} material`
500
+ })
501
+ )
502
+ }
503
+ return materialIdentity(material, dependencyDigests).pipe(Effect.flatMap(content))
504
+ }
505
+
506
+ /**
507
+ * Identifies a declaration in a plan, independently of whether its execution
508
+ * can be cached. Sealed material retains its existing content-key format;
509
+ * compensable and irreversible material use a distinct, tier-bearing plan
510
+ * namespace. These fingerprints bind approvals, not cross-run result reuse.
511
+ * Dispatch non-cacheable work under a run-local {@link ordinal} instead.
512
+ *
513
+ * @since 1.0.0
514
+ * @category constructors
515
+ */
516
+ export const planIdentity = (
517
+ material: KeyMaterial.KeyMaterial,
518
+ dependencyDigests: Readonly<Record<string, string>>
519
+ ): Effect.Effect<StepKey, KeyMaterialError | Schema.SchemaError, Crypto.Crypto> =>
520
+ material.kind === "sealed" ?
521
+ fromKeyMaterial(material, dependencyDigests) :
522
+ materialIdentity(material, dependencyDigests).pipe(Effect.flatMap((identity) =>
523
+ decodeKey({
524
+ kind: "plan-declaration",
525
+ tier: material.kind,
526
+ body: identity.body,
527
+ inputs: normalizeInputs(identity.inputs),
528
+ layers: sortStrings(identity.layers),
529
+ capabilities: normalizeCapabilities(identity.capabilities)
530
+ })
531
+ ))
532
+
533
+ /**
534
+ * Reads the entry a graph reference names. Only an own data property counts:
535
+ * an absent name or an accessor is a missing dependency, and no getter runs.
536
+ * `label` names what the record holds, as each refusal says it.
537
+ *
538
+ * @private
539
+ */
540
+ const ownDataProperty = (
541
+ record: Readonly<Record<string, unknown>>,
542
+ from: string,
543
+ label: "Digest" | "Settled result"
544
+ ): Effect.Effect<unknown, KeyMaterialError> => {
545
+ const descriptor = Object.getOwnPropertyDescriptor(record, from)
546
+ if (descriptor === undefined) {
547
+ return Effect.fail(
548
+ new KeyMaterialError({
549
+ code: "missing_dependency",
550
+ message: `Missing ${label.toLowerCase()} for graph dependency ${from}`
551
+ })
552
+ )
553
+ }
554
+ if (!("value" in descriptor)) {
555
+ return Effect.fail(
556
+ new KeyMaterialError({
557
+ code: "missing_dependency",
558
+ message: `${label} for graph dependency ${from} must be a data property`
559
+ })
560
+ )
561
+ }
562
+ return Effect.succeed(descriptor.value)
563
+ }
564
+
565
+ /**
566
+ * The positional inputs both identities hash. A `Literal` contributes its raw
567
+ * value, so `normalizeInputs` applies the one literal wrap; `resolve` supplies
568
+ * the digest for each `Ref` and `Pending`, tagged with the reference variant.
569
+ *
570
+ * @private
571
+ */
572
+ const resolveInputs = <E, R>(
573
+ material: KeyMaterial.KeyMaterial,
574
+ resolve: (reference: Exclude<KeyMaterial.InputRef, { readonly _tag: "Literal" }>) => Effect.Effect<string, E, R>
575
+ ): Effect.Effect<Record<string, unknown>, E, R> =>
576
+ Effect.gen(function*() {
577
+ const inputs: Record<string, unknown> = {}
578
+ for (let index = 0; index < material.inputs.length; index++) {
579
+ const input = material.inputs[index]!
580
+ if (input._tag === "Literal") {
581
+ inputs[String(index)] = input.value
582
+ continue
583
+ }
584
+ const digest = yield* resolve(input)
585
+ inputs[String(index)] = input._tag === "Pending"
586
+ ? digestInput(digest, { reference: "pending" })
587
+ : input.path.length > 0
588
+ ? digestInput(digest, { reference: "ref-projected", path: input.path })
589
+ : digestInput(digest, { reference: "ref" })
590
+ }
591
+ return inputs
592
+ })
593
+
594
+ /** Resolves plan references once for both content and non-cacheable declarations. */
595
+ const materialIdentity = (
596
+ material: KeyMaterial.KeyMaterial,
597
+ dependencyDigests: Readonly<Record<string, string>>
598
+ ): Effect.Effect<ContentIdentity, KeyMaterialError> =>
599
+ resolveInputs(
600
+ material,
601
+ (reference) =>
602
+ Effect.flatMap(ownDataProperty(dependencyDigests, reference.from, "Digest"), (digest) =>
603
+ typeof digest === "string" ? Effect.succeed(digest) : Effect.fail(
604
+ new KeyMaterialError({
605
+ code: "missing_dependency",
606
+ message: `Digest for graph dependency ${reference.from} must be a string`
607
+ })
608
+ ))
609
+ ).pipe(Effect.map((inputs) => ({
610
+ body: materialBody(material),
611
+ inputs,
612
+ layers: material.layers,
613
+ capabilities: { declared: material.capabilities }
614
+ })))
615
+
616
+ /**
617
+ * The node's own declaration, hashed. Shared by {@link planIdentity},
618
+ * {@link fromKeyMaterial}, and {@link dispatchIdentity} so derivations cannot drift about what
619
+ * "the node itself" means.
620
+ *
621
+ * @private
622
+ */
623
+ const materialBody = (material: KeyMaterial.KeyMaterial) => ({
624
+ version: material.version,
625
+ declaration: material.body,
626
+ ...(material.nondeterministic === undefined ? {} : { nondeterministic: material.nondeterministic }),
627
+ ...(material.effects === undefined ? {} : { effects: material.effects }),
628
+ ...(material.placement === undefined ? {} : { placement: material.placement })
629
+ })
630
+
631
+ /**
632
+ * The digest a `Pending` input contributes to a dispatch key. It is a
633
+ * constant, and that is the point: `Pending` is an ordering reference, and
634
+ * ordering does not change what a node consumes, so it must not change what
635
+ * the node caches under. The string is self-describing, so a reader of a
636
+ * hashed payload can see that no upstream value was folded.
637
+ *
638
+ * @private
639
+ */
640
+ const orderingOnly = "ordering-only"
641
+
642
+ /**
643
+ * Projects a settled result along a `Ref` path. Only own data properties
644
+ * resolve, so a missing, inherited, or accessor segment yields `undefined`
645
+ * without invoking a getter. This is a stable, distinct value: a projection that
646
+ * walks off the end of a result is a fact about the graph, not a failure.
647
+ * `undefined` drops out of the canonical form, so it hashes distinctly from
648
+ * every JSON value including `null`.
649
+ *
650
+ * Exported because it is the ONE projection semantics for the value channel:
651
+ * {@link dispatchIdentity} digests what this returns, so every consumer that
652
+ * resolves a `Ref` at execution time must resolve it the same way, or two
653
+ * inputs that key identically could be consumed differently — a stale-hit
654
+ * vector.
655
+ *
656
+ * @since 0.1.0
657
+ * @category utilities
658
+ */
659
+ export const project = (value: unknown, path: ReadonlyArray<string>): unknown => {
660
+ let current = value
661
+ for (const segment of path) {
662
+ if (typeof current !== "object" || current === null) return undefined
663
+ const descriptor = Object.getOwnPropertyDescriptor(current, segment)
664
+ if (descriptor === undefined || !("value" in descriptor)) return undefined
665
+ current = descriptor.value
666
+ }
667
+ return current
668
+ }
669
+
670
+ /**
671
+ * The key a dispatch is *cached* under, as distinct from the plan key a node
672
+ * is *identified* by.
673
+ *
674
+ * A plan key folds the resolved keys of every upstream node, transitively, so
675
+ * an edit anywhere upstream re-keys everything below it — even when the edited
676
+ * node's output value is byte for byte what it was before. Bazel does not work
677
+ * that way: `ActionCacheChecker` keys an action by the *content* of its
678
+ * inputs, so an unchanged output stops invalidation dead. The file channel in
679
+ * flows already had that property through the measured boundary digests; the
680
+ * JSON value channel did not, and this is what gives it one.
681
+ *
682
+ * The derivation folds the node's OWN material — body, version, effects,
683
+ * placement, layers, capabilities — and never an upstream key. Each material
684
+ * input contributes content instead: a `Literal` its value, a `Ref` the digest
685
+ * of the settled result of `from` projected along `path`, and a `Pending`
686
+ * nothing beyond its tag. The measured hermetic boundary is folded unchanged.
687
+ *
688
+ * `results` must hold every dependency the material names. The scheduler's
689
+ * halt rule guarantees it: a dependent of failed or skipped work never
690
+ * dispatches, so a `Ref` always resolves against a success.
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.
697
+ * @since 0.1.0
698
+ * @category constructors
699
+ */
700
+ export const dispatchIdentity = (options: {
701
+ readonly material: KeyMaterial.KeyMaterial
702
+ /** The settled output value of each dependency, by node id. */
703
+ readonly results: Readonly<Record<string, unknown>>
704
+ readonly hermetic: NonNullable<ContentIdentity["hermetic"]>
705
+ /** The engine-resolved execution environment this dispatch runs under. */
706
+ readonly environment?: EnvironmentIdentity | undefined
707
+ /** Reuses projected-value digests while the corresponding settled values remain immutable. */
708
+ readonly digestMemo?: DigestMemo | undefined
709
+ }): Effect.Effect<StepKey, KeyMaterialError | Schema.SchemaError, Crypto.Crypto> =>
710
+ Effect.gen(function*() {
711
+ const material = options.material
712
+ if (material.kind !== "sealed") {
713
+ return yield* new KeyMaterialError({
714
+ code: "non_content_material",
715
+ message: `Cannot create a dispatch key for ${material.kind} material`
716
+ })
717
+ }
718
+ const environment = options.environment === undefined ? undefined : yield* decodeEnvironment(options.environment)
719
+ const inputs = yield* resolveInputs(material, (reference) =>
720
+ reference._tag === "Pending"
721
+ ? Effect.succeed(orderingOnly)
722
+ : Effect.flatMap(ownDataProperty(options.results, reference.from, "Settled result"), (settled) => {
723
+ const compute = decodeKey({ kind: "input-value", value: project(settled, reference.path) })
724
+ return options.digestMemo === undefined
725
+ ? compute
726
+ : options.digestMemo.digest(reference.from, reference.path, compute)
727
+ }))
728
+ return yield* content({
729
+ body: materialBody(material),
730
+ inputs,
731
+ layers: material.layers,
732
+ capabilities: { declared: material.capabilities },
733
+ environment,
734
+ hermetic: options.hermetic
735
+ })
736
+ })