@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.
Files changed (61) hide show
  1. package/README.md +3 -1
  2. package/dist/cjs/Deadline.d.ts +5 -0
  3. package/dist/cjs/Deadline.d.ts.map +1 -1
  4. package/dist/cjs/Deadline.js +9 -1
  5. package/dist/cjs/Deadline.js.map +2 -2
  6. package/dist/cjs/DurableClock.d.ts +9 -10
  7. package/dist/cjs/DurableClock.d.ts.map +1 -1
  8. package/dist/cjs/DurableClock.js +19 -2
  9. package/dist/cjs/DurableClock.js.map +2 -2
  10. package/dist/cjs/Flow/make.d.ts +2 -1
  11. package/dist/cjs/Flow/make.d.ts.map +1 -1
  12. package/dist/cjs/Flow/make.js +3 -0
  13. package/dist/cjs/Flow/make.js.map +2 -2
  14. package/dist/cjs/HumanTask.d.ts.map +1 -1
  15. package/dist/cjs/HumanTask.js +2 -2
  16. package/dist/cjs/HumanTask.js.map +2 -2
  17. package/dist/cjs/Poll.d.ts +3 -1
  18. package/dist/cjs/Poll.d.ts.map +1 -1
  19. package/dist/cjs/Poll.js +6 -2
  20. package/dist/cjs/Poll.js.map +2 -2
  21. package/dist/cjs/Sleep.d.ts.map +1 -1
  22. package/dist/cjs/Sleep.js +15 -7
  23. package/dist/cjs/Sleep.js.map +2 -2
  24. package/dist/esm/Deadline.d.ts +5 -0
  25. package/dist/esm/Deadline.d.ts.map +1 -1
  26. package/dist/esm/Deadline.js +13 -1
  27. package/dist/esm/Deadline.js.map +1 -1
  28. package/dist/esm/DurableClock.d.ts +9 -10
  29. package/dist/esm/DurableClock.d.ts.map +1 -1
  30. package/dist/esm/DurableClock.js +49 -2
  31. package/dist/esm/DurableClock.js.map +1 -1
  32. package/dist/esm/Flow/make.d.ts +2 -1
  33. package/dist/esm/Flow/make.d.ts.map +1 -1
  34. package/dist/esm/Flow/make.js +5 -0
  35. package/dist/esm/Flow/make.js.map +1 -1
  36. package/dist/esm/HumanTask.d.ts.map +1 -1
  37. package/dist/esm/HumanTask.js +5 -3
  38. package/dist/esm/HumanTask.js.map +1 -1
  39. package/dist/esm/Poll.d.ts +3 -1
  40. package/dist/esm/Poll.d.ts.map +1 -1
  41. package/dist/esm/Poll.js +10 -3
  42. package/dist/esm/Poll.js.map +1 -1
  43. package/dist/esm/Sleep.d.ts.map +1 -1
  44. package/dist/esm/Sleep.js +30 -4
  45. package/dist/esm/Sleep.js.map +1 -1
  46. package/docs/README.md +3 -1
  47. package/docs/api.md +21 -17
  48. package/docs/guides/ask-a-person.md +5 -5
  49. package/docs/guides/inspect-the-plan.md +1 -1
  50. package/docs/guides/poll-until-ready.md +3 -1
  51. package/docs/guides/wait-for-a-deadline.md +5 -5
  52. package/docs/installation.md +14 -9
  53. package/docs/quickstart.md +5 -1
  54. package/docs/reference/flow.md +9 -5
  55. package/package.json +7 -7
  56. package/src/Deadline.ts +17 -1
  57. package/src/DurableClock.ts +45 -2
  58. package/src/Flow/make.ts +7 -1
  59. package/src/HumanTask.ts +5 -3
  60. package/src/Poll.ts +10 -4
  61. 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`, or an addition that overflows |
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 finite number of milliseconds that is not negative |
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, 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.
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, 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. |
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.7 @smthrs/plan@1.0.0-rc.7
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`, or an addition that overflows. |
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
 
@@ -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
- `@smthrs/flow` is not on npm yet. Link the source packages as described in [Use the libraries](#use-the-libraries).
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
- First [install the source checkout](https://github.com/smithersai/smithers/blob/main/packages/smithers/docs/installation.md#install-the-cli). The checkout's `examples` workspace carries the packages used by the flow tutorials and their pinned `effect@4.0.4`.
27
-
28
- Create a project beside the checkout:
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
- mkdir my-flows && cd my-flows
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
- Replace `/path/to/smithers` with the absolute path to your installed checkout. Run programs with `node <file>.ts`. Packages absent from the examples workspace, such as `@smthrs/artifacts`, resolve only inside the checkout; use the owning package's workspace for those examples. This linking recipe uses source exports and requires no package build.
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
 
@@ -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 package with the dependencies installed. Not on npm yet; see [Installation](./installation.md#use-the-libraries).
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
 
@@ -5,7 +5,11 @@ area: api
5
5
  order: 10
6
6
  ---
7
7
 
8
- Install the package with its `effect` peer. Not on npm yet; see [Installation](../installation.md#use-the-libraries).
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. The duration must be finite and not negative.
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.7",
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.7",
293
- "@smthrs/keys": "1.0.0-rc.7",
294
- "@smthrs/plan": "1.0.0-rc.7",
295
- "@smthrs/canonical": "1.0.0-rc.7",
296
- "@smthrs/capability": "1.0.0-rc.7"
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
- lineage = { startedAtMs: options.startedAtMs ?? (yield* origin), deadlineMs }
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) {
@@ -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, durationFromInput(options.duration, "duration"))
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 = durationFromInput(options.duration, "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; see {@link Flow.deadline}.
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
- return Number.isFinite(timeoutMs) && timeoutMs >= 0
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 finite number of ` +
692
- "milliseconds that is not negative; the question would be over before it was asked, or " +
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
- options.intervalMs *
153
- (options.backoff === "fixed" ? 1 : options.backoff === "linear" ? options.attempt : 2 ** (options.attempt - 1))
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. `maxAttempts` is the lineage's round
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
- if (!Number.isFinite(longestWaitMs)) {
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 Effect.succeed(deadline)
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
- return Number.isFinite(payload.until)
160
- ? Effect.succeed(payload.until)
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({