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

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 (199) hide show
  1. package/CHANGELOG.md +183 -0
  2. package/LICENSE +21 -0
  3. package/README.md +228 -2
  4. package/dist/cjs/CachePolicy.d.ts +131 -0
  5. package/dist/cjs/CachePolicy.d.ts.map +1 -0
  6. package/dist/cjs/CachePolicy.js +50 -0
  7. package/dist/cjs/CachePolicy.js.map +7 -0
  8. package/dist/cjs/Effects.d.ts +240 -0
  9. package/dist/cjs/Effects.d.ts.map +1 -0
  10. package/dist/cjs/Effects.js +76 -0
  11. package/dist/cjs/Effects.js.map +7 -0
  12. package/dist/cjs/FileSet.d.ts +288 -0
  13. package/dist/cjs/FileSet.d.ts.map +1 -0
  14. package/dist/cjs/FileSet.js +191 -0
  15. package/dist/cjs/FileSet.js.map +7 -0
  16. package/dist/cjs/GraphBuildError.d.ts +122 -0
  17. package/dist/cjs/GraphBuildError.d.ts.map +1 -0
  18. package/dist/cjs/GraphBuildError.js +71 -0
  19. package/dist/cjs/GraphBuildError.js.map +7 -0
  20. package/dist/cjs/KeyMaterial.d.ts +102 -0
  21. package/dist/cjs/KeyMaterial.d.ts.map +1 -0
  22. package/dist/cjs/KeyMaterial.js +64 -0
  23. package/dist/cjs/KeyMaterial.js.map +7 -0
  24. package/dist/cjs/Node.d.ts +606 -0
  25. package/dist/cjs/Node.d.ts.map +1 -0
  26. package/dist/cjs/Node.js +207 -0
  27. package/dist/cjs/Node.js.map +7 -0
  28. package/dist/cjs/Placement.d.ts +117 -0
  29. package/dist/cjs/Placement.d.ts.map +1 -0
  30. package/dist/cjs/Placement.js +46 -0
  31. package/dist/cjs/Placement.js.map +7 -0
  32. package/dist/cjs/Plan.d.ts +440 -0
  33. package/dist/cjs/Plan.d.ts.map +1 -0
  34. package/dist/cjs/Plan.js +508 -0
  35. package/dist/cjs/Plan.js.map +7 -0
  36. package/dist/cjs/PlanDiff.d.ts +58 -0
  37. package/dist/cjs/PlanDiff.d.ts.map +1 -0
  38. package/dist/cjs/PlanDiff.js +124 -0
  39. package/dist/cjs/PlanDiff.js.map +7 -0
  40. package/dist/cjs/Planned.d.ts +121 -0
  41. package/dist/cjs/Planned.d.ts.map +1 -0
  42. package/dist/cjs/Planned.js +72 -0
  43. package/dist/cjs/Planned.js.map +7 -0
  44. package/dist/cjs/Repetition.d.ts +31 -0
  45. package/dist/cjs/Repetition.d.ts.map +1 -0
  46. package/dist/cjs/Repetition.js +36 -0
  47. package/dist/cjs/Repetition.js.map +7 -0
  48. package/dist/cjs/Scheduling.d.ts +89 -0
  49. package/dist/cjs/Scheduling.d.ts.map +1 -0
  50. package/dist/cjs/Scheduling.js +76 -0
  51. package/dist/cjs/Scheduling.js.map +7 -0
  52. package/dist/cjs/StepKey.d.ts +333 -0
  53. package/dist/cjs/StepKey.d.ts.map +1 -0
  54. package/dist/cjs/StepKey.js +311 -0
  55. package/dist/cjs/StepKey.js.map +7 -0
  56. package/dist/cjs/index.d.ts +91 -0
  57. package/dist/cjs/index.d.ts.map +1 -0
  58. package/dist/cjs/index.js +59 -0
  59. package/dist/cjs/index.js.map +7 -0
  60. package/dist/cjs/internal/ConflictAnnotation.d.ts +59 -0
  61. package/dist/cjs/internal/ConflictAnnotation.d.ts.map +1 -0
  62. package/dist/cjs/internal/ConflictAnnotation.js +247 -0
  63. package/dist/cjs/internal/ConflictAnnotation.js.map +7 -0
  64. package/dist/cjs/internal/EffectCandidates.d.ts +19 -0
  65. package/dist/cjs/internal/EffectCandidates.d.ts.map +1 -0
  66. package/dist/cjs/internal/EffectCandidates.js +92 -0
  67. package/dist/cjs/internal/EffectCandidates.js.map +7 -0
  68. package/dist/cjs/internal/JsonMirror.d.ts +34 -0
  69. package/dist/cjs/internal/JsonMirror.d.ts.map +1 -0
  70. package/dist/cjs/internal/JsonMirror.js +195 -0
  71. package/dist/cjs/internal/JsonMirror.js.map +7 -0
  72. package/dist/cjs/internal/effects.d.ts +245 -0
  73. package/dist/cjs/internal/effects.d.ts.map +1 -0
  74. package/dist/cjs/internal/effects.js +290 -0
  75. package/dist/cjs/internal/effects.js.map +7 -0
  76. package/dist/cjs/internal/node.d.ts +526 -0
  77. package/dist/cjs/internal/node.d.ts.map +1 -0
  78. package/dist/cjs/internal/node.js +312 -0
  79. package/dist/cjs/internal/node.js.map +7 -0
  80. package/dist/cjs/package.json +1 -0
  81. package/dist/cjs/test/PlanFixtures.d.ts +127 -0
  82. package/dist/cjs/test/PlanFixtures.d.ts.map +1 -0
  83. package/dist/cjs/test/PlanFixtures.js +59 -0
  84. package/dist/cjs/test/PlanFixtures.js.map +7 -0
  85. package/dist/esm/CachePolicy.d.ts +131 -0
  86. package/dist/esm/CachePolicy.d.ts.map +1 -0
  87. package/dist/esm/CachePolicy.js +117 -0
  88. package/dist/esm/CachePolicy.js.map +1 -0
  89. package/dist/esm/Effects.d.ts +240 -0
  90. package/dist/esm/Effects.d.ts.map +1 -0
  91. package/dist/esm/Effects.js +167 -0
  92. package/dist/esm/Effects.js.map +1 -0
  93. package/dist/esm/FileSet.d.ts +288 -0
  94. package/dist/esm/FileSet.d.ts.map +1 -0
  95. package/dist/esm/FileSet.js +324 -0
  96. package/dist/esm/FileSet.js.map +1 -0
  97. package/dist/esm/GraphBuildError.d.ts +122 -0
  98. package/dist/esm/GraphBuildError.d.ts.map +1 -0
  99. package/dist/esm/GraphBuildError.js +146 -0
  100. package/dist/esm/GraphBuildError.js.map +1 -0
  101. package/dist/esm/KeyMaterial.d.ts +102 -0
  102. package/dist/esm/KeyMaterial.d.ts.map +1 -0
  103. package/dist/esm/KeyMaterial.js +85 -0
  104. package/dist/esm/KeyMaterial.js.map +1 -0
  105. package/dist/esm/Node.d.ts +606 -0
  106. package/dist/esm/Node.d.ts.map +1 -0
  107. package/dist/esm/Node.js +521 -0
  108. package/dist/esm/Node.js.map +1 -0
  109. package/dist/esm/Placement.d.ts +117 -0
  110. package/dist/esm/Placement.d.ts.map +1 -0
  111. package/dist/esm/Placement.js +65 -0
  112. package/dist/esm/Placement.js.map +1 -0
  113. package/dist/esm/Plan.d.ts +440 -0
  114. package/dist/esm/Plan.d.ts.map +1 -0
  115. package/dist/esm/Plan.js +673 -0
  116. package/dist/esm/Plan.js.map +1 -0
  117. package/dist/esm/PlanDiff.d.ts +58 -0
  118. package/dist/esm/PlanDiff.d.ts.map +1 -0
  119. package/dist/esm/PlanDiff.js +159 -0
  120. package/dist/esm/PlanDiff.js.map +1 -0
  121. package/dist/esm/Planned.d.ts +121 -0
  122. package/dist/esm/Planned.d.ts.map +1 -0
  123. package/dist/esm/Planned.js +147 -0
  124. package/dist/esm/Planned.js.map +1 -0
  125. package/dist/esm/Repetition.d.ts +31 -0
  126. package/dist/esm/Repetition.d.ts.map +1 -0
  127. package/dist/esm/Repetition.js +24 -0
  128. package/dist/esm/Repetition.js.map +1 -0
  129. package/dist/esm/Scheduling.d.ts +89 -0
  130. package/dist/esm/Scheduling.d.ts.map +1 -0
  131. package/dist/esm/Scheduling.js +82 -0
  132. package/dist/esm/Scheduling.js.map +1 -0
  133. package/dist/esm/StepKey.d.ts +333 -0
  134. package/dist/esm/StepKey.d.ts.map +1 -0
  135. package/dist/esm/StepKey.js +478 -0
  136. package/dist/esm/StepKey.js.map +1 -0
  137. package/dist/esm/index.d.ts +91 -0
  138. package/dist/esm/index.d.ts.map +1 -0
  139. package/dist/esm/index.js +91 -0
  140. package/dist/esm/index.js.map +1 -0
  141. package/dist/esm/internal/ConflictAnnotation.d.ts +59 -0
  142. package/dist/esm/internal/ConflictAnnotation.d.ts.map +1 -0
  143. package/dist/esm/internal/ConflictAnnotation.js +332 -0
  144. package/dist/esm/internal/ConflictAnnotation.js.map +1 -0
  145. package/dist/esm/internal/EffectCandidates.d.ts +19 -0
  146. package/dist/esm/internal/EffectCandidates.d.ts.map +1 -0
  147. package/dist/esm/internal/EffectCandidates.js +86 -0
  148. package/dist/esm/internal/EffectCandidates.js.map +1 -0
  149. package/dist/esm/internal/JsonMirror.d.ts +34 -0
  150. package/dist/esm/internal/JsonMirror.d.ts.map +1 -0
  151. package/dist/esm/internal/JsonMirror.js +204 -0
  152. package/dist/esm/internal/JsonMirror.js.map +1 -0
  153. package/dist/esm/internal/effects.d.ts +245 -0
  154. package/dist/esm/internal/effects.d.ts.map +1 -0
  155. package/dist/esm/internal/effects.js +459 -0
  156. package/dist/esm/internal/effects.js.map +1 -0
  157. package/dist/esm/internal/node.d.ts +526 -0
  158. package/dist/esm/internal/node.d.ts.map +1 -0
  159. package/dist/esm/internal/node.js +564 -0
  160. package/dist/esm/internal/node.js.map +1 -0
  161. package/dist/esm/test/PlanFixtures.d.ts +127 -0
  162. package/dist/esm/test/PlanFixtures.d.ts.map +1 -0
  163. package/dist/esm/test/PlanFixtures.js +60 -0
  164. package/dist/esm/test/PlanFixtures.js.map +1 -0
  165. package/docs/README.md +165 -0
  166. package/docs/api.md +1475 -0
  167. package/docs/concepts/authoring-ast.md +203 -0
  168. package/docs/concepts/effects-and-conflicts.md +135 -0
  169. package/docs/concepts/plan-value.md +168 -0
  170. package/docs/concepts/step-keys.md +179 -0
  171. package/docs/guides/author-a-node-graph.md +182 -0
  172. package/docs/guides/compile-a-plan.md +146 -0
  173. package/docs/guides/declare-file-effects.md +143 -0
  174. package/docs/guides/diff-two-plans.md +101 -0
  175. package/docs/installation.md +71 -0
  176. package/docs/quickstart.md +128 -0
  177. package/docs/testing.md +73 -0
  178. package/docs/troubleshooting.md +314 -0
  179. package/package.json +254 -3
  180. package/src/CachePolicy.ts +143 -0
  181. package/src/Effects.ts +284 -0
  182. package/src/FileSet.ts +411 -0
  183. package/src/GraphBuildError.ts +158 -0
  184. package/src/KeyMaterial.ts +106 -0
  185. package/src/Node.ts +871 -0
  186. package/src/Placement.ts +99 -0
  187. package/src/Plan.ts +868 -0
  188. package/src/PlanDiff.ts +199 -0
  189. package/src/Planned.ts +208 -0
  190. package/src/Repetition.ts +33 -0
  191. package/src/Scheduling.ts +154 -0
  192. package/src/StepKey.ts +710 -0
  193. package/src/index.ts +103 -0
  194. package/src/internal/ConflictAnnotation.ts +374 -0
  195. package/src/internal/EffectCandidates.ts +89 -0
  196. package/src/internal/JsonMirror.ts +255 -0
  197. package/src/internal/effects.ts +543 -0
  198. package/src/internal/node.ts +858 -0
  199. package/src/test/PlanFixtures.ts +76 -0
@@ -0,0 +1,199 @@
1
+ /**
2
+ * A plan comparison, as a value.
3
+ *
4
+ * A plan diff answers "which steps changed key (and _which input_
5
+ * changed them), what entered the envelope, what joined the release set —
6
+ * 'what is different about this run' is a set comparison, not archaeology."
7
+ * This module is the set comparison.
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.
13
+ *
14
+ * @since 0.1.0
15
+ */
16
+
17
+ import { jsonMirror } from "./internal/JsonMirror.ts"
18
+ import type * as Plan from "./Plan.ts"
19
+ import type * as Planned from "./Planned.ts"
20
+
21
+ /**
22
+ * A node whose key moved, with the fields that moved it.
23
+ *
24
+ * @since 0.1.0
25
+ * @category models
26
+ * @slop
27
+ */
28
+ export interface Rekeyed {
29
+ readonly id: string
30
+ readonly from: string
31
+ readonly to: string
32
+ /**
33
+ * Field labels such as `"kind"`, `"body"`, `"layers"`, `"capabilities"`,
34
+ * `"effects"`, and `"input[0]"` whose declaration differs. `"kind"` is the
35
+ * effect tier (`sealed`, `compensable`, or `irreversible`). A
36
+ * `Pending`-referenced upstream re-key is attributed to that input position
37
+ * too. `changed` is empty only when none of the compared fields moved.
38
+ */
39
+ readonly changed: ReadonlyArray<string>
40
+ }
41
+
42
+ /**
43
+ * What changed between two plans of the same flow.
44
+ *
45
+ * @since 0.1.0
46
+ * @category models
47
+ * @slop
48
+ */
49
+ export interface PlanDiff {
50
+ readonly added: ReadonlyArray<string>
51
+ readonly removed: ReadonlyArray<string>
52
+ readonly rekeyed: ReadonlyArray<Rekeyed>
53
+ readonly unchanged: ReadonlyArray<string>
54
+ }
55
+
56
+ /**
57
+ * Order-insensitive structural comparison for the attribution report only.
58
+ * Digests go through RFC 8785 canonical JSON; this does not have to, and
59
+ * saying so keeps the two from being confused.
60
+ *
61
+ * @private
62
+ */
63
+ const stable = (value: unknown): string => {
64
+ if (typeof value === "bigint") return `${value}n`
65
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "undefined"
66
+ if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`
67
+ const entries = Object.entries(value as Record<string, unknown>).sort(([left], [right]) => left < right ? -1 : 1)
68
+ return `{${entries.map(([name, entry]) => `${JSON.stringify(name)}:${stable(entry)}`).join(",")}}`
69
+ }
70
+
71
+ /**
72
+ * A planned value compares as the reference an AST stores for it, so the same
73
+ * reference at the same place is unchanged.
74
+ *
75
+ * @private
76
+ */
77
+ const plannedReference = (_value: unknown, reference: Planned.Reference) => ({
78
+ _tag: "PlannedReference",
79
+ node: reference.node,
80
+ path: reference.path
81
+ })
82
+
83
+ /** @private */
84
+ type ComparisonToken = string | symbol
85
+
86
+ /**
87
+ * Projects compared fields through the key's JSON semantics. A refused value
88
+ * receives an identity token scoped to its node and field, so the same value
89
+ * remains equal to itself without equating two distinct refused values.
90
+ *
91
+ * @private
92
+ */
93
+ const fieldComparison = () => {
94
+ const refused = new Map<unknown, Map<string, symbol>>()
95
+ const token = (input: unknown, nodeId: string, field: string): ComparisonToken => {
96
+ try {
97
+ return stable(jsonMirror(input, plannedReference))
98
+ } catch {
99
+ const address = JSON.stringify([nodeId, field])
100
+ let fields = refused.get(input)
101
+ if (fields === undefined) {
102
+ fields = new Map()
103
+ refused.set(input, fields)
104
+ }
105
+ let sentinel = fields.get(address)
106
+ if (sentinel === undefined) {
107
+ sentinel = Symbol(address)
108
+ fields.set(address, sentinel)
109
+ }
110
+ return sentinel
111
+ }
112
+ }
113
+ return (previous: unknown, next: unknown, nodeId: string, field: string): boolean =>
114
+ token(previous, nodeId, field) === token(next, nodeId, field)
115
+ }
116
+
117
+ /** @private */
118
+ const changedFields = (
119
+ previous: Plan.PlanNode,
120
+ next: Plan.PlanNode,
121
+ rekeyedDependencies: ReadonlySet<string>,
122
+ sameField: ReturnType<typeof fieldComparison>
123
+ ): ReadonlyArray<string> => {
124
+ const changed: Array<string> = []
125
+ // StepKey.materialBody is the identity list this attribution must mirror.
126
+ // Inputs, layers, and capabilities complete fromKeyMaterial's hashed value,
127
+ // and material.kind selects the key namespace (content versus tier-bearing
128
+ // plan declaration), so a tier-only move still re-keys and is blamed here.
129
+ if (!sameField(previous.material.version, next.material.version, next.id, "version")) changed.push("version")
130
+ if (previous.material.kind !== next.material.kind) changed.push("kind")
131
+ if (!sameField(previous.material.body, next.material.body, next.id, "body")) changed.push("body")
132
+ if (!sameField(previous.material.nondeterministic, next.material.nondeterministic, next.id, "nondeterministic")) {
133
+ changed.push("nondeterministic")
134
+ }
135
+ if (!sameField(previous.material.effects, next.material.effects, next.id, "effects")) changed.push("effects")
136
+ if (!sameField(previous.material.placement, next.material.placement, next.id, "placement")) {
137
+ changed.push("placement")
138
+ }
139
+ const width = Math.max(previous.material.inputs.length, next.material.inputs.length)
140
+ for (let index = 0; index < width; index++) {
141
+ const before = previous.material.inputs[index]
142
+ const after = next.material.inputs[index]
143
+ const field = `input[${index}]`
144
+ if (before === undefined || after === undefined || !sameField(before, after, next.id, field)) {
145
+ changed.push(field)
146
+ continue
147
+ }
148
+ if (after._tag !== "Literal" && rekeyedDependencies.has(after.from)) changed.push(field)
149
+ }
150
+ if (!sameField(previous.material.layers, next.material.layers, next.id, "layers")) changed.push("layers")
151
+ if (!sameField(previous.material.capabilities, next.material.capabilities, next.id, "capabilities")) {
152
+ changed.push("capabilities")
153
+ }
154
+ return changed
155
+ }
156
+
157
+ /**
158
+ * Compares a plan against the last plan for the same flow.
159
+ *
160
+ * @since 0.1.0
161
+ * @category constructors
162
+ * @slop
163
+ */
164
+ export const diff = (previous: Plan.Plan, next: Plan.Plan): PlanDiff => {
165
+ const before = new Map(previous.nodes.map((node) => [node.id, node]))
166
+ const after = new Map(next.nodes.map((node) => [node.id, node]))
167
+ const rekeyedIds = new Set<string>()
168
+ for (const node of next.nodes) {
169
+ const original = before.get(node.id)
170
+ if (original !== undefined && original.key !== node.key) rekeyedIds.add(node.id)
171
+ }
172
+ const added: Array<string> = []
173
+ const rekeyed: Array<Rekeyed> = []
174
+ const unchanged: Array<string> = []
175
+ const sameField = fieldComparison()
176
+ for (const node of next.nodes) {
177
+ const original = before.get(node.id)
178
+ if (original === undefined) {
179
+ added.push(node.id)
180
+ continue
181
+ }
182
+ if (original.key === node.key) {
183
+ unchanged.push(node.id)
184
+ continue
185
+ }
186
+ rekeyed.push({
187
+ id: node.id,
188
+ from: original.key,
189
+ to: node.key,
190
+ changed: changedFields(original, node, rekeyedIds, sameField)
191
+ })
192
+ }
193
+ return {
194
+ added,
195
+ removed: previous.nodes.filter((node) => !after.has(node.id)).map((node) => node.id),
196
+ rekeyed,
197
+ unchanged
198
+ }
199
+ }
package/src/Planned.ts ADDED
@@ -0,0 +1,208 @@
1
+ /**
2
+ * The placeholder a body sees where a step result will be.
3
+ *
4
+ * A flow body runs at PLAN time, so its
5
+ * own payload is real data while every step result is a value that does not
6
+ * exist yet. That value has a name and one rule: a planned value may be **passed** — into a payload field,
7
+ * into a branch, into a map — and field access is allowed, because it records a
8
+ * reference path. It may never be **computed on**.
9
+ *
10
+ * {@link Planned} is branded, so arithmetic is a compile error.
11
+ * {@link make} returns a strict proxy
12
+ * whose `Symbol.toPrimitive`, `valueOf`, `toString`, `toJSON`, and call traps
13
+ * throw {@link module:GraphBuildError.GraphBuildError} rather than let a plan be
14
+ * built around `NaN` or `"[object Object]"`. The lenient proxy this adapts
15
+ * (`@smthrs/core`'s `Graph.plannedValue`) answered `toPrimitive` with a
16
+ * readable string, which is exactly the silent-wrong-plan path this module
17
+ * exists to close.
18
+ *
19
+ * JavaScript does not expose traps for `Boolean(value)` or strict identity
20
+ * (`value === other`), so those operations cannot be refused at run time.
21
+ * They reveal only proxy truthiness or identity and never the planned result;
22
+ * authors must use {@link module:Node.branch} for decisions on real values.
23
+ * Type-aware ESLint `@typescript-eslint/strict-boolean-expressions` rejects
24
+ * planned conditions, but does not reject explicit Boolean coercion or all
25
+ * reference comparisons. Those still require review.
26
+ *
27
+ * @since 0.1.0
28
+ */
29
+
30
+ import type * as Types from "effect/Types"
31
+ import { GraphBuildError } from "./GraphBuildError.ts"
32
+
33
+ /**
34
+ * The brand carried by every planned value, and the key its reference is read
35
+ * from. Interned with `Symbol.for` so a value that crossed a module boundary is
36
+ * still recognised. The interned symbol is a recognition aid, not a
37
+ * capability, so {@link reference} validates the value stored under it.
38
+ *
39
+ * @since 0.1.0
40
+ * @category symbols
41
+ * @slop
42
+ */
43
+ export const TypeId: unique symbol = Symbol.for("@smthrs/plan/Planned")
44
+
45
+ /**
46
+ * The type-level form of {@link TypeId}.
47
+ *
48
+ * @since 0.1.0
49
+ * @category symbols
50
+ * @slop
51
+ */
52
+ export type TypeId = typeof TypeId
53
+
54
+ /**
55
+ * What a planned value points at: the node that will produce the result, and
56
+ * the property path read from it. `path` is empty for the result itself.
57
+ *
58
+ * @since 0.1.0
59
+ * @category models
60
+ * @slop
61
+ */
62
+ export interface Reference {
63
+ readonly node: string
64
+ readonly path: ReadonlyArray<string>
65
+ }
66
+
67
+ /**
68
+ * A {@link Reference} plus the phantom type of the value it stands for, so
69
+ * `Planned<string>` and `Planned<number>` are not interchangeable.
70
+ *
71
+ * @since 0.1.0
72
+ * @category models
73
+ * @slop
74
+ */
75
+ export interface Identity<out T> extends Reference {
76
+ readonly _T: Types.Covariant<T>
77
+ }
78
+
79
+ /**
80
+ * A step result that has not been produced yet.
81
+ *
82
+ * The brand rejects arithmetic and ordinary value arguments. The mapped half keeps field
83
+ * access typed — `result.files` is a `Planned` of the field, which is what
84
+ * makes "pass it on, never compute on it" expressible rather than merely
85
+ * documented.
86
+ *
87
+ * @since 0.1.0
88
+ * @category models
89
+ * @slop
90
+ */
91
+ export type Planned<T> =
92
+ & { readonly [TypeId]: Identity<T> }
93
+ & ([T] extends [object] ? { readonly [K in keyof T]: Planned<T[K]> } : unknown)
94
+
95
+ /**
96
+ * The operations that are computation rather than reference, keyed by the
97
+ * property that performs them. `toJSON` is here because `JSON.stringify` reads
98
+ * it, and a plan that serialized a placeholder would look correct.
99
+ *
100
+ * @private
101
+ */
102
+ const refusals = new Map<PropertyKey, string>([
103
+ [Symbol.toPrimitive, "Symbol.toPrimitive"],
104
+ ["valueOf", "valueOf"],
105
+ ["toString", "toString"],
106
+ ["toJSON", "toJSON"]
107
+ ])
108
+
109
+ /** @private */
110
+ const describe = (reference: Reference): string =>
111
+ reference.path.length === 0 ? reference.node : `${reference.node}.${reference.path.join(".")}`
112
+
113
+ /**
114
+ * The one refusal this module raises. The guidance is spelled out rather than
115
+ * linked because the author reading it is inside a body and needs the
116
+ * correction on the spot.
117
+ *
118
+ * @private
119
+ */
120
+ const refuse = (reference: Reference, operation: string): GraphBuildError =>
121
+ new GraphBuildError({
122
+ code: "planned_value_computed",
123
+ node: reference.node,
124
+ path: reference.path,
125
+ message: `Planned value ${describe(reference)} was computed at plan time by ${operation}. ` +
126
+ "Use Node.map to compute, Node.branch to decide, or pass the value into a payload."
127
+ })
128
+
129
+ /**
130
+ * The proxy itself. `_T` of {@link Identity} is a phantom and never exists at
131
+ * run time, so the trap carries the plain {@link Reference}.
132
+ *
133
+ * @private
134
+ */
135
+ const placeholder = (reference: Reference): unknown => {
136
+ /* v8 ignore next -- a Proxy application always enters the `apply` trap below, so the callable target is unreachable by construction */
137
+ const target = (): undefined => undefined
138
+ return new Proxy(target, {
139
+ get: (_target, key) => {
140
+ if (key === TypeId) return reference
141
+ const refusal = refusals.get(key)
142
+ if (refusal !== undefined) throw refuse(reference, refusal)
143
+ // Not a thenable: a body that returns a planned value inside a promise
144
+ // must resolve to the placeholder, not hang awaiting a proxy.
145
+ if (key === "then") return undefined
146
+ return placeholder({ node: reference.node, path: [...reference.path, String(key)] })
147
+ },
148
+ apply: () => {
149
+ throw refuse(reference, "function application")
150
+ },
151
+ has: () => {
152
+ throw refuse(reference, "property existence (`in`)")
153
+ },
154
+ ownKeys: () => {
155
+ throw refuse(reference, "property enumeration")
156
+ }
157
+ })
158
+ }
159
+
160
+ /**
161
+ * Creates the strict placeholder standing for a node's result.
162
+ *
163
+ * Planning hands one of these to every builder that consumes an upstream
164
+ * value — a branch arm, a continuation — and reads the {@link Reference} back
165
+ * off whatever the builder passed it into.
166
+ *
167
+ * @since 0.1.0
168
+ * @category constructors
169
+ * @slop
170
+ */
171
+ export const make = <T>(node: string): Planned<T> => placeholder({ node, path: [] }) as Planned<T>
172
+
173
+ /** @private */
174
+ const carrier = (value: unknown): value is { readonly [TypeId]?: Reference } =>
175
+ (typeof value === "object" && value !== null) || typeof value === "function"
176
+
177
+ /** @private */
178
+ const validReference = (value: unknown): value is Reference =>
179
+ typeof value === "object" && value !== null &&
180
+ typeof (value as Reference).node === "string" && (value as Reference).node.length > 0 &&
181
+ Array.isArray((value as Reference).path) &&
182
+ Array.from((value as Reference).path).every((segment) => typeof segment === "string")
183
+
184
+ /**
185
+ * Reads the reference a planned value records, or `undefined` for anything
186
+ * else. An interned symbol is a recognition aid rather than a capability, so a
187
+ * forged carrier is accepted only when its node and path have the complete
188
+ * reference shape. This is how a payload is scanned for the upstream results
189
+ * it consumes.
190
+ *
191
+ * @since 0.1.0
192
+ * @category accessors
193
+ * @slop
194
+ */
195
+ export const reference = (value: unknown): Reference | undefined => {
196
+ if (!carrier(value)) return undefined
197
+ const candidate = value[TypeId]
198
+ return validReference(candidate) ? candidate : undefined
199
+ }
200
+
201
+ /**
202
+ * Checks whether a value is a planned placeholder.
203
+ *
204
+ * @since 0.1.0
205
+ * @category guards
206
+ * @slop
207
+ */
208
+ export const isPlanned = (value: unknown): value is Planned<unknown> => reference(value) !== undefined
@@ -0,0 +1,33 @@
1
+ /**
2
+ * What a bounded repetition does when it reaches its ceiling.
3
+ *
4
+ * This is the ONE ceiling vocabulary. `@smthrs/flow`'s `Poll.make` takes it as
5
+ * `onTimeout`, for the attempt a poll gives up on, and `@smthrs/patterns`'
6
+ * `Loop` takes it as `onMaxReached`, for the iteration a loop gives up on. It
7
+ * was the same two words written out in both packages, with no edge between
8
+ * them, so a third member added on one side would have been invisible to the
9
+ * other. It lives here because `@smthrs/plan` is the package both already
10
+ * depend on.
11
+ *
12
+ * @since 1.0.0-rc.0
13
+ */
14
+
15
+ import * as Schema from "effect/Schema"
16
+
17
+ /**
18
+ * What a bounded repetition answers with at its ceiling: `fail` raises the
19
+ * repetition's own typed failure, and `return-last` settles with the last
20
+ * value it produced.
21
+ *
22
+ * @category models
23
+ * @since 1.0.0-rc.0
24
+ */
25
+ export const AtCeiling = Schema.Literals(["fail", "return-last"])
26
+
27
+ /**
28
+ * The value form of {@link AtCeiling}.
29
+ *
30
+ * @category models
31
+ * @since 1.0.0-rc.0
32
+ */
33
+ export type AtCeiling = typeof AtCeiling.Type
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Shared ready-work admission policy. This module decides priority, aging,
3
+ * deterministic ties, and step/agent capacity. It does not decide which nodes
4
+ * are ready, evaluate branches, launch effects, or own execution lifetimes.
5
+ * Those decisions must use the compiled graph and the caller's activation
6
+ * state before presenting candidates here.
7
+ *
8
+ * @since 1.0.0
9
+ */
10
+
11
+ import type * as Plan from "./Plan.ts"
12
+
13
+ /**
14
+ * Admission limits, independent of graph width and provider capacity.
15
+ * Omitted limits are unbounded within JavaScript's safe-integer range.
16
+ *
17
+ * @since 1.0.0
18
+ * @category models
19
+ */
20
+ export interface Concurrency {
21
+ readonly steps?: number | undefined
22
+ readonly agents?: number | undefined
23
+ }
24
+
25
+ /**
26
+ * Currently held permits. Every agent also holds a step permit.
27
+ *
28
+ * @since 1.0.0
29
+ * @category models
30
+ */
31
+ export interface Usage {
32
+ readonly steps: number
33
+ readonly agents: number
34
+ }
35
+
36
+ /**
37
+ * The part of a plan node admission reads. Declared once here so the three
38
+ * places that name it, {@link Candidate}, {@link Admission} and
39
+ * {@link Policy.admit}, cannot drift apart.
40
+ */
41
+ type Schedulable = Pick<Plan.PlanNode, "id" | "kind" | "priority">
42
+
43
+ /**
44
+ * A ready node's scheduling state. `order` is its position in the compiled
45
+ * plan, not arrival order. `waited` counts capacity-constrained admission
46
+ * passes, not wall-clock time.
47
+ *
48
+ * @since 1.0.0
49
+ * @category models
50
+ */
51
+ export interface Candidate<N extends Schedulable = Plan.PlanNode> {
52
+ readonly node: N
53
+ readonly order: number
54
+ readonly waited: number
55
+ }
56
+
57
+ /**
58
+ * One admission decision. Deferred candidates carry their incremented age;
59
+ * admitted candidates retain their age for dispatch diagnostics. No input is
60
+ * mutated and neither array owns or freezes the caller's node values.
61
+ *
62
+ * @since 1.0.0
63
+ * @category models
64
+ */
65
+ export interface Admission<N extends Schedulable = Plan.PlanNode> {
66
+ readonly admitted: ReadonlyArray<Candidate<N>>
67
+ readonly deferred: ReadonlyArray<Candidate<N>>
68
+ readonly agents: number
69
+ }
70
+
71
+ /**
72
+ * Pure policy shared by execution coordinators.
73
+ *
74
+ * @since 1.0.0
75
+ * @category models
76
+ */
77
+ export interface Policy {
78
+ readonly limits: Usage
79
+ readonly admit: <N extends Schedulable>(
80
+ ready: ReadonlyArray<Candidate<N>>,
81
+ active: Usage
82
+ ) => Admission<N>
83
+ }
84
+
85
+ const integer = (name: string, value: number, minimum: number): number => {
86
+ if (!Number.isSafeInteger(value) || value < minimum) {
87
+ throw new RangeError(`${name} must be a ${minimum === 1 ? "positive" : "non-negative"} safe integer`)
88
+ }
89
+ return value
90
+ }
91
+
92
+ /**
93
+ * Snapshots validated limits. Admission orders by declared priority plus age,
94
+ * using exact integer arithmetic even when their sum exceeds MAX_SAFE_INTEGER.
95
+ * Ties preserve compiled plan order. An agent blocked by its subset cap does
96
+ * not prevent ordinary ready work from using spare step capacity.
97
+ *
98
+ * Invalid candidate/permit state is refused before any decision is returned.
99
+ * At MAX_SAFE_INTEGER the age saturates; a bounded plan cannot require that
100
+ * many capacity-constrained passes to drain finite work.
101
+ *
102
+ * @since 1.0.0
103
+ * @category constructors
104
+ */
105
+ export const make = (concurrency: Concurrency = {}): Policy => {
106
+ const limits = Object.freeze({
107
+ steps: integer("concurrency.steps", concurrency.steps ?? Number.MAX_SAFE_INTEGER, 1),
108
+ agents: integer("concurrency.agents", concurrency.agents ?? Number.MAX_SAFE_INTEGER, 1)
109
+ })
110
+ const admit: Policy["admit"] = (ready, active) => {
111
+ integer("active.steps", active.steps, 0)
112
+ integer("active.agents", active.agents, 0)
113
+ if (active.agents > active.steps) throw new RangeError("active.agents cannot exceed active.steps")
114
+ const ids = new Set<string>()
115
+ const positions = new Set<number>()
116
+ const ranked = ready.map((candidate) => {
117
+ const { node, order, waited } = candidate
118
+ integer("candidate.order", order, 0)
119
+ integer("candidate.waited", waited, 0)
120
+ if (!Number.isSafeInteger(node.priority)) throw new RangeError("candidate.priority must be a safe integer")
121
+ if (node.kind !== "step" && node.kind !== "agent" && node.kind !== "merge") {
122
+ throw new RangeError("invalid candidate kind")
123
+ }
124
+ if (typeof node.id !== "string" || node.id === "" || ids.has(node.id)) {
125
+ throw new RangeError("candidate ids must be non-empty and unique")
126
+ }
127
+ if (positions.has(order)) throw new RangeError("candidate plan positions must be unique")
128
+ ids.add(node.id)
129
+ positions.add(order)
130
+ return { candidate, priority: BigInt(node.priority) + BigInt(waited) }
131
+ })
132
+ ranked.sort((left, right) =>
133
+ left.priority === right.priority
134
+ ? left.candidate.order - right.candidate.order
135
+ : left.priority > right.priority
136
+ ? -1
137
+ : 1
138
+ )
139
+ const admitted: Array<typeof ready[number]> = []
140
+ const deferred: Array<typeof ready[number]> = []
141
+ let agents = 0
142
+ for (const { candidate } of ranked) {
143
+ const isAgent = candidate.node.kind === "agent"
144
+ if (admitted.length >= limits.steps - active.steps || (isAgent && agents >= limits.agents - active.agents)) {
145
+ deferred.push(Object.freeze({ ...candidate, waited: Math.min(Number.MAX_SAFE_INTEGER, candidate.waited + 1) }))
146
+ } else {
147
+ admitted.push(Object.freeze({ ...candidate }))
148
+ if (isAgent) agents++
149
+ }
150
+ }
151
+ return Object.freeze({ admitted: Object.freeze(admitted), deferred: Object.freeze(deferred), agents })
152
+ }
153
+ return Object.freeze({ limits, admit })
154
+ }