@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
package/docs/api.md
ADDED
|
@@ -0,0 +1,1475 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "API reference"
|
|
3
|
+
description: "Every public export of @smthrs/plan: the authoring AST, the planned placeholder, key material, placement, the step-key compiler, the plan value, and its diff."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
`@smthrs/plan` exports thirteen modules from its root entry point, and each is also
|
|
7
|
+
importable from `@smthrs/plan/<Module>`:
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { FileSet, Node, Plan, PlanDiff } from "@smthrs/plan"
|
|
11
|
+
// or
|
|
12
|
+
import * as Plan from "@smthrs/plan/Plan"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`@smthrs/plan/internal/*` and `@smthrs/plan/*/index` are not public.
|
|
16
|
+
`@smthrs/plan/package.json` is exported.
|
|
17
|
+
|
|
18
|
+
| Namespace | What it is |
|
|
19
|
+
| ----------------- | --------------------------------------------------------------------------------------------------------- |
|
|
20
|
+
| `Node` | The pure, pipeable authoring AST: `succeed`, `all`, `map`, `andThen`, `branch`, `catch`, `priority`. |
|
|
21
|
+
| `Planned` | The strict placeholder a body sees where a step result will be, and the reference it records. |
|
|
22
|
+
| `GraphBuildError` | The refusals a plan-time build raises instead of producing a wrong plan. |
|
|
23
|
+
| `FileSet` | The static filesystem vocabulary: patterns, globs, tree artifacts, filegroups, and overlap. |
|
|
24
|
+
| `Effects` | The one effect envelope model: declare, narrow a step against an envelope, find write overlap. |
|
|
25
|
+
| `KeyMaterial` | What a planner declares about a node: body, tagged input references, layers, capabilities, effects. |
|
|
26
|
+
| `StepKey` | The compiler that turns material plus resolved dependency digests into a [`@smthrs/keys`](/api/keys) key. |
|
|
27
|
+
| `Plan` | `compile`, `append`, the node and conflict schemas, and the digest an approval binds to. |
|
|
28
|
+
| `PlanDiff` | A plan comparison as a value: added, removed, re-keyed with attribution, unchanged. |
|
|
29
|
+
| `Placement` | Where a node runs: the four directives, their host-selection detail, and the annotation key. |
|
|
30
|
+
| `Scheduling` | The pure admission policy a scheduler consults: concurrency, priority order, and the halt rule. |
|
|
31
|
+
| `CachePolicy` | How far a recorded sealed result travels, how long it stays servable, and the key it rides under. |
|
|
32
|
+
| `Repetition` | What a bounded repetition answers with at its ceiling. |
|
|
33
|
+
|
|
34
|
+
The shortest composition that reaches every layer:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import * as NodeCrypto from "@effect/platform-node/NodeCrypto"
|
|
38
|
+
import { Plan, PlanDiff } from "@smthrs/plan"
|
|
39
|
+
import * as Effect from "effect/Effect"
|
|
40
|
+
|
|
41
|
+
const program = Effect.gen(function*() {
|
|
42
|
+
const plan = yield* Plan.compile({
|
|
43
|
+
planId: "review-4821",
|
|
44
|
+
flow: "example/Review",
|
|
45
|
+
nodes: [
|
|
46
|
+
{
|
|
47
|
+
id: "read-pr",
|
|
48
|
+
material: {
|
|
49
|
+
version: "flows/key-material/v2",
|
|
50
|
+
kind: "sealed",
|
|
51
|
+
body: { action: "read-pr", pr: 4821 },
|
|
52
|
+
inputs: [],
|
|
53
|
+
layers: [],
|
|
54
|
+
capabilities: ["net:get"]
|
|
55
|
+
},
|
|
56
|
+
effects: { reads: [], writes: ["pr.json"], boundaryMode: "hard" }
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: "run-tests",
|
|
60
|
+
material: {
|
|
61
|
+
version: "flows/key-material/v2",
|
|
62
|
+
kind: "sealed",
|
|
63
|
+
body: { action: "run-tests" },
|
|
64
|
+
inputs: [{ _tag: "Ref", from: "read-pr", path: [] }],
|
|
65
|
+
layers: [],
|
|
66
|
+
capabilities: []
|
|
67
|
+
},
|
|
68
|
+
effects: { reads: ["pr.json"], writes: ["report.json"], boundaryMode: "hard" }
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
return PlanDiff.diff(plan, plan)
|
|
74
|
+
}).pipe(Effect.provide(NodeCrypto.layer))
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Compiling needs Effect's `Crypto` service and nothing else. The package depends on [`@smthrs/crypto`](/api/crypto), [`@smthrs/canonical`](/api/canonical), [`@smthrs/keys`](/api/keys), and `effect`, performs no I/O, and is browser-safe. Persisting a compiled plan is [`@smthrs/plan-store`](/api/plan-store), which is where the database dependency lives.
|
|
78
|
+
|
|
79
|
+
## Entry point
|
|
80
|
+
|
|
81
|
+
| Import | Source | Platform |
|
|
82
|
+
| -------------- | ---------------------------------------------------------------------------------------------------------- | ---------------- |
|
|
83
|
+
| `@smthrs/plan` | [src/index.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/plan/src/index.ts) | Node and browser |
|
|
84
|
+
|
|
85
|
+
## KeyMaterial
|
|
86
|
+
|
|
87
|
+
[src/KeyMaterial.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/plan/src/KeyMaterial.ts)
|
|
88
|
+
|
|
89
|
+
What a planner declares about one node, handed to the compiler: a `version`, a tier `kind`, an optional `nondeterministic` flag, an opaque `body`, an ordered list of `InputRef`s, `layers`, `capabilities`, and opaque `effects` and `placement`.
|
|
90
|
+
|
|
91
|
+
`kind` is `sealed`, `compensable`, or `irreversible`. Absence of `nondeterministic` claims determinism; only the explicit declaration changes identity. The `InputRef` tag is hashed, so `Pending{from}` and `Ref{from, path: []}` cannot collide even though both resolve to the same dependency digest.
|
|
92
|
+
|
|
93
|
+
`dependencies` is the single derivation of a node's edge set, so a hashed reference and an edge can never disagree. `StepKey` canonically serializes `effects` and `placement` and never interprets them, which keeps the key compiler independent of whatever the flow builder decides an effect declaration looks like. `Plan.compile` is stricter: it decodes `NodeDraft.effects` through `NodeEffects` and writes the result into `material.effects`, replacing anything a caller put there. That makes the draft declaration the single derivation point for effect identity, so a node's key cannot disagree with the effects its conflict annotations and approval payload were computed from.
|
|
94
|
+
|
|
95
|
+
### KeyMaterial.InputRef
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
type InputRef =
|
|
99
|
+
| { readonly _tag: "Literal"; readonly value: unknown }
|
|
100
|
+
| { readonly _tag: "Ref"; readonly from: string; readonly path: ReadonlyArray<string> }
|
|
101
|
+
| { readonly _tag: "Pending"; readonly from: string }
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
One declared input. `Literal` is hashed inline. `Ref` names an upstream node and the property path read off its result. `Pending` names an upstream node without consuming its value, which is an ordering reference. `from` is a non-empty string. Exported as both a schema and a type.
|
|
105
|
+
|
|
106
|
+
### KeyMaterial.KeyMaterial
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
const KeyMaterial: Schema.Struct<{
|
|
110
|
+
version: Schema.Literal<"flows/key-material/v2">
|
|
111
|
+
kind: Schema.Literals<["sealed", "compensable", "irreversible"]>
|
|
112
|
+
nondeterministic: Schema.optional<Schema.Literal<true>>
|
|
113
|
+
body: Schema.Unknown
|
|
114
|
+
inputs: Schema.Array$<typeof InputRef>
|
|
115
|
+
layers: Schema.Array$<Schema.String>
|
|
116
|
+
capabilities: Schema.Array$<Schema.String>
|
|
117
|
+
effects: Schema.optional<Schema.Unknown>
|
|
118
|
+
placement: Schema.optional<Schema.Unknown>
|
|
119
|
+
}>
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Everything that can change a node's result. Exported as both a schema and a type.
|
|
123
|
+
|
|
124
|
+
### KeyMaterial.version
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
const version: "flows/key-material/v2"
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The material version, folded into every hashed body so a bump re-keys every node derived from it.
|
|
131
|
+
|
|
132
|
+
### KeyMaterial.dependencies
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
const dependencies: (material: KeyMaterial) => ReadonlyArray<string>
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The graph-local dependencies a material names, in declaration order and without duplicates. `Literal` inputs contribute nothing. `Plan.compile` uses the result as the node's edge set.
|
|
139
|
+
|
|
140
|
+
## Scheduling
|
|
141
|
+
|
|
142
|
+
`Scheduling.make(concurrency?)` creates a pure admission policy. Import it from
|
|
143
|
+
`@smthrs/plan/Scheduling` or the package's `Scheduling` namespace. The optional
|
|
144
|
+
`steps` and `agents` limits must be positive safe integers; omission is
|
|
145
|
+
unbounded within the safe-integer range. Construction snapshots the limits.
|
|
146
|
+
|
|
147
|
+
`policy.admit(ready, active)` takes candidates `{ node, order, waited }` and
|
|
148
|
+
the current permit usage `{ steps, agents }`. Each node supplies its unique
|
|
149
|
+
`id`, `kind` (`step`, `agent`, or `merge`), and safe-integer `priority`.
|
|
150
|
+
`order` is its unique non-negative position in the compiled plan, not arrival
|
|
151
|
+
order. `waited` is its non-negative count of capacity-constrained admission
|
|
152
|
+
passes. An agent consumes both a step permit and an agent permit.
|
|
153
|
+
|
|
154
|
+
The result contains immutable `admitted` and `deferred` candidate arrays plus
|
|
155
|
+
the number of newly admitted `agents`. Only deferred candidates receive an
|
|
156
|
+
incremented age; input candidates and node values are not mutated. Age
|
|
157
|
+
saturates at `Number.MAX_SAFE_INTEGER`. Exact priority-plus-age comparison
|
|
158
|
+
prevents floating-point rounding from changing the ordering. Equal scores
|
|
159
|
+
preserve plan order; a blocked agent does not prevent a regular node from using
|
|
160
|
+
available step capacity. Invalid counts, duplicate IDs/positions, or invalid
|
|
161
|
+
priorities are refused synchronously with `RangeError`.
|
|
162
|
+
|
|
163
|
+
This policy is used by `@smthrs/engine-store`'s durable plan scheduler. It does
|
|
164
|
+
not determine readiness, evaluate branches, launch nodes, or cancel effects.
|
|
165
|
+
The caller must supply only ready candidates and apply the returned ages before
|
|
166
|
+
its next admission pass. Public interpreter/compiled scheduling parity is not
|
|
167
|
+
provided by this policy alone.
|
|
168
|
+
|
|
169
|
+
## StepKey
|
|
170
|
+
|
|
171
|
+
[src/StepKey.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/plan/src/StepKey.ts)
|
|
172
|
+
|
|
173
|
+
The compiler from material to a [`@smthrs/keys`](/api/keys) `Key`. `planIdentity` substitutes each `Ref` and `Pending` for the already-computed key of the referenced node. Sealed material retains the `fromKeyMaterial` content-key format; other tiers use a separate declaration namespace. `ordinal` mints the deliberately run-local key of compensable, irreversible, or unsealed work, whose `tier` is one of those three words.
|
|
174
|
+
|
|
175
|
+
Structural node ids do not enter declaration fingerprints. Non-cacheable execution keys also need a run-local structural scope to distinguish repeated identical effects. Only `sealed` material may become a content key, so `fromKeyMaterial` fails `non_content_material` for the other two tiers. A dependency digest is resolved as an own property, so a `Ref` naming `toString` or `constructor` is a `missing_dependency` refusal rather than a colliding key.
|
|
176
|
+
|
|
177
|
+
The brand behind `digestInput` is private, so a plain object that merely has a `digest` field hashes as a literal. That closes a collision where shape sniffing hashed a genuine upstream-result reference and an ordinary content hash identically.
|
|
178
|
+
|
|
179
|
+
`environment` is hashed in its own namespace rather than merged into the caller's declarations, so `caller{fs:["a"]} + env{fs:["b"]}` cannot alias `caller{fs:["a","b"]} + env{}`. Environment layers keep declaration order because composition order can change behavior; caller-owned layers are set-normalized. `EnvironmentIdentity` is a discriminated union: a declared environment carries no `runScope`, and an undeclared one must carry a non-empty one, pinning the key to a single run so a step whose environment identity is unknown never serves a cross-run hit. Both `content` and `dispatchIdentity` enforce that at run time with `invalid_environment`.
|
|
180
|
+
|
|
181
|
+
`project` is the one projection semantics for the value channel. It resolves only own data properties, so a path segment that is missing, inherited, or an accessor yields `undefined` and no getter runs during key derivation.
|
|
182
|
+
|
|
183
|
+
A `DigestMemo` shares one in-flight projected-value digest between concurrent callers. A waiter never inherits the leader's interruption: if the leader's fiber is cancelled, the waiter recomputes as the new leader.
|
|
184
|
+
|
|
185
|
+
### StepKey.StepKey
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
type StepKey = StoredKey
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
A computed step key: `key1_` plus a SHA-256 digest of the canonical serialization of the material. Identical in representation to every other flow key, which is what lets the engine dispatch under it.
|
|
192
|
+
|
|
193
|
+
### StepKey.ContentIdentity
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
interface ContentIdentity {
|
|
197
|
+
readonly body: unknown
|
|
198
|
+
readonly inputs: Readonly<Record<string, unknown | DigestInput>>
|
|
199
|
+
readonly layers: ReadonlyArray<string>
|
|
200
|
+
readonly capabilities: Readonly<Record<string, ReadonlyArray<string>>>
|
|
201
|
+
readonly environment?: EnvironmentIdentity | undefined
|
|
202
|
+
readonly hermetic?: {
|
|
203
|
+
readonly readSet: ReadonlyArray<{ readonly path: string; readonly digest: string }>
|
|
204
|
+
readonly writeSet: ReadonlyArray<FileSet.Entry>
|
|
205
|
+
readonly removes?: ReadonlyArray<string> | undefined
|
|
206
|
+
readonly boundaryMode: "hard" | "expected"
|
|
207
|
+
} | undefined
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Material describing a sealed or hermetic content-addressed step. `layers` and each capability group are set-normalized before hashing; `hermetic.readSet` is sorted and deduplicated by path and digest, and `hermetic.writeSet` by entry.
|
|
212
|
+
|
|
213
|
+
### StepKey.OrdinalIdentity
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
interface OrdinalIdentity {
|
|
217
|
+
readonly runId: string
|
|
218
|
+
readonly parentScope?: string | undefined
|
|
219
|
+
readonly ordinal: number
|
|
220
|
+
readonly tier: "compensable" | "irreversible" | "unsealed"
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Material describing the run-local identity of a non-cacheable step.
|
|
225
|
+
|
|
226
|
+
### StepKey.EnvironmentIdentity
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
type EnvironmentIdentity =
|
|
230
|
+
| {
|
|
231
|
+
readonly declared: true
|
|
232
|
+
readonly layers: ReadonlyArray<string>
|
|
233
|
+
readonly capabilities: Readonly<Record<string, ReadonlyArray<string>>>
|
|
234
|
+
readonly runScope?: undefined
|
|
235
|
+
}
|
|
236
|
+
| {
|
|
237
|
+
readonly declared: false
|
|
238
|
+
readonly layers: ReadonlyArray<string>
|
|
239
|
+
readonly capabilities: Readonly<Record<string, ReadonlyArray<string>>>
|
|
240
|
+
readonly runScope: string
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
The engine-resolved execution environment a content key is computed under.
|
|
245
|
+
`EnvironmentIdentity` is also a runtime schema; its TypeScript type is derived
|
|
246
|
+
from that schema. `content`, `dispatchIdentity`, and `environmentIdentity`
|
|
247
|
+
validate the entire shape, reject unknown fields, and return typed
|
|
248
|
+
`invalid_environment` errors for invalid identities rather than defects.
|
|
249
|
+
|
|
250
|
+
### StepKey.environmentIdentity
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
const environmentIdentity: (
|
|
254
|
+
environment?: EnvironmentIdentity
|
|
255
|
+
) => Effect.Effect<StoredKey, KeyMaterialError | Schema.SchemaError, Crypto.Crypto>
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
A fingerprint for binding a durable execution to its runtime environment,
|
|
259
|
+
not an action dispatch key. It has a separate `execution-environment/v1`
|
|
260
|
+
namespace and uses the same normalization as content/dispatch keys: layer
|
|
261
|
+
order and duplicates are significant; layer strings and capability patterns
|
|
262
|
+
normalize to NFC; capability-pattern sets sort and deduplicate. Capability
|
|
263
|
+
group names retain their exact spelling. An omitted environment, declared-empty
|
|
264
|
+
environment, and undeclared run-scoped environment remain distinct.
|
|
265
|
+
|
|
266
|
+
`PlanScheduler` snapshots the identity at construction and stores this
|
|
267
|
+
fingerprint with `PlanInputStore` before dispatch. Changed identities cannot
|
|
268
|
+
resume the same run. This does not change existing valid action-key material
|
|
269
|
+
or detect implementation changes missing from the declared identity.
|
|
270
|
+
|
|
271
|
+
### StepKey.DigestInput
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
interface DigestInput {
|
|
275
|
+
readonly digest: string
|
|
276
|
+
readonly reference?: "ref" | "pending" | "ref-projected"
|
|
277
|
+
readonly path?: ReadonlyArray<string>
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
A precomputed digest supplied as a step input rather than a literal value. It also carries a private brand, which is the whole point: only `digestInput` can produce one.
|
|
282
|
+
|
|
283
|
+
### StepKey.digestInput
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
const digestInput: (
|
|
287
|
+
digest: string,
|
|
288
|
+
reference?: {
|
|
289
|
+
readonly reference: "ref" | "pending" | "ref-projected"
|
|
290
|
+
readonly path?: ReadonlyArray<string>
|
|
291
|
+
}
|
|
292
|
+
) => DigestInput
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
Nominally tags a precomputed digest so it is hashed as a digest reference rather than a literal value.
|
|
296
|
+
|
|
297
|
+
### StepKey.isDigestInput
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
const isDigestInput: (value: unknown) => value is DigestInput
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Type guard for values produced by `digestInput`. It reads the private brand, so a plain `{digest: "..."}` answers `false`.
|
|
304
|
+
|
|
305
|
+
### StepKey.DigestMemo
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
interface DigestMemo {
|
|
309
|
+
readonly digest: (
|
|
310
|
+
from: string,
|
|
311
|
+
path: ReadonlyArray<string>,
|
|
312
|
+
compute: Effect.Effect<StepKey, Schema.SchemaError, Crypto.Crypto>
|
|
313
|
+
) => Effect.Effect<StepKey, Schema.SchemaError, Crypto.Crypto>
|
|
314
|
+
}
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Caller-owned memoization context for projected dependency-value digests, addressed by the JSON-encoded `[from, path]` tuple. Entries are sound only while each settled `from` value is immutable, so create a fresh memo when those values can change.
|
|
318
|
+
|
|
319
|
+
### StepKey.makeDigestMemo
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
const makeDigestMemo: () => DigestMemo
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
Creates an empty memo.
|
|
326
|
+
|
|
327
|
+
### StepKey.content
|
|
328
|
+
|
|
329
|
+
```ts
|
|
330
|
+
const content: (
|
|
331
|
+
identity: ContentIdentity
|
|
332
|
+
) => Effect.Effect<StepKey, KeyMaterialError | Schema.SchemaError, Crypto.Crypto>
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
Produces a cross-run reusable key. Set-like declarations are normalized before serialization; write declarations remain part of the identity even when the step writes nothing.
|
|
336
|
+
|
|
337
|
+
### StepKey.ordinal
|
|
338
|
+
|
|
339
|
+
```ts
|
|
340
|
+
const ordinal: (
|
|
341
|
+
identity: OrdinalIdentity
|
|
342
|
+
) => Effect.Effect<StepKey, Schema.SchemaError, Crypto.Crypto>
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
Produces a run-local key for compensable, irreversible, or unsealed work. These keys intentionally cannot be reused across runs.
|
|
346
|
+
|
|
347
|
+
### StepKey.planIdentity
|
|
348
|
+
|
|
349
|
+
```ts
|
|
350
|
+
const planIdentity: (
|
|
351
|
+
material: KeyMaterial.KeyMaterial,
|
|
352
|
+
dependencyDigests: Readonly<Record<string, string>>
|
|
353
|
+
) => Effect.Effect<StepKey, KeyMaterialError | Schema.SchemaError, Crypto.Crypto>
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
The declaration fingerprint used by `Plan.compile`, `append`, and `verify`.
|
|
357
|
+
Accepts every effect tier. Non-sealed fingerprints use a distinct, tier-bearing
|
|
358
|
+
namespace and are not cross-run cache keys. Dependency references must resolve
|
|
359
|
+
to own string-valued properties, as with `fromKeyMaterial`.
|
|
360
|
+
|
|
361
|
+
### StepKey.fromKeyMaterial
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
const fromKeyMaterial: (
|
|
365
|
+
material: KeyMaterial.KeyMaterial,
|
|
366
|
+
dependencyDigests: Readonly<Record<string, string>>
|
|
367
|
+
) => Effect.Effect<StepKey, KeyMaterialError | Schema.SchemaError, Crypto.Crypto>
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
The sealed plan key: resolves graph-local references against `dependencyDigests`, which must hold an own string-valued property for every node the material names, then builds a content key. Fails `non_content_material` unless `material.kind` is `sealed`. Use `planIdentity` for a tier-independent compiler.
|
|
371
|
+
|
|
372
|
+
### StepKey.dispatchIdentity
|
|
373
|
+
|
|
374
|
+
```ts
|
|
375
|
+
const dispatchIdentity: (options: {
|
|
376
|
+
readonly material: KeyMaterial.KeyMaterial
|
|
377
|
+
readonly results: Readonly<Record<string, unknown>>
|
|
378
|
+
readonly hermetic: NonNullable<ContentIdentity["hermetic"]>
|
|
379
|
+
readonly environment?: EnvironmentIdentity | undefined
|
|
380
|
+
readonly digestMemo?: DigestMemo | undefined
|
|
381
|
+
}) => Effect.Effect<StepKey, KeyMaterialError | Schema.SchemaError, Crypto.Crypto>
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
The key a dispatch is _cached_ under, as distinct from the plan key a node is _identified_ by.
|
|
385
|
+
|
|
386
|
+
A plan key folds the resolved keys of every upstream node, transitively, so an edit anywhere upstream re-keys everything below it, even when the edited node's output value is byte for byte what it was before. This derivation folds the node's own material and never an upstream key. Each input contributes content instead: a `Literal` its value, a `Ref` the digest of the settled result of `from` projected along `path`, and a `Pending` nothing beyond its tag. The measured hermetic boundary is folded unchanged.
|
|
387
|
+
|
|
388
|
+
`results` must hold every dependency the material names. The scheduler's halt rule guarantees it: a dependent of failed or skipped work never dispatches, so a `Ref` always resolves against a success.
|
|
389
|
+
|
|
390
|
+
### StepKey.project
|
|
391
|
+
|
|
392
|
+
```ts
|
|
393
|
+
const project: (value: unknown, path: ReadonlyArray<string>) => unknown
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
Projects a settled result along a `Ref` path. Only own data properties resolve, so a missing, inherited, or accessor segment yields `undefined` without invoking a getter. A projection that walks off the end of a result is a fact about the graph, not a failure: `undefined` drops out of the canonical form, so it hashes distinctly from every JSON value including `null`.
|
|
397
|
+
|
|
398
|
+
This is exported because it is the one projection semantics for the value channel. Every consumer that resolves a `Ref` at execution time must resolve it this way, or two inputs that key identically could be consumed differently.
|
|
399
|
+
|
|
400
|
+
### StepKey.KeyMaterialError
|
|
401
|
+
|
|
402
|
+
```ts
|
|
403
|
+
class KeyMaterialError extends Schema.TaggedError<KeyMaterialError>()("@smthrs/plan/KeyMaterialError", {
|
|
404
|
+
code: Schema.Literals(["invalid_environment", "missing_dependency", "non_content_material"])
|
|
405
|
+
message: Schema.String
|
|
406
|
+
})
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
Stable failures while resolving graph-local dependency references.
|
|
410
|
+
|
|
411
|
+
## Plan
|
|
412
|
+
|
|
413
|
+
[src/Plan.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/plan/src/Plan.ts)
|
|
414
|
+
|
|
415
|
+
`compile` puts drafts in topological order of material dependencies, substitutes dependency digests, annotates write-set overlaps, and derives the plan digest. `append` adds a pre-keyed subgraph at the next generation, and `generationNodes` reads back the nodes the newest generation added.
|
|
416
|
+
|
|
417
|
+
Inferred edges extend `dependsOn` without reordering `plan.nodes`, so a reader can precede its writer in the array. Schedule nodes from the complete `dependsOn` graph.
|
|
418
|
+
|
|
419
|
+
`PlanNode.kind` is `step`, `agent`, or `merge`. `dependsOn` is the edge set: material references, ordering edges from `serialize`, and inferred reader-after-writer edges. Explicit `Ref`/`Pending` dependency paths select the version being read: a read before a writer consumes the initial source or an earlier producer's output, not that later writer's output. Otherwise, the compiler orders a reader after overlapping writers. A contradictory inferred ordering still fails with `cycle`; the diagnostic names both nodes, overlapping paths, and the dependency chain. Ordering edges are deliberately not key material: file content enters dispatch identity through boundary digests. `NodeEffects` carries `reads`, `writes`, optional `removes`, and `boundaryMode` (`hard` or `expected`). Removals participate in ordering just like writes.
|
|
420
|
+
|
|
421
|
+
Planning performs no I/O. Declared effects carry read and write _paths_, never digests, because measuring a path is run-time work. A node's key is a function of what it consumes, so an edited declaration re-keys that node and its dependent cone and nothing else. That is the entire invalidation mechanism: there is no reverse-dependency index and no invalidating node visitor, because content addressing subsumes both.
|
|
422
|
+
|
|
423
|
+
A plan grows and is never rewritten. `append` leaves the nodes already in it with their id, key, edges, and generation byte for byte, and the new nodes arrive pre-keyed against them. Re-ordering after a reconciliation happens by re-keying future steps.
|
|
424
|
+
|
|
425
|
+
`baseDigest` is the digest at generation 0: what a human approved and what a running run pins. `digest` advances with every appended elaboration. Both cover node identity, every computed key, the edge set, the conflict annotations, the declared effects, the priority, and each node's own conflict and runtime strategies.
|
|
426
|
+
|
|
427
|
+
A compiled plan is a deep-frozen snapshot of the drafts it was given. Material is stored as the inert JSON mirror its key already covers, so a `Date`, a `URL`, or any value with a data-valued callable `toJSON` is stored as the value it serializes to, and mutating a caller's draft after compiling cannot change the plan, its keys, or its digest. A material accessor, or a prototype with no JSON representation, is refused as `invalid_node` naming the node and the payload path rather than stored by reference. A `Planned` placeholder is left intact so canonical serialization still refuses it.
|
|
428
|
+
|
|
429
|
+
### Plan.NodeEffects
|
|
430
|
+
|
|
431
|
+
```ts
|
|
432
|
+
const NodeEffects: Schema.Struct<{
|
|
433
|
+
reads: Schema.Array$<typeof FileSet.ReadDeclaration>
|
|
434
|
+
writes: Schema.Array$<typeof FileSet.Declaration>
|
|
435
|
+
removes: Schema.optional<Schema.Array$<typeof FileSet.Pattern>>
|
|
436
|
+
boundaryMode: Schema.Literals<["hard", "expected"]>
|
|
437
|
+
}>
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
What a node does to the world, declared as paths. Exported as both a schema and a type.
|
|
441
|
+
|
|
442
|
+
### Plan.PairStrategy
|
|
443
|
+
|
|
444
|
+
```ts
|
|
445
|
+
type PairStrategy = "serialize" | "lane" | "fail"
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
The plan-time verdict for one overlapping pair of writers. Exported as both a schema and a type.
|
|
449
|
+
|
|
450
|
+
### Plan.RuntimeStrategy
|
|
451
|
+
|
|
452
|
+
```ts
|
|
453
|
+
type RuntimeStrategy = "delay-rebase" | "stop-merge"
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
What the scheduler does when a predicted overlap actually bites. Exported as both a schema and a type.
|
|
457
|
+
|
|
458
|
+
### Plan.ConflictAnnotation
|
|
459
|
+
|
|
460
|
+
```ts
|
|
461
|
+
const ConflictAnnotation: Schema.Struct<{
|
|
462
|
+
with: Schema.NonEmptyString
|
|
463
|
+
paths: Schema.Array$<Schema.String>
|
|
464
|
+
strategy: typeof PairStrategy
|
|
465
|
+
runtime: typeof RuntimeStrategy
|
|
466
|
+
}>
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
One resolved overlap between two writers that no dependency path already orders. Conflict is a property of the pair, not of one declaration. Exported as both a schema and a type.
|
|
470
|
+
|
|
471
|
+
### Plan.PlanNode
|
|
472
|
+
|
|
473
|
+
```ts
|
|
474
|
+
const PlanNode: Schema.Struct<{
|
|
475
|
+
id: Schema.NonEmptyString
|
|
476
|
+
kind: Schema.Literals<["step", "agent", "merge"]>
|
|
477
|
+
key: typeof StoredKey
|
|
478
|
+
material: typeof KeyMaterial.KeyMaterial
|
|
479
|
+
effects: typeof NodeEffects
|
|
480
|
+
dependsOn: Schema.Array$<Schema.NonEmptyString>
|
|
481
|
+
conflicts: Schema.Array$<typeof ConflictAnnotation>
|
|
482
|
+
strategy: typeof PairStrategy
|
|
483
|
+
runtime: typeof RuntimeStrategy
|
|
484
|
+
priority: Schema.Int
|
|
485
|
+
generation: Schema.Int
|
|
486
|
+
}>
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
A keyed node of the plan. `strategy` and `runtime` are this declaration's own preferences, recorded so a later elaboration can resolve a pair against them without re-reading the flow source. Exported as both a schema and a type.
|
|
490
|
+
|
|
491
|
+
### Plan.Plan
|
|
492
|
+
|
|
493
|
+
```ts
|
|
494
|
+
const Plan: Schema.Struct<{
|
|
495
|
+
planId: Schema.NonEmptyString
|
|
496
|
+
flow: Schema.NonEmptyString
|
|
497
|
+
generation: Schema.Int
|
|
498
|
+
baseDigest: typeof StoredKey
|
|
499
|
+
digest: typeof StoredKey
|
|
500
|
+
nodes: Schema.Array$<typeof PlanNode>
|
|
501
|
+
}>
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
The whole keyed graph plus the digest an approval binds to. Exported as both a schema and a type.
|
|
505
|
+
|
|
506
|
+
### Plan.NodeDraft
|
|
507
|
+
|
|
508
|
+
```ts
|
|
509
|
+
interface NodeDraft {
|
|
510
|
+
readonly id: string
|
|
511
|
+
readonly material: KeyMaterial.KeyMaterial
|
|
512
|
+
readonly effects: NodeEffects
|
|
513
|
+
readonly kind?: PlanNode["kind"] | undefined
|
|
514
|
+
readonly priority?: number | undefined
|
|
515
|
+
readonly conflictStrategy?: PairStrategy | undefined
|
|
516
|
+
readonly runtimeStrategy?: RuntimeStrategy | undefined
|
|
517
|
+
}
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
What a planner hands `compile`: a node without its key. `kind` defaults to `step`, `priority` to 0, `conflictStrategy` to `serialize`, and `runtimeStrategy` to `delay-rebase`.
|
|
521
|
+
|
|
522
|
+
### Plan.compile
|
|
523
|
+
|
|
524
|
+
```ts
|
|
525
|
+
const compile: (options: {
|
|
526
|
+
readonly planId: string
|
|
527
|
+
readonly flow: string
|
|
528
|
+
readonly nodes: ReadonlyArray<NodeDraft>
|
|
529
|
+
}) => Effect.Effect<Plan, PlanError | StepKey.KeyMaterialError | Schema.SchemaError, Crypto.Crypto>
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
Compiles drafts into a plan: topological order of material dependencies, dependency-digest substitution, overlap annotation, reader-after-writer ordering, and the plan digest. No I/O. The options are snapshotted before anything else happens, and the result is deep-frozen at generation 0 with `baseDigest` equal to `digest`. Array order follows material dependencies; scheduling follows the complete `dependsOn` graph.
|
|
533
|
+
|
|
534
|
+
Traversal uses explicit stacks. Because the conflict and reader-after-writer passes consider a quadratic number of node pairs, plans are bounded by `maximumPlanNodes` and fail with `graph_too_large` above that limit or the effect-analysis work budget. Analysis yields periodically.
|
|
535
|
+
|
|
536
|
+
### Plan.append
|
|
537
|
+
|
|
538
|
+
```ts
|
|
539
|
+
const append: (
|
|
540
|
+
plan: Plan,
|
|
541
|
+
drafts: ReadonlyArray<NodeDraft>
|
|
542
|
+
) => Effect.Effect<Plan, PlanError | StepKey.KeyMaterialError | Schema.SchemaError, Crypto.Crypto>
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
Appends an elaborated subgraph at the next generation. Nodes already in the plan keep their id, key, edges, and generation byte for byte, and the new nodes arrive pre-keyed against them. `baseDigest` does not move; `digest` does. New nodes retain material-dependency order after the recorded prefix, including when inferred edges point to later entries. An append with no drafts fails as `invalid_node`.
|
|
546
|
+
|
|
547
|
+
### Plan.verify
|
|
548
|
+
|
|
549
|
+
```ts
|
|
550
|
+
const verify: (input: unknown) => Effect.Effect<
|
|
551
|
+
Plan,
|
|
552
|
+
PlanError | StepKey.KeyMaterialError | Schema.SchemaError,
|
|
553
|
+
Crypto.Crypto
|
|
554
|
+
>
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
Reconstructs an imported plan using compiler key, dependency, conflict and generation rules. It verifies both the approval digests and the complete node contract, then returns an immutable snapshot. Compiler-owned immutable plans take a trusted fast path. `Plan.append` verifies imported prefixes through this same boundary. Recorded material-dependency order is checked without sorting inferred edges. Existing ordinals, keys, and approval digests remain unchanged; corrupted records are refused rather than silently rewritten.
|
|
558
|
+
|
|
559
|
+
### Plan.isVerified
|
|
560
|
+
|
|
561
|
+
```ts
|
|
562
|
+
const isVerified: (input: unknown) => input is Plan
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
Whether a value is a compiler-owned immutable snapshot that already passed `verify`. Such values take verify's trusted fast path and need no second schema validation, which is what lets [`@smthrs/plan-store`](/api/plan-store)'s `PlanStore.append` skip re-decoding them.
|
|
566
|
+
|
|
567
|
+
### Plan.prefixDigest
|
|
568
|
+
|
|
569
|
+
```ts
|
|
570
|
+
const prefixDigest: (
|
|
571
|
+
plan: Plan
|
|
572
|
+
) => Effect.Effect<StepKey.StepKey, StepKey.KeyMaterialError | Schema.SchemaError, Crypto.Crypto>
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
The approval digest of every generation before the newest: the digest the plan carried before its last append. `PlanStore.append` matches it against the stored envelope, so an append proves the recorded prefix is the caller's from the already-verified prefix instead of decoding and re-verifying every stored row.
|
|
576
|
+
|
|
577
|
+
### Plan.generationNodes
|
|
578
|
+
|
|
579
|
+
```ts
|
|
580
|
+
const generationNodes: (plan: Plan) => ReadonlyArray<PlanNode>
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
The nodes added by the newest generation: what `PlanStore.append` inserts, and what a scheduler such as [`@smthrs/engine-store`](/api/engine-store)'s reports in the `subgraph-appended` record it writes to the run journal.
|
|
584
|
+
|
|
585
|
+
### Plan.maximumPlanNodes
|
|
586
|
+
|
|
587
|
+
```ts
|
|
588
|
+
const maximumPlanNodes: 10_000
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
Maximum number of nodes retained by one compiled plan. Conflict analysis is quadratic in node count, so an explicit ceiling keeps untrusted declarations from turning planning into an unbounded CPU task.
|
|
592
|
+
|
|
593
|
+
### Plan.PlanError
|
|
594
|
+
|
|
595
|
+
```ts
|
|
596
|
+
class PlanError extends Schema.TaggedError<PlanError>()("@smthrs/plan/PlanError", {
|
|
597
|
+
code: Schema.Literals([
|
|
598
|
+
"cycle",
|
|
599
|
+
"unknown_dependency",
|
|
600
|
+
"duplicate_node",
|
|
601
|
+
"overlap_forbidden",
|
|
602
|
+
"invalid_effects",
|
|
603
|
+
"invalid_node",
|
|
604
|
+
"invalid_plan",
|
|
605
|
+
"graph_too_large"
|
|
606
|
+
])
|
|
607
|
+
message: Schema.String
|
|
608
|
+
})
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
A graph the compiler refuses, or a stored plan `verify` cannot accept. The code set is closed, so a caller may switch on it.
|
|
612
|
+
|
|
613
|
+
| `code` | Meaning |
|
|
614
|
+
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
615
|
+
| `cycle` | material dependencies close a cycle, or a reader-after-writer edge would close one |
|
|
616
|
+
| `unknown_dependency` | a `Ref` or `Pending` names a node that is neither in the drafts nor already in the plan |
|
|
617
|
+
| `duplicate_node` | a draft reuses an id the plan already holds |
|
|
618
|
+
| `overlap_forbidden` | a `fail` pair genuinely overlaps and no dependency path orders it |
|
|
619
|
+
| `invalid_effects` | one path is declared as both a write and a removal |
|
|
620
|
+
| `invalid_node` | an empty plan id, flow, or node id, a priority that is not a safe integer, a `kind` or strategy outside its literal set, or key material or an effect declaration this release cannot decode, which includes a path that is not workspace-relative |
|
|
621
|
+
| `invalid_plan` | `Plan.verify` decoded a plan whose generations, approval digest, keys, effects, or ordering do not match its content; raised on `PlanStore.record`, `append`, and `get`, never by `compile` |
|
|
622
|
+
| `graph_too_large` | a plan exceeds `Plan.maximumPlanNodes` or the effect-analysis work budget |
|
|
623
|
+
|
|
624
|
+
Compilation walks with explicit stacks and never recurses per edge. Effect analysis caches transitive reachability in bitsets, updates it when inferring ordering edges, and yields periodically for interruption. It refuses more than 250,000 candidate pairs or 10,000,000 work units with `graph_too_large`. Work includes overlap comparisons, bitset merges and graph traversal. `verify` shares this budget and one effect expansion map and candidate index across all replayed generations. Plans above `Plan.maximumPlanNodes` are refused before effect analysis.
|
|
625
|
+
|
|
626
|
+
### Conflict annotations
|
|
627
|
+
|
|
628
|
+
Declared write sets make overlap detectable at plan time. Writers already ordered by a dependency path, including one ordered through an ordering edge this pass just inferred, are not conflicts. Every other overlapping pair is annotated on both members with the resolved verdict.
|
|
629
|
+
|
|
630
|
+
| Verdict | Effect |
|
|
631
|
+
| ----------- | -------------------------------------------------------------------------------- |
|
|
632
|
+
| `serialize` | the default; the later writer gains an ordering edge |
|
|
633
|
+
| `lane` | both writers get lane annotations when either asks for one, and no ordering edge |
|
|
634
|
+
| `fail` | `compile` fails with `overlap_forbidden`, for flows that promise disjointness |
|
|
635
|
+
|
|
636
|
+
`fail` dominates `lane`, which dominates `serialize`. Each annotation also carries the runtime strategy the pair resolved to, where `stop-merge` dominates `delay-rebase`; that is what the scheduler does when the predicted overlap actually bites. Nodes frozen by an earlier generation are annotated on the new node only, because their rows are append-only.
|
|
637
|
+
|
|
638
|
+
## Node
|
|
639
|
+
|
|
640
|
+
[src/Node.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/plan/src/Node.ts)
|
|
641
|
+
|
|
642
|
+
The pure, pipeable authoring AST. Building a node records an inspectable, closure-free, JSON-serializable description and executes nothing.
|
|
643
|
+
|
|
644
|
+
Map transforms; branch decides. Both branch arms are evaluated once, symbolically, so the exit condition and the handoff site are visible topology before anything runs. A plan is always a DAG, so there is no loop node: repetition lives one level up, in what a flow settles with.
|
|
645
|
+
|
|
646
|
+
A payload is stored as its inert JSON mirror, and that mirror is identity material: it is hashed into the step key and persisted verbatim as plaintext in `node_json` and in the approval card. Nothing in this package redacts it, and redaction after hashing would break `Plan.verify`. Keep credentials out of payloads; a secret belongs in a layer, a capability, or the environment, resolved at dispatch. A data-valued callable `toJSON` is honoured, so a `Date` or a `URL` keys as the value it serializes to rather than as an empty object; a function or symbol member is dropped from an object and becomes `null` in an array; shared references and cycles clone as they were written. Accessors and unsupported prototypes without `toJSON` fail as `invalid_payload`, and a `toJSON` that returns its own receiver fails as `cyclic_payload` rather than collapsing to an empty object the way it once did. The clone and the input therefore key identically or refuse together.
|
|
647
|
+
|
|
648
|
+
`isNode` recognizes a node this package built by registration at construction, and a rehydrated node, an object sharing the node prototype whose own `ast` is a well-formed AST, by that shape, because `@smthrs/flow` hands an AST that crossed a serialization boundary back as a node. The `TypeId` marker is a public string any object can carry and counts for nothing on its own. Every combinator that admits a node reads its `ast` as trusted topology, so an object carrying the marker on any other prototype, one inheriting it from a node, and one whose `ast` is missing, malformed, or cyclic are all refused with the same `GraphBuildError` as any other non-node. A proxy is judged by the shape it forwards.
|
|
649
|
+
|
|
650
|
+
### Node.Node
|
|
651
|
+
|
|
652
|
+
```ts
|
|
653
|
+
interface Node<out A, out E = never, out R = never> extends Pipeable.Pipeable {
|
|
654
|
+
readonly [TypeId]: {
|
|
655
|
+
readonly _A: Types.Covariant<A>
|
|
656
|
+
readonly _E: Types.Covariant<E>
|
|
657
|
+
readonly _R: Types.Covariant<R>
|
|
658
|
+
}
|
|
659
|
+
readonly ast: Ast
|
|
660
|
+
}
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
A pure graph-building value. `R` is Effect's requirement channel and it is phantom here: nothing at plan time reads it, so building a plan stays requirement-free while the type still states which implementations executing it will need.
|
|
664
|
+
|
|
665
|
+
`Node.Any` is `Node<unknown, unknown, any>`. `Node.Success<N>`, `Node.Error<N>`, and `Node.Services<N>` extract the three parameters.
|
|
666
|
+
|
|
667
|
+
### Node.Ast
|
|
668
|
+
|
|
669
|
+
```ts
|
|
670
|
+
type Ast = Succeed | Fail | All | Map | AndThen | Branch | Catch | FlowCall | ActionCall
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
The inspectable AST a node stores: closure-free, and JSON serializable for every JSON payload an author puts in it. Every variant carries an optional `priority`.
|
|
674
|
+
|
|
675
|
+
### Node.CallMode
|
|
676
|
+
|
|
677
|
+
```ts
|
|
678
|
+
const CallMode: Schema.Literals<["inline", "boundary", "handoff"]>
|
|
679
|
+
type CallMode = "inline" | "boundary" | "handoff"
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
How a flow call joins the caller's plan: `inline` splices the callee's body in, `boundary` makes it one child execution, and `handoff` names the next trampoline round. This is the one call-mode vocabulary. `Node.flowCall` takes it and [`@smthrs/flow`](/api/flow)'s graph builder switches on it, so both packages read the same three names.
|
|
683
|
+
|
|
684
|
+
### Node.TypeId
|
|
685
|
+
|
|
686
|
+
```ts
|
|
687
|
+
const TypeId: "~@smthrs/plan/Node"
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
The runtime type identifier carried by every node, and its own type. It is a public string, so it is a hint and never a capability.
|
|
691
|
+
|
|
692
|
+
### Node.FunctionIdentity
|
|
693
|
+
|
|
694
|
+
```ts
|
|
695
|
+
type FunctionIdentity = {
|
|
696
|
+
readonly _tag: "FunctionIdentity"
|
|
697
|
+
readonly algorithm: "sha256-source-ephemeral/v4" | "sha256-source-captures/v5" | "static-node/v1"
|
|
698
|
+
readonly digest: string
|
|
699
|
+
}
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
The serializable stand-in an AST keeps for a plan-time function: a digest of its normalized source, hashed in place of a closure that could not be shipped, stored, or compared.
|
|
703
|
+
|
|
704
|
+
### Node.isNode
|
|
705
|
+
|
|
706
|
+
```ts
|
|
707
|
+
const isNode: (value: unknown) => value is Any
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
Checks whether a value is a node, by construction registration or by the shape a rehydrated node forwards.
|
|
711
|
+
|
|
712
|
+
### Node.succeed
|
|
713
|
+
|
|
714
|
+
```ts
|
|
715
|
+
const succeed: <A>(value: A) => Node<Succeed<A>>
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
A node that succeeds with the constant’s inert JSON projection, typed as `Succeed<A>`. Dates produce `string | null` (an invalid date produces `null`); URLs produce strings. Callable `toJSON` results are projected recursively. Function and symbol members are omitted from objects and become `null` in arrays. Planned references resolve to their referenced result types. Use `Node.map` to reconstruct a domain value explicitly.
|
|
719
|
+
|
|
720
|
+
### Node.fail
|
|
721
|
+
|
|
722
|
+
```ts
|
|
723
|
+
const fail: <E>(error: E) => Node<never, Fail<E>>
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
A node that fails with the constant’s inert JSON projection, in the typed error channel. `Fail<E>` is the same projection `Succeed<E>` describes, because a plan stores an error exactly as it stores a value.
|
|
727
|
+
|
|
728
|
+
It is how a plan states a refusal it already knows about, such as an unsupported input, an exhausted budget, or a compensation with nothing left to undo, without an action that exists only to fail. `Node.catch` recovers it exactly as it recovers an action’s failure, including schema filtering, and an uncaught one is the flow’s typed failure. It is a leaf: it declares no effects, and its error is keyed into its step identity, so two different refusals are two different steps.
|
|
729
|
+
|
|
730
|
+
### Node.all
|
|
731
|
+
|
|
732
|
+
```ts
|
|
733
|
+
const all: <const Nodes extends Readonly<Record<string, Any>>>(
|
|
734
|
+
nodes: Nodes
|
|
735
|
+
) => Node<
|
|
736
|
+
Types.Simplify<{ readonly [K in keyof Nodes]: Success<Nodes[K]> }>,
|
|
737
|
+
Error<Nodes[keyof Nodes]>,
|
|
738
|
+
Services<Nodes[keyof Nodes]>
|
|
739
|
+
>
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
Combines independent children into one node, keyed by name. Width is fixed at plan time. A non-node member throws `invalid_all_member` naming that member.
|
|
743
|
+
|
|
744
|
+
### Node.race, Node.any, and Node.quorum
|
|
745
|
+
|
|
746
|
+
```ts
|
|
747
|
+
type Members = Readonly<Record<string, Any>>
|
|
748
|
+
const race: <const Nodes extends Members>(
|
|
749
|
+
nodes: Nodes
|
|
750
|
+
) => Node<Success<Nodes[keyof Nodes]>, Error<Nodes[keyof Nodes]>, Services<Nodes[keyof Nodes]>>
|
|
751
|
+
const any: <const Nodes extends Members>(
|
|
752
|
+
nodes: Nodes
|
|
753
|
+
) => Node<Success<Nodes[keyof Nodes]>, Error<Nodes[keyof Nodes]>, Services<Nodes[keyof Nodes]>>
|
|
754
|
+
const quorum: <const Nodes extends Members>(
|
|
755
|
+
count: number,
|
|
756
|
+
nodes: Nodes
|
|
757
|
+
) => Node<
|
|
758
|
+
Types.Simplify<{ readonly [K in keyof Nodes]?: Success<Nodes[K]> }>,
|
|
759
|
+
Error<Nodes[keyof Nodes]>,
|
|
760
|
+
Services<Nodes[keyof Nodes]>
|
|
761
|
+
>
|
|
762
|
+
```
|
|
763
|
+
|
|
764
|
+
Joins that settle early. `race` settles with the first member to settle, success or typed failure. `any` settles with the first success and fails with the last failure when every member fails. `quorum` succeeds with exactly the first `count` successes, keyed by member, and fails with the failure that leaves too few members able to succeed.
|
|
765
|
+
|
|
766
|
+
Members still running when the join decides are interrupted. The members that decided are journaled in order, so a resumed run settles the same members and never starts the others again. An interrupted (parked) member is undecided; a defect or interpreter refusal in a member propagates without a decision. No members, a non-node member, or a quorum count outside 1 to the member count throws `invalid_join`.
|
|
767
|
+
|
|
768
|
+
### Node.map
|
|
769
|
+
|
|
770
|
+
```ts
|
|
771
|
+
const map: {
|
|
772
|
+
<A, B>(f: (a: A) => B): <E, R>(self: Node<A, E, R>) => Node<B, E, R>
|
|
773
|
+
<A, E, R, B>(self: Node<A, E, R>, f: (a: A) => B): Node<B, E, R>
|
|
774
|
+
}
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
Transforms an eventual success value with a deferred pure function. The function is digested, not run: it executes later, on the real value. A `map` that decides what happens next is a `branch` written wrongly.
|
|
778
|
+
|
|
779
|
+
### Node.andThen
|
|
780
|
+
|
|
781
|
+
```ts
|
|
782
|
+
const andThen: {
|
|
783
|
+
<B, E2, R2>(next: Node<B, E2, R2>): <A, E, R>(self: Node<A, E, R>) => Node<B, E | E2, R | R2>
|
|
784
|
+
<A, E, R, B, E2, R2>(self: Node<A, E, R>, next: Node<B, E2, R2>): Node<B, E | E2, R | R2>
|
|
785
|
+
}
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
Starts the entire next subtree only after the first node succeeds, without consuming its result. Failure or interruption prevents nested actions, combinations, and inline flows from starting. Independent children inside the next subtree can still run concurrently once the boundary opens. Passing a callback is a type error and throws `invalid_continuation` in JavaScript.
|
|
789
|
+
|
|
790
|
+
### Node.bindPlanned
|
|
791
|
+
|
|
792
|
+
```ts
|
|
793
|
+
const bindPlanned: {
|
|
794
|
+
<A, B, E2, R2>(
|
|
795
|
+
build: (reference: Planned.Planned<A>) => Node<B, E2, R2>
|
|
796
|
+
): <E, R>(self: Node<A, E, R>) => Node<B, E | E2, R | R2>
|
|
797
|
+
<A, E, R, B, E2, R2>(
|
|
798
|
+
self: Node<A, E, R>,
|
|
799
|
+
build: (reference: Planned.Planned<A>) => Node<B, E2, R2>
|
|
800
|
+
): Node<B, E | E2, R | R2>
|
|
801
|
+
}
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
Builds a dependency at plan time using a reference to the future result. Pass it into payloads; use `Node.map` for value computation and `Node.branch` for decisions. The callback receives a symbolic reference. Enable type-aware ESLint's `@typescript-eslint/strict-boolean-expressions` to reject planned conditions; explicit Boolean coercion and reference equality still require review.
|
|
805
|
+
|
|
806
|
+
Migration: replace callback-form `Node.andThen` with `Node.bindPlanned`. Direct node sequencing remains `Node.andThen`. This source API change preserves existing AST and key formats.
|
|
807
|
+
|
|
808
|
+
`bindPlanned` builds dependencies, not a whole-subtree success barrier. Members
|
|
809
|
+
that do not consume the reference can start while its producer runs. Use
|
|
810
|
+
explicit `andThen` when no work in the next subtree may start before success.
|
|
811
|
+
The corrected graph normalization now carries that explicit barrier to every
|
|
812
|
+
descendant, including inline-flow bodies. This changes compiled keys/digests
|
|
813
|
+
for previously under-ordered nested sequences; re-plan affected work rather
|
|
814
|
+
than substituting the new graph into an already approved execution.
|
|
815
|
+
|
|
816
|
+
### Node.BranchOptions
|
|
817
|
+
|
|
818
|
+
```ts
|
|
819
|
+
interface BranchOptions<A, B1, E1, R1, B2, E2, R2> {
|
|
820
|
+
readonly if: (value: A) => boolean
|
|
821
|
+
readonly then: (value: Planned.Planned<A>) => Node<B1, E1, R1>
|
|
822
|
+
readonly else: (value: Planned.Planned<A>) => Node<B2, E2, R2>
|
|
823
|
+
}
|
|
824
|
+
```
|
|
825
|
+
|
|
826
|
+
`if` runs at run time on the real value. `then` and `else` run at plan time, once each, against a `Planned` placeholder.
|
|
827
|
+
|
|
828
|
+
### Node.branch
|
|
829
|
+
|
|
830
|
+
```ts
|
|
831
|
+
const branch: {
|
|
832
|
+
<A, B1, E1, R1, B2, E2, R2>(
|
|
833
|
+
options: BranchOptions<A, B1, E1, R1, B2, E2, R2>
|
|
834
|
+
): <E, R>(self: Node<A, E, R>) => Node<B1 | B2, E | E1 | E2, R | R1 | R2>
|
|
835
|
+
<A, E, R, B1, E1, R1, B2, E2, R2>(
|
|
836
|
+
self: Node<A, E, R>,
|
|
837
|
+
options: BranchOptions<A, B1, E1, R1, B2, E2, R2>
|
|
838
|
+
): Node<B1 | B2, E | E1 | E2, R | R1 | R2>
|
|
839
|
+
}
|
|
840
|
+
```
|
|
841
|
+
|
|
842
|
+
Decides between two arms, both of them static topology. The predicate function is snapshotted at construction; replacing `options.if` later does not change the chosen arm. Both arms contribute their requirements, because a run has to be able to take either. An arm that does not return a node throws `invalid_continuation`.
|
|
843
|
+
|
|
844
|
+
### Node.CatchOptions
|
|
845
|
+
|
|
846
|
+
```ts
|
|
847
|
+
interface CatchOptions<E, B, E2, R2 = never, Handled = E> {
|
|
848
|
+
readonly error?: Schema.Schema<Handled> | undefined
|
|
849
|
+
readonly onFailure: (error: Planned.Planned<Handled>) => Node<B, E2, R2>
|
|
850
|
+
}
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
The statically planned recovery arm and the optional schema selecting which typed failures it handles.
|
|
854
|
+
|
|
855
|
+
### Node.catch
|
|
856
|
+
|
|
857
|
+
```ts
|
|
858
|
+
const catch: {
|
|
859
|
+
<Handled, B, E2, R2>(
|
|
860
|
+
options: CatchOptions<unknown, B, E2, R2, Handled> & { readonly error: Schema.Schema<Handled> }
|
|
861
|
+
): <A, E, R>(self: Node<A, E, R>) => Node<A | B, E | E2, R | R2>
|
|
862
|
+
<E, B, E2, R2>(
|
|
863
|
+
options: CatchOptions<E, B, E2, R2> & { readonly error?: undefined }
|
|
864
|
+
): <A, R>(self: Node<A, E, R>) => Node<A | B, E2, R | R2>
|
|
865
|
+
<A, E, R, Handled, B, E2, R2>(
|
|
866
|
+
self: Node<A, E, R>,
|
|
867
|
+
options: CatchOptions<E, B, E2, R2, Handled> & { readonly error: Schema.Schema<Handled> }
|
|
868
|
+
): Node<A | B, E | E2, R | R2>
|
|
869
|
+
<A, E, R, B, E2, R2>(
|
|
870
|
+
self: Node<A, E, R>,
|
|
871
|
+
options: CatchOptions<E, B, E2, R2> & { readonly error?: undefined }
|
|
872
|
+
): Node<A | B, E2, R | R2>
|
|
873
|
+
}
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
Recovers from matching typed failures with static failure topology. With no schema the whole typed error channel is handled. A schema handles only values it accepts, so schema-filtered overloads retain `E | E2`: refinements can reject values without narrowing their TypeScript type. The filter’s JSON Schema and runtime function identities both enter the catch key. Uncaptured schema callbacks have process-local identity and cannot enter a stable graph. For stable custom checks, construct `SchemaAST.Filter` with a `Node.capture`-annotated `run` function and declare all semantic captures; `Schema.makeFilter` wraps its predicate in an opaque closure.
|
|
877
|
+
|
|
878
|
+
### Node.priority
|
|
879
|
+
|
|
880
|
+
```ts
|
|
881
|
+
const priority: {
|
|
882
|
+
(value: number): <A, E, R>(self: Node<A, E, R>) => Node<A, E, R>
|
|
883
|
+
<A, E, R>(self: Node<A, E, R>, value: number): Node<A, E, R>
|
|
884
|
+
}
|
|
885
|
+
```
|
|
886
|
+
|
|
887
|
+
Attaches a scheduling priority, leaving the original node unchanged. Higher runs first among ready work, so a priority changes latency and nothing else. It never enters key material. Children inherit the value lexically when the graph is built, and a child that states its own keeps it. A value that is not a safe integer throws `invalid_priority`.
|
|
888
|
+
|
|
889
|
+
### Node.declaredPriority
|
|
890
|
+
|
|
891
|
+
```ts
|
|
892
|
+
const declaredPriority: (ast: Ast) => number | undefined
|
|
893
|
+
```
|
|
894
|
+
|
|
895
|
+
Reads the priority a node carries, or `undefined` when it states none and inherits from whatever encloses it.
|
|
896
|
+
|
|
897
|
+
### Node.capture
|
|
898
|
+
|
|
899
|
+
```ts
|
|
900
|
+
const capture: <C extends Readonly<Record<string, unknown>>, Args extends ReadonlyArray<unknown>, A>(
|
|
901
|
+
captures: C,
|
|
902
|
+
operation: (this: Readonly<C>, ...args: Args) => A
|
|
903
|
+
) => (...args: Args) => A
|
|
904
|
+
```
|
|
905
|
+
|
|
906
|
+
Declares the inert values a plan-time function closes over, which gives that function deterministic identity instead of process-local entropy. A deeply frozen plain copy is canonicalized into function identity and bound as the callback's `this` receiver. Use a function expression to read that copy; ordinary arguments keep their positions. Caller objects remain unchanged, and mutable lexical aliases must not supply semantic state. Sealed, non-extensible, frozen and Immer ordinary data are supported. Built-in brands and Proxies are refused, and object capture requires `structuredClone`. See the [admission algorithm](https://crypto.smithers.sh/reference/api/#identitycapture). Unsupported values, accessors, exotic prototypes, symbols, cycles, and member nesting beyond 256 levels throw a `TypeError` naming the path, instead of producing an identity that cannot describe the function's behavior.
|
|
907
|
+
|
|
908
|
+
Plan uses the capture and function-identity implementation from `@smthrs/crypto/Identity`, the one implementation every node model embeds. Capturing an already-captured function preserves its original source and folds both capture sets into the identity. Wrappers can be captured and identified interchangeably by either package. A non-function operation throws `TypeError("Node.capture requires a function operation")`.
|
|
909
|
+
|
|
910
|
+
### Node.plannedReference
|
|
911
|
+
|
|
912
|
+
```ts
|
|
913
|
+
const plannedReference: (value: unknown) => {
|
|
914
|
+
readonly _tag: "PlannedReference"
|
|
915
|
+
readonly node: string
|
|
916
|
+
readonly path: ReadonlyArray<string>
|
|
917
|
+
} | undefined
|
|
918
|
+
```
|
|
919
|
+
|
|
920
|
+
Reads the inert AST reference created for a planned value. Structural lookalikes remain ordinary payload data: the marker is private to the AST cloner, so this accessor is the only recognition path.
|
|
921
|
+
|
|
922
|
+
### Node.branchSubject and Node.catchSubject
|
|
923
|
+
|
|
924
|
+
```ts
|
|
925
|
+
const branchSubject: "branch/subject"
|
|
926
|
+
const catchSubject: "catch/subject"
|
|
927
|
+
```
|
|
928
|
+
|
|
929
|
+
The node reference a branch arm's symbolic subject carries, and the prefix each `catch` mints its own token under. Arms are built before the graph assigns ids, so every reference an arm records names one of these placeholders, and graph building rewrites it to the node the arm belongs to.
|
|
930
|
+
|
|
931
|
+
### Engine members
|
|
932
|
+
|
|
933
|
+
`flowCall`, `actionCall`, `declaration`, `continuation`, `mapper`, `predicate`, `catchFilter`, and `functionIdentity` exist for [`@smthrs/flow`](/api/flow), which owns flow and action authoring. They are supported names, not authoring API.
|
|
934
|
+
|
|
935
|
+
```ts
|
|
936
|
+
const flowCall: <A = unknown, E = never, R = never>(
|
|
937
|
+
declaration: unknown,
|
|
938
|
+
flow: string,
|
|
939
|
+
mode: CallMode,
|
|
940
|
+
payload: unknown
|
|
941
|
+
) => Node<A, E, R>
|
|
942
|
+
|
|
943
|
+
const actionCall: <A = unknown, E = never, R = never>(
|
|
944
|
+
declaration: unknown,
|
|
945
|
+
action: string,
|
|
946
|
+
payload: unknown
|
|
947
|
+
) => Node<A, E, R>
|
|
948
|
+
|
|
949
|
+
const declaration: (ast: Extract<Ast, { readonly _tag: "ActionCall" | "FlowCall" }>) => unknown
|
|
950
|
+
const continuation: (
|
|
951
|
+
ast: Extract<Ast, { readonly _tag: "AndThen" }>
|
|
952
|
+
) => ((value: Planned.Planned<unknown>) => unknown) | undefined
|
|
953
|
+
const mapper: (ast: Ast) => ((value: unknown) => unknown) | undefined
|
|
954
|
+
const predicate: (ast: Ast) => ((value: unknown) => boolean) | undefined
|
|
955
|
+
const catchFilter: (ast: Ast) => Schema.Top | undefined
|
|
956
|
+
const functionIdentity: (operation: unknown) => FunctionIdentity
|
|
957
|
+
```
|
|
958
|
+
|
|
959
|
+
`flowCall` and `actionCall` validate nothing: an unknown tag becomes a call node the graph keeps as a leaf. `declaration`, `continuation`, `mapper`, `predicate`, and `catchFilter` answer `undefined` for the wrong variant and for an AST rehydrated from JSON, whose side tables did not survive serialization. `functionIdentity` throws a `TypeError` when handed anything but a function.
|
|
960
|
+
|
|
961
|
+
## Planned
|
|
962
|
+
|
|
963
|
+
[src/Planned.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/plan/src/Planned.ts)
|
|
964
|
+
|
|
965
|
+
A planned value may be passed into a payload field, into a branch, or into a map, and field access is allowed, because it records a reference path.
|
|
966
|
+
|
|
967
|
+
:::danger
|
|
968
|
+
A planned value may never be computed on.
|
|
969
|
+
:::
|
|
970
|
+
|
|
971
|
+
Misuse fails twice. The type is branded, so arithmetic on a planned value is a compile error; and the proxy's `Symbol.toPrimitive`, `valueOf`, `toString`, `toJSON`, application, `in`, and enumeration traps throw rather than let a plan be built around `NaN` or `"[object Object]"`, which catches template interpolation, `String(value)`, and `JSON.stringify` of a payload holding one. JavaScript exposes no trap for `Boolean(value)` or strict identity, so those cannot be refused at run time; they reveal only proxy truthiness or identity and never the planned result.
|
|
972
|
+
|
|
973
|
+
The `TypeId` symbol is interned, so a value that crossed a module boundary is still recognised. Interning is a recognition aid rather than a capability, so `reference` returns a reference only when the value stored under that symbol has the complete `{node, path}` shape.
|
|
974
|
+
|
|
975
|
+
### Planned.Planned
|
|
976
|
+
|
|
977
|
+
```ts
|
|
978
|
+
type Planned<T> =
|
|
979
|
+
& { readonly [TypeId]: Identity<T> }
|
|
980
|
+
& ([T] extends [object] ? { readonly [K in keyof T]: Planned<T[K]> } : unknown)
|
|
981
|
+
```
|
|
982
|
+
|
|
983
|
+
A step result that has not been produced yet. The mapped half keeps field access typed, so `result.files` is a `Planned` of the field.
|
|
984
|
+
|
|
985
|
+
### Planned.Reference and Planned.Identity
|
|
986
|
+
|
|
987
|
+
```ts
|
|
988
|
+
interface Reference {
|
|
989
|
+
readonly node: string
|
|
990
|
+
readonly path: ReadonlyArray<string>
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
interface Identity<out T> extends Reference {
|
|
994
|
+
readonly _T: Types.Covariant<T>
|
|
995
|
+
}
|
|
996
|
+
```
|
|
997
|
+
|
|
998
|
+
What a planned value points at: the node that will produce the result, and the property path read from it. `path` is empty for the result itself.
|
|
999
|
+
|
|
1000
|
+
### Planned.TypeId
|
|
1001
|
+
|
|
1002
|
+
```ts
|
|
1003
|
+
const TypeId: unique symbol // Symbol.for("@smthrs/plan/Planned")
|
|
1004
|
+
```
|
|
1005
|
+
|
|
1006
|
+
The brand carried by every planned value, and the key its reference is read from.
|
|
1007
|
+
|
|
1008
|
+
### Planned.make
|
|
1009
|
+
|
|
1010
|
+
```ts
|
|
1011
|
+
const make: <T>(node: string) => Planned<T>
|
|
1012
|
+
```
|
|
1013
|
+
|
|
1014
|
+
Creates the strict placeholder standing for a node's result. Planning hands one to every builder that consumes an upstream value, then reads the `Reference` back off whatever the builder passed it into.
|
|
1015
|
+
|
|
1016
|
+
### Planned.reference
|
|
1017
|
+
|
|
1018
|
+
```ts
|
|
1019
|
+
const reference: (value: unknown) => Reference | undefined
|
|
1020
|
+
```
|
|
1021
|
+
|
|
1022
|
+
Reads the reference a planned value records, or `undefined` for anything else. A forged carrier is accepted only when its node and path have the complete reference shape. This is how a payload is scanned for the upstream results it consumes.
|
|
1023
|
+
|
|
1024
|
+
### Planned.isPlanned
|
|
1025
|
+
|
|
1026
|
+
```ts
|
|
1027
|
+
const isPlanned: (value: unknown) => value is Planned<unknown>
|
|
1028
|
+
```
|
|
1029
|
+
|
|
1030
|
+
Checks whether a value is a planned placeholder.
|
|
1031
|
+
|
|
1032
|
+
## FileSet
|
|
1033
|
+
|
|
1034
|
+
[src/FileSet.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/plan/src/FileSet.ts)
|
|
1035
|
+
|
|
1036
|
+
The static filesystem declaration vocabulary shared by planning and execution: a workspace-relative `Pattern`, a Bazel-style `Glob`, a `TreeArtifact`, and the `Filegroup` that names a reusable collection.
|
|
1037
|
+
|
|
1038
|
+
`canonical` rewrites every separator to `/` and normalizes to Unicode NFC, and every exact-path comparison goes through it, so the backslash spelling and the NFD spelling of one workspace path overlap. `workspaceRelative` refuses absolute paths, drive-letter prefixes (`C:/x` and the drive-relative `C:x`), `..` and `.` segments, empty segments, the C0 control range, and DEL. C1 bytes stay legal, because a POSIX file name may contain them.
|
|
1039
|
+
|
|
1040
|
+
`overlaps` is conservative: `true` may over-serialize, while `false` proves that no path can belong to both declarations. It compares the `folded` form, so two spellings that differ only in letter case overlap, because a case-insensitive filesystem (default macOS APFS, Windows NTFS) opens them as one file.
|
|
1041
|
+
|
|
1042
|
+
### The declaration types
|
|
1043
|
+
|
|
1044
|
+
```ts
|
|
1045
|
+
const Pattern: Schema.String // workspace-relative; `*` and `**` match only inside a Glob
|
|
1046
|
+
const Glob: Schema.TaggedStruct<"Glob", { include: NonEmptyArray<Pattern>; exclude?: Array<Pattern> }>
|
|
1047
|
+
const TreeArtifact: Schema.TaggedStruct<"TreeArtifact", { path: Pattern }>
|
|
1048
|
+
const Filegroup: Schema.TaggedStruct<"Filegroup", { name: NonEmptyString; entries: Array<Entry> }>
|
|
1049
|
+
const ReadFilegroup: Schema.TaggedStruct<"Filegroup", { name: NonEmptyString; entries: Array<ReadEntry> }>
|
|
1050
|
+
|
|
1051
|
+
type Entry = Pattern | Glob | TreeArtifact
|
|
1052
|
+
type ReadEntry = Pattern | Glob
|
|
1053
|
+
type Declaration = Entry | Filegroup
|
|
1054
|
+
type ReadDeclaration = ReadEntry | ReadFilegroup
|
|
1055
|
+
```
|
|
1056
|
+
|
|
1057
|
+
Each is exported as both a schema and a type. `Declaration` is what a write set accepts and `ReadDeclaration` what a read set accepts, which is how a tree artifact is kept out of a read set: a read set names what a node consumes, and a directory output is not that.
|
|
1058
|
+
|
|
1059
|
+
### FileSet.canonical
|
|
1060
|
+
|
|
1061
|
+
```ts
|
|
1062
|
+
const canonical: (path: string) => string
|
|
1063
|
+
```
|
|
1064
|
+
|
|
1065
|
+
The canonical spelling of a declared path or pattern: every separator is `/`, and Unicode is normalized to NFC.
|
|
1066
|
+
|
|
1067
|
+
### FileSet.folded
|
|
1068
|
+
|
|
1069
|
+
```ts
|
|
1070
|
+
const folded: (path: string) => string
|
|
1071
|
+
```
|
|
1072
|
+
|
|
1073
|
+
The `canonical` spelling with letter case folded away. Static overlap compares this form.
|
|
1074
|
+
|
|
1075
|
+
### FileSet.workspaceRelative
|
|
1076
|
+
|
|
1077
|
+
```ts
|
|
1078
|
+
const workspaceRelative: (pattern: string) => boolean
|
|
1079
|
+
```
|
|
1080
|
+
|
|
1081
|
+
Whether a declared path stays inside the workspace and names it one way only. Refuses absolute paths (POSIX and drive-letter), any first segment starting with a drive letter and `:` (including the drive-relative `C:foo`), upward traversal, the aliasing forms (`.` segments, empty segments), C0 controls, and DEL.
|
|
1082
|
+
|
|
1083
|
+
### FileSet.makeFilegroup
|
|
1084
|
+
|
|
1085
|
+
```ts
|
|
1086
|
+
const makeFilegroup: (name: string, entries: ReadonlyArray<Entry>) => Filegroup
|
|
1087
|
+
```
|
|
1088
|
+
|
|
1089
|
+
Creates a named filegroup.
|
|
1090
|
+
|
|
1091
|
+
### FileSet.expand and FileSet.expandReads
|
|
1092
|
+
|
|
1093
|
+
```ts
|
|
1094
|
+
const expand: (declarations: ReadonlyArray<Declaration>) => ReadonlyArray<Entry>
|
|
1095
|
+
const expandReads: (declarations: ReadonlyArray<ReadDeclaration>) => ReadonlyArray<ReadEntry>
|
|
1096
|
+
```
|
|
1097
|
+
|
|
1098
|
+
Expands filegroups deterministically, preserving declaration order. Both plan passes expand before they compare anything.
|
|
1099
|
+
|
|
1100
|
+
### FileSet.isGlob and FileSet.isTreeArtifact
|
|
1101
|
+
|
|
1102
|
+
```ts
|
|
1103
|
+
const isGlob: (value: unknown) => value is Glob
|
|
1104
|
+
const isTreeArtifact: (value: unknown) => value is TreeArtifact
|
|
1105
|
+
```
|
|
1106
|
+
|
|
1107
|
+
Which variant an entry is.
|
|
1108
|
+
|
|
1109
|
+
### FileSet.matchesPattern and FileSet.matchesGlob
|
|
1110
|
+
|
|
1111
|
+
```ts
|
|
1112
|
+
const matchesPattern: (pattern: string, path: string) => boolean
|
|
1113
|
+
const matchesGlob: (glob: Glob, path: string) => boolean
|
|
1114
|
+
```
|
|
1115
|
+
|
|
1116
|
+
Bazel's `*` and `**` path semantics without permitting traversal: `*` matches within one segment, `**` matches segments, and a trailing `**` matches the rest of the path. `matchesGlob` honours the glob's `exclude` list.
|
|
1117
|
+
|
|
1118
|
+
Matching consumes the entire path, including Unicode line separators. Compiled
|
|
1119
|
+
matchers are cached by canonical pattern spelling. Segment matching is linear
|
|
1120
|
+
in the segment and pattern lengths; recursive matching visits each pattern/path
|
|
1121
|
+
segment pair once, without regex backtracking.
|
|
1122
|
+
|
|
1123
|
+
### FileSet.overlaps
|
|
1124
|
+
|
|
1125
|
+
```ts
|
|
1126
|
+
const overlaps: (left: Entry, right: Entry) => boolean
|
|
1127
|
+
```
|
|
1128
|
+
|
|
1129
|
+
Conservative static overlap. A plain string is a literal path, even one containing `*` or `**`, as it is at execution. Exact paths and tree roots compare in `folded` form: canonical separator, NFC, and case-insensitive. A glob against an exact path folds both its `include` and `exclude` patterns. Two globs always overlap, and so do a glob and a tree artifact. A tree artifact overlaps any path beneath it. A glob tests the path bytes it is handed, so canonicalizing a measured path before matching is the caller's decision.
|
|
1130
|
+
|
|
1131
|
+
## Effects
|
|
1132
|
+
|
|
1133
|
+
[src/Effects.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/plan/src/Effects.ts)
|
|
1134
|
+
|
|
1135
|
+
The one effect envelope model. A flow declares what it may read and write, and a
|
|
1136
|
+
graph build checks every step beneath it against that declaration. `@smthrs/flow`
|
|
1137
|
+
enforces it while it builds, `@smthrs/core` re-exports it as `Effects`, and
|
|
1138
|
+
`@smthrs/patterns` intersects a decorator's declaration with the flow it wraps
|
|
1139
|
+
through it.
|
|
1140
|
+
|
|
1141
|
+
`FileSet` above is the other half of the vocabulary and stays separate: it
|
|
1142
|
+
describes the files a node actually touches, in Bazel-style entries a plan
|
|
1143
|
+
compiles conflicts from, while this module describes the AUTHORITY a flow holds,
|
|
1144
|
+
in the flat path grammar `covers` defines.
|
|
1145
|
+
|
|
1146
|
+
### Effects.Declaration
|
|
1147
|
+
|
|
1148
|
+
```ts
|
|
1149
|
+
interface Declaration {
|
|
1150
|
+
readonly reads: ReadonlyArray<string>
|
|
1151
|
+
readonly writes: ReadonlyArray<string>
|
|
1152
|
+
readonly mode: "hermetic" | "expected"
|
|
1153
|
+
readonly onConflict: "serialize" | "lane" | "fail"
|
|
1154
|
+
readonly tier?: "sealed" | "compensable" | "irreversible" | undefined
|
|
1155
|
+
}
|
|
1156
|
+
```
|
|
1157
|
+
|
|
1158
|
+
`mode` says whether the declaration is complete (`hermetic`) or partial
|
|
1159
|
+
(`expected`). `onConflict` says what the planner should do about another writer
|
|
1160
|
+
of the same path, and its three values are `Plan.PairStrategy`. `tier` says how
|
|
1161
|
+
reversible the effect is, matching `KeyMaterial.kind`, and an omitted tier reads
|
|
1162
|
+
as `sealed`.
|
|
1163
|
+
|
|
1164
|
+
### Effects.make
|
|
1165
|
+
|
|
1166
|
+
```ts
|
|
1167
|
+
const make: (input: MakeOptions) => Declaration
|
|
1168
|
+
```
|
|
1169
|
+
|
|
1170
|
+
Constructs a deterministic declaration. `MakeOptions` takes `Iterable<string>`
|
|
1171
|
+
for `reads` and `writes` and is otherwise identical to `Declaration`.
|
|
1172
|
+
Normalization is sorting and deduplication only: no separator rewriting and no
|
|
1173
|
+
dot-segment resolution is performed, so hand it paths that are already
|
|
1174
|
+
normalized.
|
|
1175
|
+
|
|
1176
|
+
### Effects.covers
|
|
1177
|
+
|
|
1178
|
+
```ts
|
|
1179
|
+
const covers: (envelope: string, path: string) => boolean
|
|
1180
|
+
```
|
|
1181
|
+
|
|
1182
|
+
Whether one envelope entry covers one path. The grammar is exhaustive and
|
|
1183
|
+
intentionally not full minimatch: an exact path matches itself, `*` and `**`
|
|
1184
|
+
match everything, `prefix*` matches by string prefix, and `prefix/**` matches
|
|
1185
|
+
`prefix/` and everything below it but not the bare path `prefix`. A path
|
|
1186
|
+
containing a whole `.` or `..` segment is never covered.
|
|
1187
|
+
|
|
1188
|
+
### Effects.narrow
|
|
1189
|
+
|
|
1190
|
+
```ts
|
|
1191
|
+
const narrow: (envelope: Declaration, step: Declaration) => NarrowResult
|
|
1192
|
+
```
|
|
1193
|
+
|
|
1194
|
+
Verifies that a step declaration stays within an enclosing envelope. Read and
|
|
1195
|
+
write paths must be covered independently, `expected` may tighten to
|
|
1196
|
+
`hermetic` but not the reverse, and the tier may narrow from `irreversible` to
|
|
1197
|
+
`compensable` to `sealed`.
|
|
1198
|
+
|
|
1199
|
+
### Effects.NarrowResult
|
|
1200
|
+
|
|
1201
|
+
```ts
|
|
1202
|
+
type NarrowResult =
|
|
1203
|
+
| { readonly ok: true }
|
|
1204
|
+
| {
|
|
1205
|
+
readonly ok: false
|
|
1206
|
+
readonly code: "effect_outside_envelope" | "effect_mode_widening" | "effect_tier_widening"
|
|
1207
|
+
readonly paths: ReadonlyArray<string>
|
|
1208
|
+
}
|
|
1209
|
+
```
|
|
1210
|
+
|
|
1211
|
+
`paths` is populated for `effect_outside_envelope` and empty for the other two.
|
|
1212
|
+
The three codes are the three `GraphBuildError` codes a build records when a
|
|
1213
|
+
declaration widens the envelope that encloses it.
|
|
1214
|
+
|
|
1215
|
+
### Effects.overlaps
|
|
1216
|
+
|
|
1217
|
+
```ts
|
|
1218
|
+
const overlaps: (a: Declaration, b: Declaration) => ReadonlyArray<string>
|
|
1219
|
+
```
|
|
1220
|
+
|
|
1221
|
+
Returns the concrete or narrower write declarations two declarations share,
|
|
1222
|
+
sorted and duplicate-free. Two declarations of the same literal path always
|
|
1223
|
+
overlap, including a path `covers` refuses to match because it carries a `.` or
|
|
1224
|
+
`..` segment: glob coverage stays strict, but two writers naming the same
|
|
1225
|
+
unnormalized path are still writing the same resource.
|
|
1226
|
+
|
|
1227
|
+
### Effects.sealed
|
|
1228
|
+
|
|
1229
|
+
```ts
|
|
1230
|
+
const sealed: (declaration: Declaration) => Declaration
|
|
1231
|
+
```
|
|
1232
|
+
|
|
1233
|
+
Returns a `hermetic`, `sealed` copy of a declaration.
|
|
1234
|
+
|
|
1235
|
+
### The prepared matching API
|
|
1236
|
+
|
|
1237
|
+
```ts
|
|
1238
|
+
const prepareEnvelope: (envelope: Declaration) => PreparedEnvelope
|
|
1239
|
+
const narrowPrepared: (envelope: PreparedEnvelope, step: Declaration) => NarrowResult
|
|
1240
|
+
|
|
1241
|
+
const indexPaths: (lists: Iterable<ReadonlyArray<string>>) => PathIndex
|
|
1242
|
+
const rankPaths: (index: PathIndex, paths: ReadonlyArray<string>) => Ranked
|
|
1243
|
+
const overlapRanks: (index: PathIndex, a: Ranked, b: Ranked) => Array<number>
|
|
1244
|
+
|
|
1245
|
+
const boundedEffects: (declaration: Declaration, limit: number, refuse: () => Error) => Declaration
|
|
1246
|
+
const maximumPathLength: 4096
|
|
1247
|
+
const maximumGlobs: 128
|
|
1248
|
+
```
|
|
1249
|
+
|
|
1250
|
+
What a graph build uses so one envelope costs its size once per build rather
|
|
1251
|
+
than once per step it encloses. `prepareEnvelope` reads an envelope into the
|
|
1252
|
+
form `narrowPrepared` answers from; `indexPaths`, `rankPaths`, and
|
|
1253
|
+
`overlapRanks` are the same decomposition for write overlap, which `overlaps`
|
|
1254
|
+
wraps for the pairwise case. `boundedEffects` snapshots a declaration into
|
|
1255
|
+
builder-owned data and throws the caller's own refusal before the copy grows
|
|
1256
|
+
past `limit`, past `maximumPathLength` code units in one path, or past
|
|
1257
|
+
`maximumGlobs` patterns in one list.
|
|
1258
|
+
|
|
1259
|
+
## Placement
|
|
1260
|
+
|
|
1261
|
+
[src/Placement.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/plan/src/Placement.ts)
|
|
1262
|
+
|
|
1263
|
+
The one placement model. [`@smthrs/core`](/api/core) re-exports it as `Placement`, [`@smthrs/flow`](/api/flow) annotates with it as `Flow.PlacementDirective`, and `@smthrs/registry` lowers a descriptor's frontmatter literal into it. A directive is serializable detail identifying a host profile; it never carries a host implementation, credentials, or any other runtime handle.
|
|
1264
|
+
|
|
1265
|
+
The four tags read `flows/core/Placement/...` because they are hashed into `KeyMaterial.placement`, and therefore into the step key of every node that declares a placement. Retagging them would re-key recorded work and miss its cached results, so the tag is treated as a wire value rather than as a location.
|
|
1266
|
+
|
|
1267
|
+
### Placement.Placement and Placement.Options
|
|
1268
|
+
|
|
1269
|
+
```ts
|
|
1270
|
+
type Placement = Data.TaggedEnum<{
|
|
1271
|
+
readonly "flows/core/Placement/Local": Readonly<Record<never, never>>
|
|
1272
|
+
readonly "flows/core/Placement/Client": Readonly<Record<never, never>>
|
|
1273
|
+
readonly "flows/core/Placement/Sandbox": Options
|
|
1274
|
+
readonly "flows/core/Placement/Remote": Options
|
|
1275
|
+
}>
|
|
1276
|
+
|
|
1277
|
+
interface Options {
|
|
1278
|
+
readonly image?: string | undefined
|
|
1279
|
+
readonly profile?: string | undefined
|
|
1280
|
+
readonly target?: string | undefined
|
|
1281
|
+
}
|
|
1282
|
+
```
|
|
1283
|
+
|
|
1284
|
+
### Placement.local, client, sandbox and remote
|
|
1285
|
+
|
|
1286
|
+
```ts
|
|
1287
|
+
const local: () => Placement
|
|
1288
|
+
const client: () => Placement
|
|
1289
|
+
const sandbox: (options?: Options) => Placement
|
|
1290
|
+
const remote: (options?: Options) => Placement
|
|
1291
|
+
```
|
|
1292
|
+
|
|
1293
|
+
The local process host, the viewer's browser, an isolated sandbox, and a remote control plane. `sandbox` and `remote` copy the options they are given, so a later mutation of the caller's object cannot change a recorded directive.
|
|
1294
|
+
|
|
1295
|
+
### Placement.Annotation
|
|
1296
|
+
|
|
1297
|
+
```ts
|
|
1298
|
+
const Annotation: Context.Key<Placement, Placement>
|
|
1299
|
+
```
|
|
1300
|
+
|
|
1301
|
+
The annotation key a declared placement is carried under, identified by `"@smthrs/plan/Placement"`. One key: `@smthrs/core`'s `Annotations.Placement` and `@smthrs/flow`'s `Flow.Placement` are both this object.
|
|
1302
|
+
|
|
1303
|
+
## CachePolicy
|
|
1304
|
+
|
|
1305
|
+
[src/CachePolicy.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/plan/src/CachePolicy.ts)
|
|
1306
|
+
|
|
1307
|
+
The one cache-policy model. [`@smthrs/flow`](/api/flow) publishes it as `CacheEnvironment.CachePolicy` and [`@smthrs/engine-store`](/api/engine-store) reads it off a dispatched action; [`@smthrs/patterns`](/api/smithers-patterns) declares it over a flow as `WithCache.Policy`. It was two declarations in those two packages, joined by nothing but a re-typed identifier string, so a field added on one side was invisible to the other.
|
|
1308
|
+
|
|
1309
|
+
The annotation key reads `"@smthrs/flow/Action/CachePolicy"`. The engine reads stored and dispatched actions by that identifier, so retagging it would make every policy already written invisible at dispatch. The identifier is treated as a wire value rather than as a location.
|
|
1310
|
+
|
|
1311
|
+
### CachePolicy.CacheScope
|
|
1312
|
+
|
|
1313
|
+
```ts
|
|
1314
|
+
const CacheScope: Schema.Literals<["run", "flow", "shared"]>
|
|
1315
|
+
type CacheScope = "run" | "flow" | "shared"
|
|
1316
|
+
```
|
|
1317
|
+
|
|
1318
|
+
How far a recorded sealed result may travel. `shared` is the content-addressed default: the key names the inputs and the environment, so any run on any host that would have produced the same bytes may reuse the row. `run` and `flow` fold one execution's or one flow's identity into the key, so a sibling never reads it. The three levels are the old `run | workflow | global` policy named after the current concepts.
|
|
1319
|
+
|
|
1320
|
+
### CachePolicy.CachePolicy
|
|
1321
|
+
|
|
1322
|
+
```ts
|
|
1323
|
+
const CachePolicy: Schema.Struct<{
|
|
1324
|
+
ttlMs: Schema.optionalKey<Schema.Int>
|
|
1325
|
+
scope: Schema.optionalKey<typeof CacheScope>
|
|
1326
|
+
}>
|
|
1327
|
+
```
|
|
1328
|
+
|
|
1329
|
+
`ttlMs` is a positive whole number of milliseconds bounding the age of a row the engine may serve; past it the dispatch executes again and journals `cache-expired`, so the refusal is durable evidence a replay reads rather than a fresh clock reading. Both fields are optional and both defaults are the pre-policy behaviour: no age bound, and the reach the composition already granted.
|
|
1330
|
+
|
|
1331
|
+
### CachePolicy.CachePolicyAnnotation and cachePolicyOf
|
|
1332
|
+
|
|
1333
|
+
```ts
|
|
1334
|
+
const CachePolicyAnnotation: Context.Key<CachePolicy, CachePolicy>
|
|
1335
|
+
const cachePolicyOf: (annotations: Context.Context<never>) => CachePolicy | undefined
|
|
1336
|
+
```
|
|
1337
|
+
|
|
1338
|
+
The key a declared policy is carried under, identified by `"@smthrs/flow/Action/CachePolicy"`, and the reader for it. One key: `@smthrs/flow`'s `CacheEnvironment.CachePolicyAnnotation` and `@smthrs/patterns`' `WithCache.CachePolicyAnnotation` are both this object.
|
|
1339
|
+
|
|
1340
|
+
### CachePolicy.annotate
|
|
1341
|
+
|
|
1342
|
+
```ts
|
|
1343
|
+
const annotate: <A extends { annotate: (key: typeof CachePolicyAnnotation, value: CachePolicy) => A }>(
|
|
1344
|
+
declaration: A,
|
|
1345
|
+
policy: CachePolicy
|
|
1346
|
+
) => A
|
|
1347
|
+
```
|
|
1348
|
+
|
|
1349
|
+
Attaches a policy to any declaration that carries annotations, answering a new one rather than mutating it. `@smthrs/flow` publishes it as `CacheEnvironment.withCache`.
|
|
1350
|
+
|
|
1351
|
+
## Repetition
|
|
1352
|
+
|
|
1353
|
+
[src/Repetition.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/plan/src/Repetition.ts)
|
|
1354
|
+
|
|
1355
|
+
The one bounded-repetition ceiling vocabulary. [`@smthrs/flow`](/api/flow)'s `Poll.make` takes it as `onTimeout`, for the attempt a poll gives up on, and [`@smthrs/patterns`](/api/smithers-patterns)' `Loop` takes it as `onMaxReached`, for the iteration a loop gives up on.
|
|
1356
|
+
|
|
1357
|
+
### Repetition.AtCeiling
|
|
1358
|
+
|
|
1359
|
+
```ts
|
|
1360
|
+
const AtCeiling: Schema.Literals<["fail", "return-last"]>
|
|
1361
|
+
type AtCeiling = "fail" | "return-last"
|
|
1362
|
+
```
|
|
1363
|
+
|
|
1364
|
+
`fail` raises the repetition's own typed failure at the ceiling; `return-last` settles with the last value it produced.
|
|
1365
|
+
|
|
1366
|
+
## GraphBuildError
|
|
1367
|
+
|
|
1368
|
+
[src/GraphBuildError.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/plan/src/GraphBuildError.ts)
|
|
1369
|
+
|
|
1370
|
+
The refusals a plan-time build raises instead of producing a wrong plan. Each carries the site, `node` plus the recorded property `path`, and states the fix in `message`, because the author reading it is mid-body.
|
|
1371
|
+
|
|
1372
|
+
### GraphBuildError.GraphBuildError
|
|
1373
|
+
|
|
1374
|
+
```ts
|
|
1375
|
+
class GraphBuildError extends Schema.TaggedError<GraphBuildError>()("@smthrs/plan/GraphBuildError", {
|
|
1376
|
+
code: typeof GraphBuildErrorCode
|
|
1377
|
+
node: Schema.String
|
|
1378
|
+
path: Schema.Array$<Schema.String>
|
|
1379
|
+
message: Schema.String
|
|
1380
|
+
})
|
|
1381
|
+
```
|
|
1382
|
+
|
|
1383
|
+
`node` is the node reference the failure belongs to: a planned value's origin node, an `all` member name, or a branch arm. `path` is the property path recorded on a planned value before it was misused, and the payload path for a payload refusal. It is empty for every other code.
|
|
1384
|
+
|
|
1385
|
+
### GraphBuildError.GraphBuildErrorCode
|
|
1386
|
+
|
|
1387
|
+
| `code` | Meaning |
|
|
1388
|
+
| ----------------------------- | ---------------------------------------------------------------------------------------------- |
|
|
1389
|
+
| `planned_value_computed` | a body computed on a step result |
|
|
1390
|
+
| `invalid_all_member` | `Node.all` received a non-node member |
|
|
1391
|
+
| `invalid_join` | a race, any, or quorum has a non-node member, no members, or an unreachable count |
|
|
1392
|
+
| `invalid_continuation` | a branch arm, catch arm, or continuation did not return a node |
|
|
1393
|
+
| `recursion_requires_boundary` | a flow calls itself inline instead of using a trampoline handoff or an explicit child boundary |
|
|
1394
|
+
| `placement_requires_boundary` | an inline call's callee declares a placement the enclosing flow cannot satisfy |
|
|
1395
|
+
| `cyclic_payload` | a payload contains itself, so no plan could serialize or hash it |
|
|
1396
|
+
| `payload_too_deep` | a payload is nested past the build bound |
|
|
1397
|
+
| `graph_too_deep` | authored topology is nested past the build bound |
|
|
1398
|
+
| `duplicate_node` | two structural graph addresses resolve to one durable node id |
|
|
1399
|
+
| `invalid_priority` | `Node.priority` received a value that is not a safe integer |
|
|
1400
|
+
| `invalid_payload` | a payload member cannot be captured as inert JSON without executing code or losing identity |
|
|
1401
|
+
| `unstable_callback` | a callback has process-local identity in a build that requires stable callbacks |
|
|
1402
|
+
| `effect_outside_envelope` | a declaration reads or writes a path the enclosing effect envelope does not cover |
|
|
1403
|
+
| `effect_mode_widening` | an `expected` declaration sits inside a `hermetic` envelope |
|
|
1404
|
+
| `effect_tier_widening` | a declaration's tier is less reversible than the enclosing envelope's |
|
|
1405
|
+
| `capability_outside_grant` | a called flow or action requires a capability the caller does not hold, so the call drops it |
|
|
1406
|
+
| `write_conflict` | two work nodes overlap under `onConflict: "fail"` |
|
|
1407
|
+
| `missing_key_material` | a node reached key compilation without any |
|
|
1408
|
+
| `dependency_cycle` | a dependency set cannot be ordered |
|
|
1409
|
+
| `plan_too_large` | a node, edge, conflict, or effect-path limit was crossed |
|
|
1410
|
+
| `payload_too_large` | one plan value expands to more members than the bound |
|
|
1411
|
+
| `invalid_node` | a malformed node AST |
|
|
1412
|
+
|
|
1413
|
+
`GraphBuildErrorCode` is a closed schema literal, so a caller may switch on it and a new refusal is a deliberate addition rather than a new free-form string. This package raises `planned_value_computed`, `invalid_all_member`, `invalid_join`, `invalid_continuation`, `invalid_priority`, `invalid_payload`, and `cyclic_payload`. [`@smthrs/flow`](/api/flow)'s graph walk raises `recursion_requires_boundary`, `placement_requires_boundary`, `payload_too_deep`, `graph_too_deep`, `duplicate_node`, `unstable_callback`, and the four effect-authority codes. [`@smthrs/core`](/api/core)'s graph builder raises the last six, plus `payload_too_deep`, `graph_too_deep`, `duplicate_node`, `invalid_payload`, and the four effect-authority codes; it uses this error rather than a second class of its own. `unstable_callback` is raised when `Graph.build` runs with `callbackIdentity: "stable"` and a callback carries no `Node.capture` declaration; declare its complete inert captures, including the version of any imported implementation, so the callback keys by content instead of by process.
|
|
1414
|
+
|
|
1415
|
+
The four effect-authority codes are recorded while a walk checks every
|
|
1416
|
+
declaration against the envelope enclosing it, using the narrowing rule
|
|
1417
|
+
[`Effects.narrow`](#effectsnarrow) defines.
|
|
1418
|
+
|
|
1419
|
+
A `write_conflict` names the first of the overlapping pair in `node` and both in
|
|
1420
|
+
its `message`; `Graph.conflicts` carries the pair as data.
|
|
1421
|
+
|
|
1422
|
+
### GraphBuildError.isFatalDiagnostic
|
|
1423
|
+
|
|
1424
|
+
```ts
|
|
1425
|
+
const isFatalDiagnostic: (diagnostic: GraphBuildError) => boolean
|
|
1426
|
+
```
|
|
1427
|
+
|
|
1428
|
+
Whether a recorded refusal blocks the drafts a plan is compiled from. Every code
|
|
1429
|
+
is fatal except `capability_outside_grant`: a capability the caller does not
|
|
1430
|
+
grant is DROPPED, so the callee runs with LESS authority than it asked for,
|
|
1431
|
+
which is the safe direction. The author still needs to see it, because a flow
|
|
1432
|
+
that silently loses a capability fails later at the action that needed it, so
|
|
1433
|
+
the build records the refusal and `Graph.drafts` still hands the drafts over.
|
|
1434
|
+
|
|
1435
|
+
## PlanDiff
|
|
1436
|
+
|
|
1437
|
+
[src/PlanDiff.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/plan/src/PlanDiff.ts)
|
|
1438
|
+
|
|
1439
|
+
The verdict is the key: two nodes with the same id and the same key are the same step. The attribution, `changed: ["body", "input[1]"]`, is a report for a human, derived by comparing declarations field by field, and is deliberately part of no digest. Labels mirror the hashed declaration: `kind` (the effect tier, which selects the key namespace), `body`, `layers`, `capabilities`, `effects`, `version`, `nondeterministic`, `placement`, and `input[n]`, including `input[n]` entries whose declaration is unchanged but whose referenced node itself re-keyed. A node re-keyed purely by an upstream edit is therefore attributed to the input position that references it, even behind an unprojected `Pending`, rather than reported as nothing changed.
|
|
1440
|
+
|
|
1441
|
+
Each compared field is projected through the same JSON mirror the keys are derived from, so two `Date` bodies a generation apart attribute to `body` rather than to nothing. The projection runs no accessor, and a field it refuses compares by an identity token scoped to that node and field, so `diff` stays a total function even for a value canonical serialization would reject.
|
|
1442
|
+
|
|
1443
|
+
### PlanDiff.PlanDiff
|
|
1444
|
+
|
|
1445
|
+
```ts
|
|
1446
|
+
interface PlanDiff {
|
|
1447
|
+
readonly added: ReadonlyArray<string>
|
|
1448
|
+
readonly removed: ReadonlyArray<string>
|
|
1449
|
+
readonly rekeyed: ReadonlyArray<Rekeyed>
|
|
1450
|
+
readonly unchanged: ReadonlyArray<string>
|
|
1451
|
+
}
|
|
1452
|
+
```
|
|
1453
|
+
|
|
1454
|
+
What changed between two plans of the same flow, as node ids.
|
|
1455
|
+
|
|
1456
|
+
### PlanDiff.Rekeyed
|
|
1457
|
+
|
|
1458
|
+
```ts
|
|
1459
|
+
interface Rekeyed {
|
|
1460
|
+
readonly id: string
|
|
1461
|
+
readonly from: string
|
|
1462
|
+
readonly to: string
|
|
1463
|
+
readonly changed: ReadonlyArray<string>
|
|
1464
|
+
}
|
|
1465
|
+
```
|
|
1466
|
+
|
|
1467
|
+
A node whose key moved, with the field labels that moved it. `changed` is empty only when none of the compared fields moved.
|
|
1468
|
+
|
|
1469
|
+
### PlanDiff.diff
|
|
1470
|
+
|
|
1471
|
+
```ts
|
|
1472
|
+
const diff: (previous: Plan.Plan, next: Plan.Plan) => PlanDiff
|
|
1473
|
+
```
|
|
1474
|
+
|
|
1475
|
+
Compares a plan against the last plan for the same flow. Pure, total, and free of requirements.
|