@smthrs/plan 0.0.0-stage → 1.0.0-rc.4
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.
- package/CHANGELOG.md +183 -0
- package/LICENSE +21 -0
- package/README.md +228 -2
- package/dist/cjs/CachePolicy.d.ts +131 -0
- package/dist/cjs/CachePolicy.d.ts.map +1 -0
- package/dist/cjs/CachePolicy.js +50 -0
- package/dist/cjs/CachePolicy.js.map +7 -0
- package/dist/cjs/Effects.d.ts +240 -0
- package/dist/cjs/Effects.d.ts.map +1 -0
- package/dist/cjs/Effects.js +76 -0
- package/dist/cjs/Effects.js.map +7 -0
- package/dist/cjs/FileSet.d.ts +288 -0
- package/dist/cjs/FileSet.d.ts.map +1 -0
- package/dist/cjs/FileSet.js +191 -0
- package/dist/cjs/FileSet.js.map +7 -0
- package/dist/cjs/GraphBuildError.d.ts +122 -0
- package/dist/cjs/GraphBuildError.d.ts.map +1 -0
- package/dist/cjs/GraphBuildError.js +71 -0
- package/dist/cjs/GraphBuildError.js.map +7 -0
- package/dist/cjs/KeyMaterial.d.ts +102 -0
- package/dist/cjs/KeyMaterial.d.ts.map +1 -0
- package/dist/cjs/KeyMaterial.js +64 -0
- package/dist/cjs/KeyMaterial.js.map +7 -0
- package/dist/cjs/Node.d.ts +606 -0
- package/dist/cjs/Node.d.ts.map +1 -0
- package/dist/cjs/Node.js +207 -0
- package/dist/cjs/Node.js.map +7 -0
- package/dist/cjs/Placement.d.ts +117 -0
- package/dist/cjs/Placement.d.ts.map +1 -0
- package/dist/cjs/Placement.js +46 -0
- package/dist/cjs/Placement.js.map +7 -0
- package/dist/cjs/Plan.d.ts +440 -0
- package/dist/cjs/Plan.d.ts.map +1 -0
- package/dist/cjs/Plan.js +508 -0
- package/dist/cjs/Plan.js.map +7 -0
- package/dist/cjs/PlanDiff.d.ts +58 -0
- package/dist/cjs/PlanDiff.d.ts.map +1 -0
- package/dist/cjs/PlanDiff.js +124 -0
- package/dist/cjs/PlanDiff.js.map +7 -0
- package/dist/cjs/Planned.d.ts +121 -0
- package/dist/cjs/Planned.d.ts.map +1 -0
- package/dist/cjs/Planned.js +72 -0
- package/dist/cjs/Planned.js.map +7 -0
- package/dist/cjs/Repetition.d.ts +31 -0
- package/dist/cjs/Repetition.d.ts.map +1 -0
- package/dist/cjs/Repetition.js +36 -0
- package/dist/cjs/Repetition.js.map +7 -0
- package/dist/cjs/Scheduling.d.ts +89 -0
- package/dist/cjs/Scheduling.d.ts.map +1 -0
- package/dist/cjs/Scheduling.js +76 -0
- package/dist/cjs/Scheduling.js.map +7 -0
- package/dist/cjs/StepKey.d.ts +333 -0
- package/dist/cjs/StepKey.d.ts.map +1 -0
- package/dist/cjs/StepKey.js +311 -0
- package/dist/cjs/StepKey.js.map +7 -0
- package/dist/cjs/index.d.ts +91 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +59 -0
- package/dist/cjs/index.js.map +7 -0
- package/dist/cjs/internal/ConflictAnnotation.d.ts +59 -0
- package/dist/cjs/internal/ConflictAnnotation.d.ts.map +1 -0
- package/dist/cjs/internal/ConflictAnnotation.js +247 -0
- package/dist/cjs/internal/ConflictAnnotation.js.map +7 -0
- package/dist/cjs/internal/EffectCandidates.d.ts +19 -0
- package/dist/cjs/internal/EffectCandidates.d.ts.map +1 -0
- package/dist/cjs/internal/EffectCandidates.js +92 -0
- package/dist/cjs/internal/EffectCandidates.js.map +7 -0
- package/dist/cjs/internal/JsonMirror.d.ts +34 -0
- package/dist/cjs/internal/JsonMirror.d.ts.map +1 -0
- package/dist/cjs/internal/JsonMirror.js +195 -0
- package/dist/cjs/internal/JsonMirror.js.map +7 -0
- package/dist/cjs/internal/effects.d.ts +245 -0
- package/dist/cjs/internal/effects.d.ts.map +1 -0
- package/dist/cjs/internal/effects.js +290 -0
- package/dist/cjs/internal/effects.js.map +7 -0
- package/dist/cjs/internal/node.d.ts +526 -0
- package/dist/cjs/internal/node.d.ts.map +1 -0
- package/dist/cjs/internal/node.js +312 -0
- package/dist/cjs/internal/node.js.map +7 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/test/PlanFixtures.d.ts +127 -0
- package/dist/cjs/test/PlanFixtures.d.ts.map +1 -0
- package/dist/cjs/test/PlanFixtures.js +59 -0
- package/dist/cjs/test/PlanFixtures.js.map +7 -0
- package/dist/esm/CachePolicy.d.ts +131 -0
- package/dist/esm/CachePolicy.d.ts.map +1 -0
- package/dist/esm/CachePolicy.js +117 -0
- package/dist/esm/CachePolicy.js.map +1 -0
- package/dist/esm/Effects.d.ts +240 -0
- package/dist/esm/Effects.d.ts.map +1 -0
- package/dist/esm/Effects.js +167 -0
- package/dist/esm/Effects.js.map +1 -0
- package/dist/esm/FileSet.d.ts +288 -0
- package/dist/esm/FileSet.d.ts.map +1 -0
- package/dist/esm/FileSet.js +324 -0
- package/dist/esm/FileSet.js.map +1 -0
- package/dist/esm/GraphBuildError.d.ts +122 -0
- package/dist/esm/GraphBuildError.d.ts.map +1 -0
- package/dist/esm/GraphBuildError.js +146 -0
- package/dist/esm/GraphBuildError.js.map +1 -0
- package/dist/esm/KeyMaterial.d.ts +102 -0
- package/dist/esm/KeyMaterial.d.ts.map +1 -0
- package/dist/esm/KeyMaterial.js +85 -0
- package/dist/esm/KeyMaterial.js.map +1 -0
- package/dist/esm/Node.d.ts +606 -0
- package/dist/esm/Node.d.ts.map +1 -0
- package/dist/esm/Node.js +521 -0
- package/dist/esm/Node.js.map +1 -0
- package/dist/esm/Placement.d.ts +117 -0
- package/dist/esm/Placement.d.ts.map +1 -0
- package/dist/esm/Placement.js +65 -0
- package/dist/esm/Placement.js.map +1 -0
- package/dist/esm/Plan.d.ts +440 -0
- package/dist/esm/Plan.d.ts.map +1 -0
- package/dist/esm/Plan.js +673 -0
- package/dist/esm/Plan.js.map +1 -0
- package/dist/esm/PlanDiff.d.ts +58 -0
- package/dist/esm/PlanDiff.d.ts.map +1 -0
- package/dist/esm/PlanDiff.js +159 -0
- package/dist/esm/PlanDiff.js.map +1 -0
- package/dist/esm/Planned.d.ts +121 -0
- package/dist/esm/Planned.d.ts.map +1 -0
- package/dist/esm/Planned.js +147 -0
- package/dist/esm/Planned.js.map +1 -0
- package/dist/esm/Repetition.d.ts +31 -0
- package/dist/esm/Repetition.d.ts.map +1 -0
- package/dist/esm/Repetition.js +24 -0
- package/dist/esm/Repetition.js.map +1 -0
- package/dist/esm/Scheduling.d.ts +89 -0
- package/dist/esm/Scheduling.d.ts.map +1 -0
- package/dist/esm/Scheduling.js +82 -0
- package/dist/esm/Scheduling.js.map +1 -0
- package/dist/esm/StepKey.d.ts +333 -0
- package/dist/esm/StepKey.d.ts.map +1 -0
- package/dist/esm/StepKey.js +478 -0
- package/dist/esm/StepKey.js.map +1 -0
- package/dist/esm/index.d.ts +91 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +91 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal/ConflictAnnotation.d.ts +59 -0
- package/dist/esm/internal/ConflictAnnotation.d.ts.map +1 -0
- package/dist/esm/internal/ConflictAnnotation.js +332 -0
- package/dist/esm/internal/ConflictAnnotation.js.map +1 -0
- package/dist/esm/internal/EffectCandidates.d.ts +19 -0
- package/dist/esm/internal/EffectCandidates.d.ts.map +1 -0
- package/dist/esm/internal/EffectCandidates.js +86 -0
- package/dist/esm/internal/EffectCandidates.js.map +1 -0
- package/dist/esm/internal/JsonMirror.d.ts +34 -0
- package/dist/esm/internal/JsonMirror.d.ts.map +1 -0
- package/dist/esm/internal/JsonMirror.js +204 -0
- package/dist/esm/internal/JsonMirror.js.map +1 -0
- package/dist/esm/internal/effects.d.ts +245 -0
- package/dist/esm/internal/effects.d.ts.map +1 -0
- package/dist/esm/internal/effects.js +459 -0
- package/dist/esm/internal/effects.js.map +1 -0
- package/dist/esm/internal/node.d.ts +526 -0
- package/dist/esm/internal/node.d.ts.map +1 -0
- package/dist/esm/internal/node.js +564 -0
- package/dist/esm/internal/node.js.map +1 -0
- package/dist/esm/test/PlanFixtures.d.ts +127 -0
- package/dist/esm/test/PlanFixtures.d.ts.map +1 -0
- package/dist/esm/test/PlanFixtures.js +60 -0
- package/dist/esm/test/PlanFixtures.js.map +1 -0
- package/docs/README.md +165 -0
- package/docs/api.md +1475 -0
- package/docs/concepts/authoring-ast.md +203 -0
- package/docs/concepts/effects-and-conflicts.md +135 -0
- package/docs/concepts/plan-value.md +168 -0
- package/docs/concepts/step-keys.md +179 -0
- package/docs/guides/author-a-node-graph.md +182 -0
- package/docs/guides/compile-a-plan.md +146 -0
- package/docs/guides/declare-file-effects.md +143 -0
- package/docs/guides/diff-two-plans.md +101 -0
- package/docs/installation.md +71 -0
- package/docs/quickstart.md +128 -0
- package/docs/testing.md +73 -0
- package/docs/troubleshooting.md +314 -0
- package/package.json +254 -3
- package/src/CachePolicy.ts +143 -0
- package/src/Effects.ts +284 -0
- package/src/FileSet.ts +411 -0
- package/src/GraphBuildError.ts +158 -0
- package/src/KeyMaterial.ts +106 -0
- package/src/Node.ts +871 -0
- package/src/Placement.ts +99 -0
- package/src/Plan.ts +868 -0
- package/src/PlanDiff.ts +199 -0
- package/src/Planned.ts +208 -0
- package/src/Repetition.ts +33 -0
- package/src/Scheduling.ts +154 -0
- package/src/StepKey.ts +710 -0
- package/src/index.ts +103 -0
- package/src/internal/ConflictAnnotation.ts +374 -0
- package/src/internal/EffectCandidates.ts +89 -0
- package/src/internal/JsonMirror.ts +255 -0
- package/src/internal/effects.ts +543 -0
- package/src/internal/node.ts +858 -0
- package/src/test/PlanFixtures.ts +76 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "The authoring AST"
|
|
3
|
+
description: "Node and Planned: how a flow body describes topology as pure data, why map transforms and branch decides, and how a closure survives as a digest."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 4
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
`Node` is the shape a flow body describes, and nothing more. Building a node
|
|
9
|
+
records an inspectable, closure-free, JSON-serializable description and executes
|
|
10
|
+
nothing. `Planned` is the strict placeholder that body sees wherever a step
|
|
11
|
+
result will be.
|
|
12
|
+
|
|
13
|
+
Together they are the layer above the persisted plan. Neither runs anything.
|
|
14
|
+
|
|
15
|
+
## Building a node runs nothing
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import * as Node from "@smthrs/plan/Node"
|
|
19
|
+
|
|
20
|
+
const greeting = Node.map(Node.succeed({ name: "world" }), (value) => value.name.toUpperCase())
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`greeting` is a value holding an AST. The mapper has not run, and `succeed` has
|
|
24
|
+
produced no string. The same split as a build system's action declaration: you
|
|
25
|
+
describe the work, and something else decides when to do it.
|
|
26
|
+
|
|
27
|
+
The AST has nine variants: `Succeed`, `Fail`, `All`, `Map`, `AndThen`,
|
|
28
|
+
`Branch`, `Catch`, `FlowCall`, and `ActionCall`. `Node.Ast` is the union, and
|
|
29
|
+
every node exposes it as `node.ast`.
|
|
30
|
+
|
|
31
|
+
## Requirements are phantom
|
|
32
|
+
|
|
33
|
+
A node carries Effect's requirement channel, `R`, and carries it as a phantom.
|
|
34
|
+
Nothing at plan time reads it: the AST, the graph built from it, its key
|
|
35
|
+
material, and every digest are identical whatever `R` says.
|
|
36
|
+
|
|
37
|
+
It exists so a value that names an implementation it does not carry, an action
|
|
38
|
+
call, can say so in its type, and so the place that finally has to run that
|
|
39
|
+
implementation can demand it. Building a plan stays requirement-free; only
|
|
40
|
+
executing one is not.
|
|
41
|
+
|
|
42
|
+
## Map transforms; branch decides
|
|
43
|
+
|
|
44
|
+
`Node.map` is transformation only. The function is digested, not run: it
|
|
45
|
+
executes later, on the real value. A `map` that decides what happens next is a
|
|
46
|
+
`branch` written wrongly.
|
|
47
|
+
|
|
48
|
+
`Node.branch` takes both arms and evaluates each once, symbolically, against a
|
|
49
|
+
`Planned` placeholder. The resulting ASTs are what the node stores, so a plan
|
|
50
|
+
shows the exit condition and both continuations before it runs. The predicate is
|
|
51
|
+
digested and evaluated at run time on the real value.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { Action } from "@smthrs/flow"
|
|
55
|
+
import * as Node from "@smthrs/plan/Node"
|
|
56
|
+
import type * as Planned from "@smthrs/plan/Planned"
|
|
57
|
+
import * as Schema from "effect/Schema"
|
|
58
|
+
|
|
59
|
+
const Draft = Action.make("docs/Draft", { payload: { topic: Schema.String }, success: Schema.String })
|
|
60
|
+
const Publish = Action.make("docs/Publish", {
|
|
61
|
+
payload: { text: Schema.String, urgent: Schema.Boolean },
|
|
62
|
+
success: Schema.String
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const article = Node.bindPlanned(
|
|
66
|
+
Draft.call({ topic: "durable plans" }),
|
|
67
|
+
(text: Planned.Planned<string>) =>
|
|
68
|
+
Node.branch(Node.succeed({ urgent: true }), {
|
|
69
|
+
if: (flags) => flags.urgent,
|
|
70
|
+
then: () => Publish.call({ text, urgent: true }),
|
|
71
|
+
else: () => Publish.call({ text, urgent: false })
|
|
72
|
+
})
|
|
73
|
+
)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Both arms contribute their requirements, because both arms are topology the plan
|
|
77
|
+
carries. A run takes one of them, but which one is not known until the predicate
|
|
78
|
+
sees the real value, so an execution has to be able to take either.
|
|
79
|
+
|
|
80
|
+
`Node.catch` is the same idea for failures: the protected graph and the failure
|
|
81
|
+
arm are both stored, and the arm is built once at plan time against a strict
|
|
82
|
+
planned error placeholder. With no schema the whole typed error channel is
|
|
83
|
+
handled; with one, only the values it accepts, and the remainder survives in the
|
|
84
|
+
resulting error type.
|
|
85
|
+
|
|
86
|
+
## There is no loop node
|
|
87
|
+
|
|
88
|
+
A plan is always a DAG, so this module has no loop and never will. Repetition
|
|
89
|
+
lives one level up, in what a flow settles with.
|
|
90
|
+
|
|
91
|
+
`Node.all`, `Node.race`, `Node.any`, and `Node.quorum` have the same boundary. Its width is fixed at plan time. Fanning out
|
|
92
|
+
over something a step discovered is not `all`: end the round and carry the list
|
|
93
|
+
in the next flow's payload, where it is real data.
|
|
94
|
+
|
|
95
|
+
## Planned values
|
|
96
|
+
|
|
97
|
+
A `Planned<T>` may be passed into a payload field, into a branch, or into a map,
|
|
98
|
+
and field access is allowed, because it records a reference path. It may never
|
|
99
|
+
be computed on.
|
|
100
|
+
|
|
101
|
+
Misuse fails twice. The type is branded, so arithmetic on a planned value is a
|
|
102
|
+
compile error. The proxy's `Symbol.toPrimitive`, `valueOf`, `toString`,
|
|
103
|
+
`toJSON`, application, `in`, and enumeration traps throw a `GraphBuildError`
|
|
104
|
+
rather than let a plan be built around `NaN` or `"[object Object]"`, which
|
|
105
|
+
catches template interpolation, `String(value)`, and `JSON.stringify` of a
|
|
106
|
+
payload holding one.
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
import * as Planned from "@smthrs/plan/Planned"
|
|
110
|
+
|
|
111
|
+
declare const files: Planned.Planned<{ readonly count: number }>
|
|
112
|
+
|
|
113
|
+
/** Records the path ["count"] and stays a placeholder. */
|
|
114
|
+
const count: Planned.Planned<number> = files.count
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Reading `files.count` is a reference. Interpolating it into a string is
|
|
118
|
+
computation, and the refusal names both the node and the recorded path, so the
|
|
119
|
+
message says which value in which body was misused.
|
|
120
|
+
|
|
121
|
+
JavaScript exposes no trap for `Boolean(value)` or strict identity, so those
|
|
122
|
+
cannot be refused at run time. They reveal only proxy truthiness or identity and
|
|
123
|
+
never the planned result. Use `Node.branch` for a decision on a real value.
|
|
124
|
+
|
|
125
|
+
`Planned.TypeId` is interned, so a value that crossed a module boundary is still
|
|
126
|
+
recognized. Interning is a recognition aid rather than a capability:
|
|
127
|
+
`Planned.reference` returns a reference only when the value stored under that
|
|
128
|
+
symbol has the complete `{node, path}` shape.
|
|
129
|
+
|
|
130
|
+
## Payloads become inert JSON
|
|
131
|
+
|
|
132
|
+
A payload is stored as its inert JSON mirror. A data-valued callable `toJSON` is
|
|
133
|
+
honored, so a `Date` or a `URL` keys as the value it serializes to rather than
|
|
134
|
+
as an empty object. A function or symbol member is dropped from an object and
|
|
135
|
+
becomes `null` in an array. Shared references and cycles clone as they were
|
|
136
|
+
written.
|
|
137
|
+
|
|
138
|
+
Accessors and unsupported prototypes without `toJSON` fail as `invalid_payload`.
|
|
139
|
+
A `toJSON` that returns its own receiver fails as `cyclic_payload` rather than
|
|
140
|
+
collapsing to an empty object. The clone and the input therefore key identically
|
|
141
|
+
or refuse together.
|
|
142
|
+
|
|
143
|
+
## Functions survive as digests
|
|
144
|
+
|
|
145
|
+
The functions an author writes, a mapper, a continuation, a branch predicate,
|
|
146
|
+
live in `WeakMap`s keyed by the AST node they belong to. The AST keeps only a
|
|
147
|
+
`FunctionIdentity` digest of the function's exact source.
|
|
148
|
+
|
|
149
|
+
Exact source matters, because whitespace inside a string literal is behavior.
|
|
150
|
+
|
|
151
|
+
A function whose inert captures were declared with `Node.capture` digests those
|
|
152
|
+
captures and gets deterministic identity. Every other function additionally
|
|
153
|
+
carries process-local, per-function entropy, so two indistinguishable closure
|
|
154
|
+
sources fail closed instead of sharing a cache key.
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
import * as Node from "@smthrs/plan/Node"
|
|
158
|
+
|
|
159
|
+
const suffixed = Node.capture({ suffix: "!" }, (value: string) => `${value}!`)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`capture` refuses a capture record nested past 256 levels with a path-bearing
|
|
163
|
+
error rather than overflowing the native stack, and refuses accessors, exotic
|
|
164
|
+
prototypes, symbols, and cycles for the same reason it refuses them in a
|
|
165
|
+
payload: an identity that cannot describe the function's behavior is worse than
|
|
166
|
+
no identity.
|
|
167
|
+
|
|
168
|
+
## Recognizing a node
|
|
169
|
+
|
|
170
|
+
`Node.isNode` recognizes a node this package built, by registration at
|
|
171
|
+
construction. It also recognizes a rehydrated node, an object sharing the node
|
|
172
|
+
prototype whose own `ast` is a well-formed AST, by that shape, because
|
|
173
|
+
[`@smthrs/flow`](/api/flow) hands an AST that crossed a serialization boundary
|
|
174
|
+
back as a node.
|
|
175
|
+
|
|
176
|
+
`Node.TypeId` is a public string any object can carry and counts for nothing on
|
|
177
|
+
its own. Every combinator that admits a node reads its `ast` as trusted
|
|
178
|
+
topology, so an object carrying the marker on any other prototype, one
|
|
179
|
+
inheriting it from a node, and one whose `ast` is missing, malformed, or cyclic
|
|
180
|
+
are all refused with the same `GraphBuildError` as any other non-node. A proxy
|
|
181
|
+
is judged by the shape it forwards.
|
|
182
|
+
|
|
183
|
+
## How a flow call joins the plan
|
|
184
|
+
|
|
185
|
+
`Node.flowCall` takes a mode. `inline` splices the callee's body into the
|
|
186
|
+
caller's plan, `boundary` makes the call one child execution, and `handoff`
|
|
187
|
+
names the next trampoline round.
|
|
188
|
+
|
|
189
|
+
The child call is `FlowCall{mode: "boundary"}` rather than a second AST tag
|
|
190
|
+
beside `FlowCall`. The three modes are one authoring construct, a call to a
|
|
191
|
+
named flow with a payload, differing only in how the plan joins it, and every
|
|
192
|
+
consumer (`Graph.build`'s expansion test, key material, the interpreter's
|
|
193
|
+
dispatch) already switches on `mode`. A parallel `ChildCall` tag would duplicate
|
|
194
|
+
the flow tag, payload, and declaration side table in every one of them.
|
|
195
|
+
|
|
196
|
+
## The engine members
|
|
197
|
+
|
|
198
|
+
`Node.flowCall`, `actionCall`, `declaration`, `continuation`, `mapper`,
|
|
199
|
+
`predicate`, `catchFilter`, and `functionIdentity` exist for
|
|
200
|
+
[`@smthrs/flow`](/api/flow), which owns flow and action authoring. They are
|
|
201
|
+
supported names, not authoring API: they validate nothing, and several of them
|
|
202
|
+
answer `undefined` for an AST that was rehydrated from JSON, because the side
|
|
203
|
+
tables holding the real functions do not survive serialization.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Declared effects and conflicts"
|
|
3
|
+
description: "How declared reads and writes become ordering: the overlap pass, the reader-after-writer pass, the three verdicts, and the cycles both passes refuse."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 3
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Every node declares what it does to the workspace, as paths. Two plan-time
|
|
9
|
+
passes read those declarations and turn them into edges, so a scheduler never
|
|
10
|
+
has to guess whether two pieces of work can run at once.
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import * as Plan from "@smthrs/plan/Plan"
|
|
14
|
+
|
|
15
|
+
const effects: Plan.NodeEffects = {
|
|
16
|
+
reads: [{ _tag: "Glob", include: ["src/**/*.ts"] }],
|
|
17
|
+
writes: ["dist/bundle.js"],
|
|
18
|
+
removes: ["dist/stale.js"],
|
|
19
|
+
boundaryMode: "hard"
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Bare string entries are literal paths at both overlap analysis and execution,
|
|
24
|
+
even when they contain `*` or `**`. Wildcards belong in `Glob.include` and
|
|
25
|
+
`Glob.exclude`.
|
|
26
|
+
|
|
27
|
+
Measuring a path is run-time work, so a digest here would break the no-I/O law
|
|
28
|
+
that makes a plan reproducible.
|
|
29
|
+
|
|
30
|
+
## What a node produces
|
|
31
|
+
|
|
32
|
+
`removes` is optional, and a removal mutates the world exactly as a write does.
|
|
33
|
+
Both passes therefore fold `writes` and `removes` into one produced set. A path
|
|
34
|
+
declared as both is refused as `invalid_effects`, because the declaration
|
|
35
|
+
contradicts itself.
|
|
36
|
+
|
|
37
|
+
`boundaryMode` is `hard` or `expected`. It travels with the declaration into the
|
|
38
|
+
measured boundary at execution time; the plan passes do not interpret it.
|
|
39
|
+
|
|
40
|
+
## The overlap pass
|
|
41
|
+
|
|
42
|
+
Declared write sets make overlap detectable before anything runs. The pass
|
|
43
|
+
compares every pair of producing nodes and skips a pair the graph already
|
|
44
|
+
orders, including one ordered through an ordering edge this same pass just
|
|
45
|
+
inferred. Every other overlapping pair is annotated on both members with the
|
|
46
|
+
resolved verdict.
|
|
47
|
+
|
|
48
|
+
| Verdict | Effect |
|
|
49
|
+
| ----------- | --------------------------------------------------------------------------------- |
|
|
50
|
+
| `serialize` | The default. The later writer gains an ordering edge. |
|
|
51
|
+
| `lane` | Both writers get lane annotations when either asks for one, and no ordering edge. |
|
|
52
|
+
| `fail` | `compile` fails with `overlap_forbidden`, for flows that promise disjointness. |
|
|
53
|
+
|
|
54
|
+
`fail` dominates `lane`, which dominates `serialize`. A flow that promised
|
|
55
|
+
disjointness must not be quietly serialized, and a pair where either side asked
|
|
56
|
+
for a lane must not be silently ordered.
|
|
57
|
+
|
|
58
|
+
Each annotation also carries the runtime strategy the pair resolved to, where
|
|
59
|
+
`stop-merge` dominates `delay-rebase`. That is what the scheduler does when the
|
|
60
|
+
predicted overlap actually bites.
|
|
61
|
+
|
|
62
|
+
A node states its own preferences on its draft, through `conflictStrategy` and
|
|
63
|
+
`runtimeStrategy`. The annotation is a property of the _pair_, not of one
|
|
64
|
+
declaration, which is why both preferences are recorded on the node and resolved
|
|
65
|
+
per pair.
|
|
66
|
+
|
|
67
|
+
Nodes frozen by an earlier generation are annotated on the new node only,
|
|
68
|
+
because their rows are append-only.
|
|
69
|
+
|
|
70
|
+
## The reader-after-writer pass
|
|
71
|
+
|
|
72
|
+
The overlap pass compares write sets against write sets, so a node that _reads_
|
|
73
|
+
a path another node _writes_ is ordered by nothing. Reader and writer could be
|
|
74
|
+
admitted in the same wavefront round; the reader would then measure pre-producer
|
|
75
|
+
bytes and, because the dispatch key honestly folds the digest it measured, cache
|
|
76
|
+
that wrong execution as a legitimate one.
|
|
77
|
+
|
|
78
|
+
The second pass closes that. A reader whose read set overlaps a producer's
|
|
79
|
+
produced set gains an ordering edge to the producer, unless the graph already
|
|
80
|
+
orders it there or an explicit `Ref`/`Pending` dependency path selects a read
|
|
81
|
+
before that writer.
|
|
82
|
+
|
|
83
|
+
Like a `serialize` edge, the edge enters `dependsOn` and never key material. The
|
|
84
|
+
reader computes the same result either way, and its content dependence is
|
|
85
|
+
already keyed by the hermetic boundary digests measured at dispatch.
|
|
86
|
+
|
|
87
|
+
## When an edge would close a cycle
|
|
88
|
+
|
|
89
|
+
An explicit `Ref`/`Pending` dependency path that orders the writer after the
|
|
90
|
+
reader is accepted, including a transitive path. It selects earlier bytes: the
|
|
91
|
+
initial source or an earlier producer's output. The pass preserves that order
|
|
92
|
+
instead of adding a reverse edge.
|
|
93
|
+
|
|
94
|
+
If only inferred producer or `serialize` edges order the writer after the
|
|
95
|
+
reader, there is no explicit selection of earlier bytes. Adding the required
|
|
96
|
+
reader-after-writer edge closes a cycle, so the plan is refused with `cycle`.
|
|
97
|
+
The message names the reader, the overlapping paths, the producer, and the
|
|
98
|
+
contradictory dependency chain. For example, if the chain contains inferred
|
|
99
|
+
edges and no declared-only path selects the earlier read:
|
|
100
|
+
|
|
101
|
+
```text
|
|
102
|
+
Plan cycle: node lint reads dist/bundle.js, which node bundle produces, so lint must
|
|
103
|
+
follow bundle, but bundle already depends on lint through bundle -> report -> lint
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Reachability through declared `Ref`/`Pending` edges establishes explicit
|
|
107
|
+
sequencing. Declaration order alone does not select earlier bytes.
|
|
108
|
+
|
|
109
|
+
## Overlap is conservative
|
|
110
|
+
|
|
111
|
+
`FileSet.overlaps` answers `true` whenever two declarations _might_ share a
|
|
112
|
+
path. Over-serializing costs latency; under-serializing costs correctness, so
|
|
113
|
+
the comparison is deliberately asymmetric: `false` proves that no path can
|
|
114
|
+
belong to both declarations.
|
|
115
|
+
|
|
116
|
+
Two globs always overlap, and so do a glob and a tree artifact, because
|
|
117
|
+
comparing pattern languages exactly is not worth the risk of being wrong.
|
|
118
|
+
|
|
119
|
+
Exact paths compare in canonical form: every separator rewritten to `/` and
|
|
120
|
+
Unicode normalized to NFC. Without that, `dist\bundle.js` and `dist/bundle.js`
|
|
121
|
+
would be two spellings of one file that the overlap check could not see, which
|
|
122
|
+
is the same class of hole that `.` segments and empty segments would open.
|
|
123
|
+
[Declare the files a node touches](../guides/declare-file-effects.md) covers the
|
|
124
|
+
vocabulary and the forms `workspaceRelative` refuses.
|
|
125
|
+
|
|
126
|
+
## What this buys the scheduler
|
|
127
|
+
|
|
128
|
+
A plan arrives at the scheduler with every predictable conflict already
|
|
129
|
+
resolved into an edge or an annotation. Two nodes with no path between them and
|
|
130
|
+
no annotation between them are safe to run at once, and that fact was
|
|
131
|
+
established before a single byte was read.
|
|
132
|
+
|
|
133
|
+
The annotations that remain describe the cases a plan cannot resolve alone: two
|
|
134
|
+
lane writers that a runtime has to keep apart, and the runtime strategy each
|
|
135
|
+
pair agreed on if the predicted overlap turns real.
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "The plan value"
|
|
3
|
+
description: "What a compiled plan is: a keyed graph, two digests, a generation counter, and a snapshot nothing can mutate after the fact."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 1
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
A plan is the whole of what a run intends to do, computed before anything runs
|
|
9
|
+
and expressed as one value. It is a `Plan` struct: an id, the flow it was
|
|
10
|
+
compiled for, a generation counter, two digests, and an ordered list of keyed
|
|
11
|
+
nodes.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import * as Plan from "@smthrs/plan/Plan"
|
|
15
|
+
|
|
16
|
+
declare const plan: Plan.Plan
|
|
17
|
+
plan.planId // "review-4821"
|
|
18
|
+
plan.flow // "example/Review"
|
|
19
|
+
plan.generation // 0
|
|
20
|
+
plan.baseDigest // the digest a human approved
|
|
21
|
+
plan.digest // the digest as of this generation
|
|
22
|
+
plan.nodes // every keyed node, in material-dependency order
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Everything else in this package either produces that value, persists it,
|
|
26
|
+
compares two of them, or supplies the vocabulary its fields are written in.
|
|
27
|
+
|
|
28
|
+
## The four rules
|
|
29
|
+
|
|
30
|
+
Four properties shape every design decision here. Read them once and most of the
|
|
31
|
+
API stops being surprising.
|
|
32
|
+
|
|
33
|
+
### Planning demands nothing
|
|
34
|
+
|
|
35
|
+
A `Node` carries Effect's requirement channel, `R`, as a phantom. No combinator
|
|
36
|
+
reads it, so building a graph asks for no service at all. The channel states
|
|
37
|
+
which implementations running the plan will need, and the place that runs it is
|
|
38
|
+
where the compiler asks for them.
|
|
39
|
+
|
|
40
|
+
`Plan.compile` is the one exception, and a narrow one: it asks for Effect's
|
|
41
|
+
`Crypto` service, because it hashes. It asks for nothing else.
|
|
42
|
+
|
|
43
|
+
### Planning performs no I/O
|
|
44
|
+
|
|
45
|
+
Nothing here reads a file, a clock, or a network. A node's declared `effects`
|
|
46
|
+
carry read and write _paths_, never digests, because measuring a path is the
|
|
47
|
+
scheduler's run-time job. That is what makes a plan reproducible: compile the
|
|
48
|
+
same drafts twice, on two machines, and you get the same keys and the same
|
|
49
|
+
digest.
|
|
50
|
+
|
|
51
|
+
### Invalidation is re-keying
|
|
52
|
+
|
|
53
|
+
A node's key is a function of what it consumes, so an edited declaration
|
|
54
|
+
re-keys that node and its dependent cone and nothing else. There is deliberately
|
|
55
|
+
no reverse-dependency index and no invalidating node visitor in this package,
|
|
56
|
+
and there must never be one: content addressing subsumes both. [Step keys](./step-keys.md) covers what a key folds in.
|
|
57
|
+
|
|
58
|
+
### A plan grows; it is never rewritten
|
|
59
|
+
|
|
60
|
+
`Plan.append` adds a pre-keyed subgraph at the next generation. Recorded nodes
|
|
61
|
+
keep their id, key, edges, and generation byte for byte, and the SQL raises
|
|
62
|
+
rather than letting a caller update or delete one. Re-ordering after a
|
|
63
|
+
reconciliation happens by re-keying _future_ steps, never by rewriting history.
|
|
64
|
+
|
|
65
|
+
## Nodes
|
|
66
|
+
|
|
67
|
+
Within each generation, `plan.nodes` follows the topological order of material dependencies.
|
|
68
|
+
Inferred reader-after-writer edges can point to later array entries. Schedule
|
|
69
|
+
nodes from the complete `dependsOn` graph.
|
|
70
|
+
|
|
71
|
+
Each entry of `plan.nodes` is a `PlanNode`:
|
|
72
|
+
|
|
73
|
+
| Field | What it holds |
|
|
74
|
+
| ------------ | ------------------------------------------------------------------------------ |
|
|
75
|
+
| `id` | The durable lookup address. Never hashed, so renaming a node re-keys nothing. |
|
|
76
|
+
| `kind` | `step`, `agent`, or `merge`. |
|
|
77
|
+
| `key` | The computed step key, in the same `key1_` format the engine dispatches under. |
|
|
78
|
+
| `material` | The declaration the key was derived from. |
|
|
79
|
+
| `effects` | The reads, writes, removals, and boundary mode this node declares. |
|
|
80
|
+
| `dependsOn` | The scheduling edge set, including dependencies on later array entries. |
|
|
81
|
+
| `conflicts` | One annotation per overlapping writer no dependency path already orders. |
|
|
82
|
+
| `strategy` | This declaration's preferred plan-time verdict for an overlap. |
|
|
83
|
+
| `runtime` | This declaration's preferred response when a predicted overlap actually bites. |
|
|
84
|
+
| `priority` | The scheduler's tie-break among ready work. Higher runs first. |
|
|
85
|
+
| `generation` | Which append added this node. Generation 0 is what `compile` produced. |
|
|
86
|
+
|
|
87
|
+
`dependsOn` is wider than the material references alone. It also carries the
|
|
88
|
+
ordering edge a `serialize` verdict added and the reader-after-writer edges that
|
|
89
|
+
put a node behind whoever produces the paths it reads. Ordering edges are
|
|
90
|
+
deliberately not key material: a node serialized behind another still computes
|
|
91
|
+
the same result, so re-keying it would throw away a legitimate cache hit.
|
|
92
|
+
[Declared effects and conflicts](./effects-and-conflicts.md) covers both passes.
|
|
93
|
+
|
|
94
|
+
`priority` is not key material either, for the same reason. It changes latency
|
|
95
|
+
and nothing else.
|
|
96
|
+
|
|
97
|
+
## Two digests
|
|
98
|
+
|
|
99
|
+
`baseDigest` is the digest at generation 0: what a human approved and what a
|
|
100
|
+
running run pins. `digest` advances with every appended elaboration. A control
|
|
101
|
+
plane binds an approval to `baseDigest`, so a plan that grew during the run
|
|
102
|
+
still validates against the decision that admitted it.
|
|
103
|
+
|
|
104
|
+
Both digests cover node identity, every computed key, the edge set, the conflict
|
|
105
|
+
annotations, the declared effects, the priority, and each node's own conflict
|
|
106
|
+
and runtime strategies. They do not cover anything presentational, and they do
|
|
107
|
+
not cover a diff's attribution report.
|
|
108
|
+
|
|
109
|
+
The command line prints the same projection: [`smthrs flow plan`](/cli/flow)
|
|
110
|
+
renders the plan card and the approval payload that
|
|
111
|
+
[`smthrs approvals approve`](/cli/approvals) and `smthrs flow execute` accept.
|
|
112
|
+
|
|
113
|
+
## Generations
|
|
114
|
+
|
|
115
|
+
`compile` produces generation 0. Every `append` produces the next one, and
|
|
116
|
+
`Plan.generationNodes` reads back only the nodes the newest generation added,
|
|
117
|
+
which is exactly what [`@smthrs/plan-store`](/api/plan-store)'s `PlanStore.append` inserts.
|
|
118
|
+
|
|
119
|
+
A generation exists because plans elaborate. A step that discovers the shape of
|
|
120
|
+
its own follow-on work cannot state that work up front, so the plan grows to
|
|
121
|
+
hold it. What it must never do is change what an operator already saw, which is
|
|
122
|
+
why growth is the only move available.
|
|
123
|
+
|
|
124
|
+
## An immutable snapshot
|
|
125
|
+
|
|
126
|
+
A compiled plan is a deep-frozen snapshot of the drafts it was given. Material
|
|
127
|
+
is stored as the inert JSON mirror its key already covers, so a `Date`, a `URL`,
|
|
128
|
+
or any value with a data-valued callable `toJSON` is stored as the value it
|
|
129
|
+
serializes to. Mutating a caller's draft after compiling cannot change the plan,
|
|
130
|
+
its keys, or its digest.
|
|
131
|
+
|
|
132
|
+
A material accessor, or a prototype with no JSON representation, is refused as
|
|
133
|
+
`invalid_node` naming the node and the payload path rather than stored by
|
|
134
|
+
reference. A `Planned` placeholder is left intact so canonical serialization
|
|
135
|
+
still refuses it.
|
|
136
|
+
|
|
137
|
+
## Payloads are plaintext
|
|
138
|
+
|
|
139
|
+
Material is identity: the body and inputs of a node are hashed into its step
|
|
140
|
+
key and written verbatim as plaintext into `node_json` and the approval card an
|
|
141
|
+
operator reads. This package never redacts them, and redacting after hashing
|
|
142
|
+
would break `Plan.verify`. Keep credentials out of payloads. A secret belongs in
|
|
143
|
+
a layer, a capability, or the environment, resolved at dispatch.
|
|
144
|
+
|
|
145
|
+
## Where a plan comes from
|
|
146
|
+
|
|
147
|
+
`Plan.compile` takes `NodeDraft` values. Flow authors do not write those by
|
|
148
|
+
hand: [`@smthrs/flow`](/api/flow) walks a flow body into drafts through
|
|
149
|
+
`Graph.build` and `Graph.drafts`, and hands them here. `Plan.NodeDraft` is the
|
|
150
|
+
type in the middle, and it is the whole contract between the two packages.
|
|
151
|
+
|
|
152
|
+
[Author a node graph](../guides/author-a-node-graph.md) walks that seam.
|
|
153
|
+
|
|
154
|
+
## Bounds
|
|
155
|
+
|
|
156
|
+
One compiled plan holds at most `Plan.maximumPlanNodes` nodes, which is 10,000.
|
|
157
|
+
A plan above the ceiling is refused with `graph_too_large` before effect
|
|
158
|
+
analysis. Analysis also refuses more than 250,000 candidate pairs or 10,000,000
|
|
159
|
+
work units, including overlap comparisons, bitset merges and graph traversal.
|
|
160
|
+
This budget applies across all generations replayed by `verify`. Split plans
|
|
161
|
+
that exceed either budget across flow boundaries.
|
|
162
|
+
|
|
163
|
+
Reachability is cached in bitsets and updated when ordering edges are inferred.
|
|
164
|
+
Analysis yields periodically so cancellation and other fibers can run. Imported
|
|
165
|
+
plans reuse one effect expansion map and candidate index across generations.
|
|
166
|
+
|
|
167
|
+
Compilation itself walks with explicit stacks and never recurses per edge, so
|
|
168
|
+
depth is a data structure rather than native stack frames.
|