@smthrs/flow 1.0.0-rc.7 → 1.0.0-rc.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/cjs/Deadline.d.ts +5 -0
- package/dist/cjs/Deadline.d.ts.map +1 -1
- package/dist/cjs/Deadline.js +9 -1
- package/dist/cjs/Deadline.js.map +2 -2
- package/dist/cjs/DurableClock.d.ts +9 -10
- package/dist/cjs/DurableClock.d.ts.map +1 -1
- package/dist/cjs/DurableClock.js +19 -2
- package/dist/cjs/DurableClock.js.map +2 -2
- package/dist/cjs/Flow/make.d.ts +2 -1
- package/dist/cjs/Flow/make.d.ts.map +1 -1
- package/dist/cjs/Flow/make.js +3 -0
- package/dist/cjs/Flow/make.js.map +2 -2
- package/dist/cjs/HumanTask.d.ts.map +1 -1
- package/dist/cjs/HumanTask.js +2 -2
- package/dist/cjs/HumanTask.js.map +2 -2
- package/dist/cjs/Poll.d.ts +3 -1
- package/dist/cjs/Poll.d.ts.map +1 -1
- package/dist/cjs/Poll.js +6 -2
- package/dist/cjs/Poll.js.map +2 -2
- package/dist/cjs/Sleep.d.ts.map +1 -1
- package/dist/cjs/Sleep.js +15 -7
- package/dist/cjs/Sleep.js.map +2 -2
- package/dist/esm/Deadline.d.ts +5 -0
- package/dist/esm/Deadline.d.ts.map +1 -1
- package/dist/esm/Deadline.js +13 -1
- package/dist/esm/Deadline.js.map +1 -1
- package/dist/esm/DurableClock.d.ts +9 -10
- package/dist/esm/DurableClock.d.ts.map +1 -1
- package/dist/esm/DurableClock.js +49 -2
- package/dist/esm/DurableClock.js.map +1 -1
- package/dist/esm/Flow/make.d.ts +2 -1
- package/dist/esm/Flow/make.d.ts.map +1 -1
- package/dist/esm/Flow/make.js +5 -0
- package/dist/esm/Flow/make.js.map +1 -1
- package/dist/esm/HumanTask.d.ts.map +1 -1
- package/dist/esm/HumanTask.js +5 -3
- package/dist/esm/HumanTask.js.map +1 -1
- package/dist/esm/Poll.d.ts +3 -1
- package/dist/esm/Poll.d.ts.map +1 -1
- package/dist/esm/Poll.js +10 -3
- package/dist/esm/Poll.js.map +1 -1
- package/dist/esm/Sleep.d.ts.map +1 -1
- package/dist/esm/Sleep.js +30 -4
- package/dist/esm/Sleep.js.map +1 -1
- package/docs/README.md +3 -1
- package/docs/api.md +21 -17
- package/docs/guides/ask-a-person.md +5 -5
- package/docs/guides/inspect-the-plan.md +1 -1
- package/docs/guides/poll-until-ready.md +3 -1
- package/docs/guides/wait-for-a-deadline.md +5 -5
- package/docs/installation.md +14 -9
- package/docs/quickstart.md +5 -1
- package/docs/reference/flow.md +9 -5
- package/package.json +7 -7
- package/src/Deadline.ts +17 -1
- package/src/DurableClock.ts +45 -2
- package/src/Flow/make.ts +7 -1
- package/src/HumanTask.ts +5 -3
- package/src/Poll.ts +10 -4
- package/src/Sleep.ts +33 -4
package/docs/api.md
CHANGED
|
@@ -44,7 +44,7 @@ The [export reference](./reference/flow.md) lists every exported symbol with its
|
|
|
44
44
|
|
|
45
45
|
## `Flow`
|
|
46
46
|
|
|
47
|
-
`Flow.make(tag, options)` accepts struct payload fields, success and error schemas, an optional `idempotencyKey`, annotations, a `suspendedRetryPolicy`, a `maxRounds` bound for trampoline lineages, and a `deadline` for one execution. Four literals lower into the annotation bag rather than staying options: `description`, `capabilities`, `effects`, the effect envelope from [`@smthrs/plan`](/api/plan#effects), and `modelInvocable`, which says whether a catalog may offer this flow to a model and defaults to `true`. They are literals so a catalog can project a discovered flow's authority from source text without importing the module, and `Graph.build` refuses a composition beneath the flow that reads or writes outside that envelope, loosens its mode, or raises its tier. Declare exactly one of `body` or `prompt`. A prompt declaration lowers to one ordinary action, exposed as `flow.action`; its host supplies the implementation through `flow.action.toLayer`. The typed `flow.prompt` renderer runs inside that implementation, never during planning. Optional `model`, `effort`, `flows`, `system`, and `chat` fields retain host metadata. See [Prompt-backed flows](./guides/use-a-prompt.md). `maxRounds` must be a positive safe integer and `deadline` a positive finite duration; `Flow.make` throws a `RangeError` when either is not. The deadline counts from the execution's journaled first start, so a resume after a restart honors the original deadline, and expiry settles the execution with the `DeadlineExceeded` defect whether it was running or parked. A trampoline lineage is one run: every round after a handoff expires at the originator's start plus the originator's deadline, whatever the round itself declares.
|
|
47
|
+
`Flow.make(tag, options)` accepts struct payload fields, success and error schemas, an optional `idempotencyKey`, annotations, a `suspendedRetryPolicy`, a `maxRounds` bound for trampoline lineages, and a `deadline` for one execution. Four literals lower into the annotation bag rather than staying options: `description`, `capabilities`, `effects`, the effect envelope from [`@smthrs/plan`](/api/plan#effects), and `modelInvocable`, which says whether a catalog may offer this flow to a model and defaults to `true`. They are literals so a catalog can project a discovered flow's authority from source text without importing the module, and `Graph.build` refuses a composition beneath the flow that reads or writes outside that envelope, loosens its mode, or raises its tier. Declare exactly one of `body` or `prompt`. A prompt declaration lowers to one ordinary action, exposed as `flow.action`; its host supplies the implementation through `flow.action.toLayer`. The typed `flow.prompt` renderer runs inside that implementation, never during planning. Optional `model`, `effort`, `flows`, `system`, and `chat` fields retain host metadata. See [Prompt-backed flows](./guides/use-a-prompt.md). `maxRounds` must be a positive safe integer and `deadline` a positive finite duration of at most `Number.MAX_SAFE_INTEGER` milliseconds; `Flow.make` throws a `RangeError` when either is not. The deadline counts from the execution's journaled first start, so a resume after a restart honors the original deadline, and expiry settles the execution with the `DeadlineExceeded` defect whether it was running or parked. A trampoline lineage is one run: every round after a handoff expires at the originator's start plus the originator's deadline, whatever the round itself declares.
|
|
48
48
|
|
|
49
49
|
The returned definition exposes:
|
|
50
50
|
|
|
@@ -157,6 +157,8 @@ Sealed idempotency identity has two forms. A string is namespaced by the action
|
|
|
157
157
|
| `DurableQueue` | `make`, `process`, `makeWorker`, and `worker` over Effect's `PersistedQueue` |
|
|
158
158
|
| `Deadline` | `start`, `within`, and `bound`: the run deadline `Flow.make` applies, for a host that bounds a run by an approved deadline |
|
|
159
159
|
|
|
160
|
+
`DurableClock.make` and `DurableClock.sleep` throw a `RangeError` for a malformed, infinite, or negative duration, or one longer than `Number.MAX_SAFE_INTEGER` milliseconds: a durable store records a clock's due time as a safe integer. A durable sleep that would wake after epoch millisecond `Number.MAX_SAFE_INTEGER` dies with a `RangeError` instead of arming its clock.
|
|
161
|
+
|
|
160
162
|
A deferred token encodes the flow name, the execution id, and the deferred name, so another process can complete the correct durable address. `TokenInvalid` is the typed failure every completion surface returns for a token that does not parse or that names a different deferred than the one it was submitted through. Completing a deferred is first-writer-wins: the first recorded exit is the one every later read replays.
|
|
161
163
|
|
|
162
164
|
`DurableQueue.process` offers a payload, attaches a token, and suspends until a worker records the handler's exit against it. Its `retrySchedule` option bounds how a failing offer is retried; the default retries with exponential backoff, capped at one minute, and never gives up before the call dies. `DurableQueue.worker` and `makeWorker` take a `concurrency` that must be a positive safe integer.
|
|
@@ -219,11 +221,11 @@ Sleep.action.call({ millis: 60_000 })
|
|
|
219
221
|
|
|
220
222
|
A payload names exactly one deadline: a relative `millis`, or an absolute `until` in epoch milliseconds. `SleepRequestInvalid` is the typed refusal, with three codes:
|
|
221
223
|
|
|
222
|
-
| Code | Meaning
|
|
223
|
-
| -------------------- |
|
|
224
|
-
| `missing_deadline` | the payload named neither `millis` nor `until`
|
|
225
|
-
| `ambiguous_deadline` | it named both, which are the same fact stated two ways
|
|
226
|
-
| `invalid_deadline` | it named a number that is not a length of time: a non-finite or negative `millis`, a non-finite `until`,
|
|
224
|
+
| Code | Meaning |
|
|
225
|
+
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
226
|
+
| `missing_deadline` | the payload named neither `millis` nor `until` |
|
|
227
|
+
| `ambiguous_deadline` | it named both, which are the same fact stated two ways |
|
|
228
|
+
| `invalid_deadline` | it named a number that is not a length of time: a non-finite or negative `millis`, a non-finite `until`, an addition that overflows, or a deadline after epoch millisecond `Number.MAX_SAFE_INTEGER` |
|
|
227
229
|
|
|
228
230
|
Two waits of the same length are two waits: each call is its own node with its own identity, so `sleep` followed by `sleep` waits twice. A deadline that has already passed settles the node instead of parking it, because a run that resumes after its own deadline has to make progress.
|
|
229
231
|
|
|
@@ -254,7 +256,7 @@ yield * DurableDeferred.succeed(gate, { token, value: { approved: true } })
|
|
|
254
256
|
|
|
255
257
|
`delayMillis` is the exported schedule function, `CheckResult(result)` is the success schema a check action declares, `Failure` is the union a poll's rounds can fail with, and `Poll.layer` implements the `system/poll-exhausted` step. `PollExhausted` carries the stable code `poll_exhausted`.
|
|
256
258
|
|
|
257
|
-
`Poll.make` refuses a schedule no clock can keep, with a `RangeError` naming the option that is wrong: an `intervalMs` that is not finite or is negative becomes a `system/sleep` node whose timer never fires, and a `maxAttempts` below one whole attempt reaches `Flow.make` as a complaint about `maxRounds`, an option the author never wrote. The check is on the schedule rather than on the interval alone, because `delayMillis` multiplies the interval by the backoff: `{ intervalMs: 1000, maxAttempts: 2000, backoff: "exponential" }` states three finite options and still asks for a wait of `Infinity`. The last wait a poll can arm, the one before the final attempt, has to be a length too, since the attempt at the budget gives up rather than sleeps.
|
|
259
|
+
`Poll.make` refuses a schedule no clock can keep, with a `RangeError` naming the option that is wrong: an `intervalMs` that is not finite or is negative becomes a `system/sleep` node whose timer never fires, and a `maxAttempts` below one whole attempt reaches `Flow.make` as a complaint about `maxRounds`, an option the author never wrote. The check is on the schedule rather than on the interval alone, because `delayMillis` multiplies the interval by the backoff: `{ intervalMs: 1000, maxAttempts: 2000, backoff: "exponential" }` states three finite options and still asks for a wait of `Infinity`. The last wait a poll can arm, the one before the final attempt, has to be a length too, since the attempt at the budget gives up rather than sleeps, and no longer than `Number.MAX_SAFE_INTEGER` milliseconds, the latest deadline a durable timer records.
|
|
258
260
|
|
|
259
261
|
### Bounding a check that can hang
|
|
260
262
|
|
|
@@ -327,15 +329,15 @@ A failed agent run, and a launch the executor refused, journal the fault on `con
|
|
|
327
329
|
|
|
328
330
|
`HumanTask.action` is the declared `system/human-task` step: a typed question with re-asking and a deadline, built on `WaitFor`'s wait points and `DurableClock`.
|
|
329
331
|
|
|
330
|
-
| Payload field | Meaning
|
|
331
|
-
| ------------- |
|
|
332
|
-
| `name` | addresses the question. Two calls naming one question in one execution await one answer
|
|
333
|
-
| `kind` | `ask` (prose), `confirm` (a boolean), `select` (one of `options`), `json` (a value `schema` accepts)
|
|
334
|
-
| `prompt` | what the person is asked
|
|
335
|
-
| `options` | the choices a `select` offers
|
|
336
|
-
| `schema` | a JSON Schema in the bounded subset, valid only with `kind: "json"`
|
|
337
|
-
| `maxAttempts` | how many answers may be refused before the task fails. Defaults to `defaultMaxAttempts` (10)
|
|
338
|
-
| `timeoutMs` | how long the question stays open, across every attempt. A
|
|
332
|
+
| Payload field | Meaning |
|
|
333
|
+
| ------------- | -------------------------------------------------------------------------------------------------------------------- |
|
|
334
|
+
| `name` | addresses the question. Two calls naming one question in one execution await one answer |
|
|
335
|
+
| `kind` | `ask` (prose), `confirm` (a boolean), `select` (one of `options`), `json` (a value `schema` accepts) |
|
|
336
|
+
| `prompt` | what the person is asked |
|
|
337
|
+
| `options` | the choices a `select` offers |
|
|
338
|
+
| `schema` | a JSON Schema in the bounded subset, valid only with `kind: "json"` |
|
|
339
|
+
| `maxAttempts` | how many answers may be refused before the task fails. Defaults to `defaultMaxAttempts` (10) |
|
|
340
|
+
| `timeoutMs` | how long the question stays open, across every attempt. A number of milliseconds from 0 to `Number.MAX_SAFE_INTEGER` |
|
|
339
341
|
|
|
340
342
|
The bounded schema subset is `type` (`object`, `array`, `string`, `number`, `integer`, `boolean`, `null`), `enum`, `properties`, `required`, `items`, `nullable`, `description`, and `title`. `enum` members may be any JSON value, including objects and arrays, and membership is compared structurally rather than by reference. A schema supplied with any kind other than `json` is refused as `request_invalid`, because nothing would check it.
|
|
341
343
|
|
|
@@ -347,7 +349,7 @@ declared `options` or `schema`. It describes the question without including an
|
|
|
347
349
|
answer. Durable readers use it to render pending questions, including waits
|
|
348
350
|
inside child executions; routing still uses the wait token.
|
|
349
351
|
|
|
350
|
-
`HumanTaskFailed` carries `code`, `task`, `attempts`, the `rejections` it collected, and a message. `request_invalid` refuses an unanswerable question before anyone is asked: a `select` with no options, an attempt budget below one or above the documented ceiling, a `timeoutMs` that is not a length of time
|
|
352
|
+
`HumanTaskFailed` carries `code`, `task`, `attempts`, the `rejections` it collected, and a message. `request_invalid` refuses an unanswerable question before anyone is asked: a `select` with no options, an attempt budget below one or above the documented ceiling, a `timeoutMs` that is not a length of time or is above `Number.MAX_SAFE_INTEGER`, a schema outside the subset, a schema past the depth or node bound, or a schema supplied with the wrong kind. `rejected` means the budget was spent on answers the task refused. `timeout` means the deadline passed with the question open.
|
|
351
353
|
|
|
352
354
|
Sizes are bounded so one question cannot grow a durable record without limit. The attempt budget is refused above `maxAttemptBudget`, the schema is refused past `maxSchemaDepth` levels or `maxSchemaNodes` nodes, an answer is refused past `maxAnswerNodes` visited values, a value echoed into a rejection message is truncated at `maxDiagnosticChars` with an explicit marker, and the retained `rejections` array stops at `maxRetainedRejectionChars` and records how many further rejections it omitted. The per-attempt journal step still records its own full reason: only the accumulated array is capped, because a refused answer stays recorded so the record shows what was actually said.
|
|
353
355
|
|
|
@@ -365,6 +367,8 @@ A `RetryPolicy` is a plain value, so the next retry delay is derived from a pers
|
|
|
365
367
|
|
|
366
368
|
`decide` classifies non-retryable errors here and nowhere else, by the policy's declared tags and by `defaultNonRetryable`, which lists the integrity verdicts that must reach the driver unretried under every policy. Exhaustion and expiration preserve the final declared failure. The older `RetryPolicyExpired` and `RetryAttemptsExhausted` schemas remain available for interpreting historical outcomes.
|
|
367
369
|
|
|
370
|
+
The package's [`examples/retry-and-stall.ts`](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/flow/examples/retry-and-stall.ts) runs `RetryPolicy.decide` over five failed attempts and `Stall.observe` over a repair loop's rounds, with no runtime. Run it with `node examples/retry-and-stall.ts` from the package directory.
|
|
371
|
+
|
|
368
372
|
## `StepIdentity`
|
|
369
373
|
|
|
370
374
|
| Export | Purpose |
|
|
@@ -139,11 +139,11 @@ so a restart between the park and the answer resumes on the same token.
|
|
|
139
139
|
`HumanTask.HumanTaskFailed` carries `code`, `task`, `attempts`, the `rejections`
|
|
140
140
|
it collected, and a message:
|
|
141
141
|
|
|
142
|
-
| Code | Meaning
|
|
143
|
-
| ----------------- |
|
|
144
|
-
| `request_invalid` | The question was unanswerable before anyone was asked: a `select` with no options, an attempt budget below one or above `HumanTask.maxAttemptBudget`, a `timeoutMs` that is not a length of time
|
|
145
|
-
| `rejected` | The attempt budget was spent on answers the task refused.
|
|
146
|
-
| `timeout` | The deadline passed with the question still open.
|
|
142
|
+
| Code | Meaning |
|
|
143
|
+
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
144
|
+
| `request_invalid` | The question was unanswerable before anyone was asked: a `select` with no options, an attempt budget below one or above `HumanTask.maxAttemptBudget`, a `timeoutMs` that is not a length of time or is above `Number.MAX_SAFE_INTEGER`, or a schema outside the subset or past its depth or node bound. |
|
|
145
|
+
| `rejected` | The attempt budget was spent on answers the task refused. |
|
|
146
|
+
| `timeout` | The deadline passed with the question still open. |
|
|
147
147
|
|
|
148
148
|
`HumanTask.HumanAnswerInvalid` is the separate refusal an answer earns before it
|
|
149
149
|
can consume durable storage, with `answer_invalid` for an answer outside the
|
|
@@ -19,7 +19,7 @@ engine, database, or jj: planning reads declarations only. They import
|
|
|
19
19
|
add both at the version of `@smthrs/flows` you have:
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
|
-
npm install @smthrs/flow@1.0.0-rc.
|
|
22
|
+
npm install @smthrs/flow@1.0.0-rc.9 @smthrs/plan@1.0.0-rc.9
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
`@smthrs/flows` re-exports the same modules, so `import { Graph } from "@smthrs/flows"`
|
|
@@ -107,7 +107,9 @@ because the backoff multiplies the interval:
|
|
|
107
107
|
`{ intervalMs: 1000, maxAttempts: 2000, backoff: "exponential" }` states three
|
|
108
108
|
finite options and still asks for a wait of `Infinity`. The check looks at the
|
|
109
109
|
last wait a poll can arm, the one before the final attempt, since the attempt at
|
|
110
|
-
the budget gives up rather than sleeps.
|
|
110
|
+
the budget gives up rather than sleeps. That wait must also be at most
|
|
111
|
+
`Number.MAX_SAFE_INTEGER` milliseconds, the latest deadline a durable timer
|
|
112
|
+
records, so a one-millisecond interval doubling allows at most 54 attempts.
|
|
111
113
|
|
|
112
114
|
## Handle exhaustion
|
|
113
115
|
|
|
@@ -62,11 +62,11 @@ milliseconds, and exactly one of them. `Sleep.SleepRequestInvalid` is the typed
|
|
|
62
62
|
refusal, so a body can recover from it with `Node.catch` like any other declared
|
|
63
63
|
failure:
|
|
64
64
|
|
|
65
|
-
| Code | What was wrong
|
|
66
|
-
| -------------------- |
|
|
67
|
-
| `missing_deadline` | The payload named neither `millis` nor `until`.
|
|
68
|
-
| `ambiguous_deadline` | It named both, which are the same fact stated twice.
|
|
69
|
-
| `invalid_deadline` | It named a number that is not a length of time: a non-finite or negative `millis`, a non-finite `until`,
|
|
65
|
+
| Code | What was wrong |
|
|
66
|
+
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
67
|
+
| `missing_deadline` | The payload named neither `millis` nor `until`. |
|
|
68
|
+
| `ambiguous_deadline` | It named both, which are the same fact stated twice. |
|
|
69
|
+
| `invalid_deadline` | It named a number that is not a length of time: a non-finite or negative `millis`, a non-finite `until`, an addition that overflows, or a deadline after epoch millisecond `Number.MAX_SAFE_INTEGER`. |
|
|
70
70
|
|
|
71
71
|
Two behaviors are worth knowing before you write the body:
|
|
72
72
|
|
package/docs/installation.md
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: "Installation"
|
|
3
|
-
description: "Install @smthrs/flow, its runtime requirements and effect range, its import forms, and the packages a runnable composition adds."
|
|
3
|
+
description: "Install @smthrs/flow from npm, its runtime requirements and effect range, its import forms, and the packages a runnable composition adds."
|
|
4
4
|
sidebar:
|
|
5
5
|
order: 1
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
## Install the package
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
```bash
|
|
11
|
+
npm install @smthrs/flow@next effect
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The `next` tag carries the 1.0 release candidates.
|
|
11
15
|
|
|
12
16
|
`effect` is a peer dependency of this package, in the range `^4.0.4`. Add one
|
|
13
17
|
`effect` in that range to your own dependencies: your declarations import
|
|
@@ -23,17 +27,18 @@ from, and the canonical JSON checks applied to values.
|
|
|
23
27
|
|
|
24
28
|
## Use the libraries
|
|
25
29
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
Every `@smthrs` library installs from npm the same way. Name each package your
|
|
31
|
+
code imports with the `next` tag, and add one `effect` in the `^4.0.4` range
|
|
32
|
+
the libraries peer. The [Quickstart](./quickstart.md) program needs these:
|
|
29
33
|
|
|
30
34
|
```bash
|
|
31
|
-
|
|
32
|
-
echo '{ "type": "module" }' > package.json
|
|
33
|
-
ln -s /path/to/smithers/examples/node_modules node_modules
|
|
35
|
+
npm install @smthrs/flow@next @smthrs/engine@next @smthrs/plan@next @effect/platform-node effect
|
|
34
36
|
```
|
|
35
37
|
|
|
36
|
-
|
|
38
|
+
`next` resolves every `@smthrs` package to the same release candidate. With
|
|
39
|
+
pnpm 11, name that version instead of the tag; the
|
|
40
|
+
[Flows installation](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/docs/installation.md)
|
|
41
|
+
explains why.
|
|
37
42
|
|
|
38
43
|
## Import forms
|
|
39
44
|
|
package/docs/quickstart.md
CHANGED
|
@@ -15,7 +15,11 @@ A runnable copy of this program is published in the Smithers examples,
|
|
|
15
15
|
## Prerequisites
|
|
16
16
|
|
|
17
17
|
- Node.js 26.4.0 or later.
|
|
18
|
-
- A
|
|
18
|
+
- A project with the packages installed:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install @smthrs/flow@next @smthrs/engine@next @smthrs/plan@next @effect/platform-node effect
|
|
22
|
+
```
|
|
19
23
|
|
|
20
24
|
## Declare the action and the flow
|
|
21
25
|
|
package/docs/reference/flow.md
CHANGED
|
@@ -5,7 +5,11 @@ area: api
|
|
|
5
5
|
order: 10
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
Install the package with its `effect` peer
|
|
8
|
+
Install the package with its `effect` peer:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install @smthrs/flow@next effect
|
|
12
|
+
```
|
|
9
13
|
|
|
10
14
|
## Entry points
|
|
11
15
|
|
|
@@ -325,7 +329,7 @@ A directory output captured and replayed as one tree artifact.
|
|
|
325
329
|
- **Signature:** `start(options: { readonly flowName: string; readonly deadline?: Duration.Input | undefined; readonly startedAtMs?: number | undefined }): Effect.Effect<LineageDeadline | undefined, never, FlowRuntime | FlowInstance | Crypto.Crypto>`
|
|
326
330
|
- **Since:** `1.0.0`
|
|
327
331
|
|
|
328
|
-
Starts the current execution's deadline: the lineage deadline the round inherited from a handoff, or `deadline` counted from the execution's journaled first start (or from `startedAtMs`, when the host holds a durable start of its own), with a durable clock armed at it. Answers `undefined` when neither applies. Dies with a `RangeError` when `deadline` is not a positive finite duration. Start at most one deadline per execution.
|
|
332
|
+
Starts the current execution's deadline: the lineage deadline the round inherited from a handoff, or `deadline` counted from the execution's journaled first start (or from `startedAtMs`, when the host holds a durable start of its own), with a durable clock armed at it. Answers `undefined` when neither applies. Dies with a `RangeError` when `deadline` is not a positive finite duration, `startedAtMs` is not finite, or the deadline would expire after epoch millisecond `Number.MAX_SAFE_INTEGER`. Start at most one deadline per execution.
|
|
329
333
|
|
|
330
334
|
### `Deadline.remainingMs`
|
|
331
335
|
|
|
@@ -362,7 +366,7 @@ A durable flow timer: a name, a duration, and the deferred that is completed whe
|
|
|
362
366
|
- **Signature:** `make(options: { readonly name: string; readonly duration: Duration.Input }): DurableClock`
|
|
363
367
|
- **Since:** `0.1.0`
|
|
364
368
|
|
|
365
|
-
Declares one durable clock.
|
|
369
|
+
Declares one durable clock. Throws a `RangeError` when the duration is malformed, infinite, negative, or longer than `Number.MAX_SAFE_INTEGER` milliseconds: a durable store records a clock's due time as a safe integer.
|
|
366
370
|
|
|
367
371
|
### `DurableClock.sleep`
|
|
368
372
|
|
|
@@ -370,7 +374,7 @@ Declares one durable clock. The duration must be finite and not negative.
|
|
|
370
374
|
- **Default:** `inMemoryThreshold` is 60 seconds
|
|
371
375
|
- **Since:** `0.1.0`
|
|
372
376
|
|
|
373
|
-
Waits inside a flow. A duration at or below `inMemoryThreshold` runs as an in-memory action. A longer duration schedules a durable clock, so the wait outlives the process holding it.
|
|
377
|
+
Waits inside a flow. A duration at or below `inMemoryThreshold` runs as an in-memory action. A longer duration schedules a durable clock, so the wait outlives the process holding it. Throws a `RangeError` for a duration `make` refuses or a malformed, infinite, or negative threshold. A durable sleep that would wake after epoch millisecond `Number.MAX_SAFE_INTEGER` dies with a `RangeError` instead of arming its clock.
|
|
374
378
|
|
|
375
379
|
## DurableDeferred
|
|
376
380
|
|
|
@@ -736,7 +740,7 @@ Declares the host's execution-id source as a layer. Callers that name an `execut
|
|
|
736
740
|
- **Required:** `payload` and exactly one of `body` or `prompt`
|
|
737
741
|
- **Since:** `0.1.0`
|
|
738
742
|
|
|
739
|
-
Creates a durable flow definition. The `body` is the flow's one behavior, evaluated at plan time only, and it must be pure: it may not read mutable module state, clocks, random values, services, or values captured outside `payload`. A typed `prompt(payload)` lowers to one ordinary action named `<tag>/prompt`; its returned `PromptFlow` exposes the declaration as `action` and the renderer as `prompt`. The renderer runs only when the host implementation calls it. Optional `model`, `effort`, `flows`, `system`, and `chat` fields retain host metadata; annotation copies preserve them. `Flow.make` throws a `RangeError` when `maxRounds` is not a positive safe integer or `deadline` is not a positive finite duration. `deadline` bounds one execution, counted from its journaled first start, so a resume after a restart honors the original deadline; on expiry the execution settles with `Flow.DeadlineExceeded` whether it was running or parked.
|
|
743
|
+
Creates a durable flow definition. The `body` is the flow's one behavior, evaluated at plan time only, and it must be pure: it may not read mutable module state, clocks, random values, services, or values captured outside `payload`. A typed `prompt(payload)` lowers to one ordinary action named `<tag>/prompt`; its returned `PromptFlow` exposes the declaration as `action` and the renderer as `prompt`. The renderer runs only when the host implementation calls it. Optional `model`, `effort`, `flows`, `system`, and `chat` fields retain host metadata; annotation copies preserve them. `Flow.make` throws a `RangeError` when `maxRounds` is not a positive safe integer or `deadline` is not a positive finite duration of at most `Number.MAX_SAFE_INTEGER` milliseconds. `deadline` bounds one execution, counted from its journaled first start, so a resume after a restart honors the original deadline; on expiry the execution settles with `Flow.DeadlineExceeded` whether it was running or parked.
|
|
740
744
|
|
|
741
745
|
`Flow.make` throws `TypeError` for a missing or blank tag, or when the declaration
|
|
742
746
|
does not supply exactly one function as `body` or `prompt`.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smthrs/flow",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.0.0-rc.
|
|
4
|
+
"version": "1.0.0-rc.9",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"smthrs": {
|
|
7
7
|
"group": "engine"
|
|
@@ -289,11 +289,11 @@
|
|
|
289
289
|
"tag": "next"
|
|
290
290
|
},
|
|
291
291
|
"dependencies": {
|
|
292
|
-
"@smthrs/crypto": "1.0.0-rc.
|
|
293
|
-
"@smthrs/keys": "1.0.0-rc.
|
|
294
|
-
"@smthrs/plan": "1.0.0-rc.
|
|
295
|
-
"@smthrs/canonical": "1.0.0-rc.
|
|
296
|
-
"@smthrs/capability": "1.0.0-rc.
|
|
292
|
+
"@smthrs/crypto": "1.0.0-rc.9",
|
|
293
|
+
"@smthrs/keys": "1.0.0-rc.9",
|
|
294
|
+
"@smthrs/plan": "1.0.0-rc.9",
|
|
295
|
+
"@smthrs/canonical": "1.0.0-rc.9",
|
|
296
|
+
"@smthrs/capability": "1.0.0-rc.9"
|
|
297
297
|
},
|
|
298
298
|
"devDependencies": {
|
|
299
299
|
"@effect/language-service": "0.87.2",
|
|
@@ -319,7 +319,7 @@
|
|
|
319
319
|
"effect": "^4.0.4"
|
|
320
320
|
},
|
|
321
321
|
"scripts": {
|
|
322
|
-
"lint": "eslint src --max-warnings=0 && dprint check",
|
|
322
|
+
"lint": "eslint src examples --max-warnings=0 && dprint check",
|
|
323
323
|
"format": "dprint fmt",
|
|
324
324
|
"build": "node scripts/build.mjs",
|
|
325
325
|
"check": "tsc -b tsconfig.json && tsc -p tsconfig.test.json --noEmit",
|
package/src/Deadline.ts
CHANGED
|
@@ -66,6 +66,11 @@ const origin = Action.make({
|
|
|
66
66
|
* replay must line up with: the origin and the clock are addressed by fixed
|
|
67
67
|
* names within the execution.
|
|
68
68
|
*
|
|
69
|
+
* A deadline that is not a positive finite duration, a `startedAtMs` that is
|
|
70
|
+
* not finite, or a bound that would expire after epoch millisecond
|
|
71
|
+
* `Number.MAX_SAFE_INTEGER` dies with a `RangeError`: a durable store records
|
|
72
|
+
* the clock's due time as a safe integer.
|
|
73
|
+
*
|
|
69
74
|
* @category constructors
|
|
70
75
|
* @since 1.0.0
|
|
71
76
|
*/
|
|
@@ -95,7 +100,18 @@ export const start = (options: {
|
|
|
95
100
|
new RangeError(`Deadline.start: "${options.flowName}" deadline must be a positive finite duration`)
|
|
96
101
|
)
|
|
97
102
|
}
|
|
98
|
-
|
|
103
|
+
const startedAtMs = options.startedAtMs ?? (yield* origin)
|
|
104
|
+
// The clock armed below is due at `startedAtMs + deadlineMs`, and a
|
|
105
|
+
// durable store records a due time as a safe integer.
|
|
106
|
+
if (!Number.isFinite(startedAtMs) || startedAtMs + deadlineMs > Number.MAX_SAFE_INTEGER) {
|
|
107
|
+
return yield* Effect.die(
|
|
108
|
+
new RangeError(
|
|
109
|
+
`Deadline.start: "${options.flowName}" deadline must expire by epoch millisecond ` +
|
|
110
|
+
`${Number.MAX_SAFE_INTEGER}, and a ${deadlineMs} ms deadline from ${startedAtMs} does not`
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
lineage = { startedAtMs, deadlineMs }
|
|
99
115
|
}
|
|
100
116
|
const remaining = yield* remainingMs(lineage)
|
|
101
117
|
if (remaining > 0) {
|
package/src/DurableClock.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* @since 0.1.0
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import * as Clock from "effect/Clock"
|
|
14
15
|
import type * as Crypto from "effect/Crypto"
|
|
15
16
|
import * as Duration from "effect/Duration"
|
|
16
17
|
import * as Effect from "effect/Effect"
|
|
@@ -56,6 +57,28 @@ const durationFromInput = (input: Duration.Input, field: string): Duration.Durat
|
|
|
56
57
|
return parsed.value
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The latest due time a durable timer records, in epoch milliseconds: a store
|
|
62
|
+
* keeps a clock's due time as a safe integer.
|
|
63
|
+
*
|
|
64
|
+
* @private
|
|
65
|
+
*/
|
|
66
|
+
const latestDueAtMs = Number.MAX_SAFE_INTEGER
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parses a clock's own duration, which a store adds to the current time and
|
|
70
|
+
* records, so it is at most {@link latestDueAtMs} milliseconds.
|
|
71
|
+
*
|
|
72
|
+
* @private
|
|
73
|
+
*/
|
|
74
|
+
const clockDuration = (input: Duration.Input): Duration.Duration => {
|
|
75
|
+
const duration = durationFromInput(input, "duration")
|
|
76
|
+
if (Duration.toMillis(duration) > latestDueAtMs) {
|
|
77
|
+
throw new RangeError(`DurableClock: "duration" must be at most ${latestDueAtMs} milliseconds.`)
|
|
78
|
+
}
|
|
79
|
+
return duration
|
|
80
|
+
}
|
|
81
|
+
|
|
59
82
|
const makeClock = (name: string, duration: Duration.Duration): DurableClock => ({
|
|
60
83
|
[TypeId]: TypeId,
|
|
61
84
|
name,
|
|
@@ -66,18 +89,27 @@ const makeClock = (name: string, duration: Duration.Duration): DurableClock => (
|
|
|
66
89
|
/**
|
|
67
90
|
* Declares one durable clock after validating its finite nonnegative duration.
|
|
68
91
|
*
|
|
92
|
+
* Throws a `RangeError` for a malformed, infinite or negative duration, or one
|
|
93
|
+
* longer than `Number.MAX_SAFE_INTEGER` milliseconds: a durable store records
|
|
94
|
+
* a clock's due time as a safe integer.
|
|
95
|
+
*
|
|
69
96
|
* @category constructors
|
|
70
97
|
* @since 0.1.0
|
|
71
98
|
*/
|
|
72
99
|
export const make = (options: {
|
|
73
100
|
readonly name: string
|
|
74
101
|
readonly duration: Duration.Input
|
|
75
|
-
}): DurableClock => makeClock(options.name,
|
|
102
|
+
}): DurableClock => makeClock(options.name, clockDuration(options.duration))
|
|
76
103
|
|
|
77
104
|
/**
|
|
78
105
|
* Waits inside a flow, using an in-memory action for durations at or
|
|
79
106
|
* below the threshold and scheduling a durable clock for longer durations.
|
|
80
107
|
*
|
|
108
|
+
* Throws a `RangeError` for the same durations {@link make} refuses, and for
|
|
109
|
+
* a malformed, infinite or negative threshold. A durable sleep that would
|
|
110
|
+
* wake after epoch millisecond `Number.MAX_SAFE_INTEGER` dies with a
|
|
111
|
+
* `RangeError` instead of arming its clock.
|
|
112
|
+
*
|
|
81
113
|
* @category sleeping
|
|
82
114
|
* @since 0.1.0
|
|
83
115
|
*/
|
|
@@ -99,7 +131,7 @@ export const sleep = (
|
|
|
99
131
|
Crypto.Crypto | FlowRuntime | FlowInstance
|
|
100
132
|
> => {
|
|
101
133
|
const name = options.name
|
|
102
|
-
const duration =
|
|
134
|
+
const duration = clockDuration(options.duration)
|
|
103
135
|
const inMemoryThreshold = durationFromInput(
|
|
104
136
|
options.inMemoryThreshold ?? defaultInMemoryThreshold,
|
|
105
137
|
"inMemoryThreshold"
|
|
@@ -119,6 +151,17 @@ export const sleep = (
|
|
|
119
151
|
})
|
|
120
152
|
}
|
|
121
153
|
|
|
154
|
+
// A due time past the latest one a store records would fail the park
|
|
155
|
+
// write deep in the engine, so the sleep refuses it here by name.
|
|
156
|
+
const now = yield* Clock.currentTimeMillis
|
|
157
|
+
if (now + Duration.toMillis(duration) > latestDueAtMs) {
|
|
158
|
+
return yield* Effect.die(
|
|
159
|
+
new RangeError(
|
|
160
|
+
`DurableClock: "${name}" would wake after epoch millisecond ${latestDueAtMs}, ` +
|
|
161
|
+
`the latest due time a durable timer records.`
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
}
|
|
122
165
|
const engine = yield* FlowRuntime
|
|
123
166
|
const instance = yield* FlowInstance
|
|
124
167
|
const clock = makeClock(name, duration)
|
package/src/Flow/make.ts
CHANGED
|
@@ -99,7 +99,8 @@ interface MakeOptions<
|
|
|
99
99
|
readonly maxRounds?: number | undefined
|
|
100
100
|
/**
|
|
101
101
|
* How long one execution may take, counted from its journaled first start.
|
|
102
|
-
* A positive finite duration
|
|
102
|
+
* A positive finite duration of at most `Number.MAX_SAFE_INTEGER`
|
|
103
|
+
* milliseconds; see {@link Flow.deadline}.
|
|
103
104
|
*/
|
|
104
105
|
readonly deadline?: Duration.Input | undefined
|
|
105
106
|
readonly annotations?: Context.Context<never>
|
|
@@ -222,6 +223,11 @@ export const make: {
|
|
|
222
223
|
) {
|
|
223
224
|
throw new RangeError(`Flow.make: "${tag}" deadline must be a positive finite duration`)
|
|
224
225
|
}
|
|
226
|
+
// The durable clock that enforces a deadline is due at its start plus the
|
|
227
|
+
// deadline, and a store records that due time as a safe integer.
|
|
228
|
+
if (deadline !== undefined && Duration.toMillis(deadline) > Number.MAX_SAFE_INTEGER) {
|
|
229
|
+
throw new RangeError(`Flow.make: "${tag}" deadline must be at most ${Number.MAX_SAFE_INTEGER} ms`)
|
|
230
|
+
}
|
|
225
231
|
// Captured here, where the stack still names the author's file, and carried
|
|
226
232
|
// as a non-enumerable property so no digest can see it
|
|
227
233
|
// (`internal/DeclarationSite.ts`).
|
package/src/HumanTask.ts
CHANGED
|
@@ -680,7 +680,9 @@ const budgetOf = (payload: Payload): Effect.Effect<number, HumanTaskFailed> => {
|
|
|
680
680
|
const deadlineOf = (payload: Payload): Effect.Effect<Duration.Duration | undefined, HumanTaskFailed> => {
|
|
681
681
|
const timeoutMs = payload.timeoutMs
|
|
682
682
|
if (timeoutMs === undefined) return Effect.succeed(undefined)
|
|
683
|
-
|
|
683
|
+
// The timeout clock is due at its start plus the deadline, and a durable
|
|
684
|
+
// store records that due time as a safe integer.
|
|
685
|
+
return Number.isFinite(timeoutMs) && timeoutMs >= 0 && timeoutMs <= Number.MAX_SAFE_INTEGER
|
|
684
686
|
? Effect.succeed(Duration.millis(timeoutMs))
|
|
685
687
|
: Effect.fail(
|
|
686
688
|
failed(
|
|
@@ -688,8 +690,8 @@ const deadlineOf = (payload: Payload): Effect.Effect<Duration.Duration | undefin
|
|
|
688
690
|
"request_invalid",
|
|
689
691
|
0,
|
|
690
692
|
[],
|
|
691
|
-
`${tag} was called with timeoutMs ${timeoutMs}. A deadline is a
|
|
692
|
-
|
|
693
|
+
`${tag} was called with timeoutMs ${timeoutMs}. A deadline is a number of milliseconds ` +
|
|
694
|
+
`from 0 to ${Number.MAX_SAFE_INTEGER}; the question would be over before it was asked, or ` +
|
|
693
695
|
"never over at all."
|
|
694
696
|
)
|
|
695
697
|
)
|
package/src/Poll.ts
CHANGED
|
@@ -149,8 +149,10 @@ export const delayMillis = (options: {
|
|
|
149
149
|
readonly backoff: Backoff
|
|
150
150
|
readonly attempt: number
|
|
151
151
|
}): number =>
|
|
152
|
-
|
|
153
|
-
|
|
152
|
+
// A zero interval is zero under every backoff. Multiplying it by a factor
|
|
153
|
+
// that has overflowed to `Infinity` would make it `NaN`.
|
|
154
|
+
options.intervalMs === 0 ? 0 : options.intervalMs *
|
|
155
|
+
(options.backoff === "fixed" ? 1 : options.backoff === "linear" ? options.attempt : 2 ** (options.attempt - 1))
|
|
154
156
|
|
|
155
157
|
/**
|
|
156
158
|
* What a poll round can fail with.
|
|
@@ -274,7 +276,9 @@ const withAttempt = <Payload>(payload: object, attempt: number): Payload =>
|
|
|
274
276
|
* with a plan that looks correct. The check is on the schedule, not only on the
|
|
275
277
|
* interval: `{ intervalMs: 1000, maxAttempts: 2000, backoff: "exponential" }`
|
|
276
278
|
* states three finite options and asks for an infinite wait, so the LAST wait
|
|
277
|
-
* the poll can arm has to be a length too
|
|
279
|
+
* the poll can arm has to be a length too, and no longer than
|
|
280
|
+
* `Number.MAX_SAFE_INTEGER` milliseconds, the latest deadline a durable timer
|
|
281
|
+
* records. `maxAttempts` is the lineage's round
|
|
278
282
|
* budget, and a budget below one attempt reaches `Flow.make` as a `maxRounds`
|
|
279
283
|
* complaint about an option the author never wrote.
|
|
280
284
|
*
|
|
@@ -341,7 +345,9 @@ export const make = <
|
|
|
341
345
|
const longestWaitMs = maxAttempts > 1
|
|
342
346
|
? delayMillis({ intervalMs, backoff, attempt: maxAttempts - 1 })
|
|
343
347
|
: 0
|
|
344
|
-
|
|
348
|
+
// A durable timer records its deadline as a safe integer of milliseconds,
|
|
349
|
+
// so `Sleep` refuses a longer wait on the round that reaches it.
|
|
350
|
+
if (!Number.isFinite(longestWaitMs) || longestWaitMs > Number.MAX_SAFE_INTEGER) {
|
|
345
351
|
throw new RangeError(
|
|
346
352
|
`Poll.make: "${tag}" asks for a wait of ${longestWaitMs} ms before its last attempt. An ` +
|
|
347
353
|
`intervalMs of ${intervalMs} under the "${backoff}" backoff reaches a length no clock ` +
|
package/src/Sleep.ts
CHANGED
|
@@ -102,6 +102,30 @@ export const action: Action.Declared<
|
|
|
102
102
|
tier: "sealed"
|
|
103
103
|
})
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The latest deadline a sleep can arm, in epoch milliseconds.
|
|
107
|
+
*
|
|
108
|
+
* A durable store records `wakeAt` and a clock's due time as a non-negative
|
|
109
|
+
* safe integer. A finite deadline past this one passed every other check and
|
|
110
|
+
* then failed the park write, so it is refused with the payload's other
|
|
111
|
+
* invalid deadlines instead.
|
|
112
|
+
*
|
|
113
|
+
* @private
|
|
114
|
+
*/
|
|
115
|
+
const latestDeadline = Number.MAX_SAFE_INTEGER
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The refusal for a deadline later than {@link latestDeadline}.
|
|
119
|
+
*
|
|
120
|
+
* @private
|
|
121
|
+
*/
|
|
122
|
+
const tooLate = (stated: string): SleepRequestInvalid =>
|
|
123
|
+
new SleepRequestInvalid({
|
|
124
|
+
code: "invalid_deadline",
|
|
125
|
+
message: `${tag} was called with ${stated}, which ends after ${latestDeadline}, the latest epoch ` +
|
|
126
|
+
"millisecond a durable timer records."
|
|
127
|
+
})
|
|
128
|
+
|
|
105
129
|
/**
|
|
106
130
|
* The absolute deadline a payload names, in epoch milliseconds.
|
|
107
131
|
*
|
|
@@ -153,12 +177,13 @@ const deadlineOf = (
|
|
|
153
177
|
})
|
|
154
178
|
)
|
|
155
179
|
}
|
|
156
|
-
return
|
|
180
|
+
return Math.ceil(deadline) > latestDeadline
|
|
181
|
+
? Effect.fail(tooLate(`millis ${payload.millis} at current time ${now}`))
|
|
182
|
+
: Effect.succeed(deadline)
|
|
157
183
|
}
|
|
158
184
|
if (payload.until !== undefined) {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
: Effect.fail(
|
|
185
|
+
if (!Number.isFinite(payload.until)) {
|
|
186
|
+
return Effect.fail(
|
|
162
187
|
new SleepRequestInvalid({
|
|
163
188
|
code: "invalid_deadline",
|
|
164
189
|
message:
|
|
@@ -166,6 +191,10 @@ const deadlineOf = (
|
|
|
166
191
|
"the sleep must end at a real point in time."
|
|
167
192
|
})
|
|
168
193
|
)
|
|
194
|
+
}
|
|
195
|
+
return Math.ceil(payload.until) > latestDeadline
|
|
196
|
+
? Effect.fail(tooLate(`until ${payload.until}`))
|
|
197
|
+
: Effect.succeed(payload.until)
|
|
169
198
|
}
|
|
170
199
|
return Effect.fail(
|
|
171
200
|
new SleepRequestInvalid({
|