@executablemd/testing 0.3.1 → 0.4.1
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/esm/{testing/npm/src/src → src}/assertions.js +15 -24
- package/esm/{testing/npm/src/src → src}/handlers.js +140 -10
- package/esm/{testing/npm/src/src → src}/vocabulary.js +3 -0
- package/package.json +3 -3
- package/types/src/assertions.d.ts +11 -1
- package/types/src/handlers.d.ts +1 -0
- package/esm/core/mod.js +0 -55
- package/esm/core/src/api.js +0 -31
- package/esm/core/src/collect.js +0 -24
- package/esm/core/src/component-api.js +0 -47
- package/esm/core/src/content-context.js +0 -23
- package/esm/core/src/deno-compiler.js +0 -51
- package/esm/core/src/errors.js +0 -13
- package/esm/core/src/eval-context.js +0 -13
- package/esm/core/src/eval-handler.js +0 -79
- package/esm/core/src/eval-interpolate.js +0 -63
- package/esm/core/src/eval-transform.js +0 -378
- package/esm/core/src/execute.js +0 -378
- package/esm/core/src/expand.js +0 -988
- package/esm/core/src/frontmatter.js +0 -98
- package/esm/core/src/heal.js +0 -29
- package/esm/core/src/interpolate.js +0 -39
- package/esm/core/src/modifiers/daemon.js +0 -60
- package/esm/core/src/modifiers/persist.js +0 -35
- package/esm/core/src/modifiers/timeout.js +0 -55
- package/esm/core/src/modifiers.js +0 -113
- package/esm/core/src/output/normalize.js +0 -33
- package/esm/core/src/output/terminal.js +0 -20
- package/esm/core/src/render.js +0 -30
- package/esm/core/src/replay-stream.js +0 -58
- package/esm/core/src/sample-api.js +0 -45
- package/esm/core/src/scanner.js +0 -632
- package/esm/core/src/temp-file-compiler.js +0 -68
- package/esm/core/src/types.js +0 -7
- package/esm/core/src/validate.js +0 -82
- package/esm/durable-streams/combinators.js +0 -227
- package/esm/durable-streams/context.js +0 -13
- package/esm/durable-streams/divergence.js +0 -56
- package/esm/durable-streams/each.js +0 -193
- package/esm/durable-streams/effect.js +0 -273
- package/esm/durable-streams/ephemeral.js +0 -80
- package/esm/durable-streams/errors.js +0 -92
- package/esm/durable-streams/http-stream.js +0 -207
- package/esm/durable-streams/mod.js +0 -34
- package/esm/durable-streams/operations.js +0 -88
- package/esm/durable-streams/replay-guard.js +0 -87
- package/esm/durable-streams/replay-index.js +0 -132
- package/esm/durable-streams/run.js +0 -137
- package/esm/durable-streams/serialize.js +0 -65
- package/esm/durable-streams/stream.js +0 -56
- package/esm/durable-streams/types.js +0 -10
- package/esm/runtime/apis.js +0 -237
- package/esm/runtime/find-free-port.js +0 -33
- package/esm/runtime/mod.js +0 -20
- /package/esm/{testing/npm/src/_dnt.polyfills.js → _dnt.polyfills.js} +0 -0
- /package/esm/{testing/npm/src/_dnt.shims.js → _dnt.shims.js} +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/almost_equals.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/array_includes.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/assert.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/assertion_error.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/equal.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/equals.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/exists.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/fail.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/false.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/greater.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/greater_or_equal.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/instance_of.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/is_error.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/less.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/less_or_equal.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/match.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/mod.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/not_equals.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/not_instance_of.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/not_match.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/not_strict_equals.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/object_match.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/rejects.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/strict_equals.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/string_includes.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/throws.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/unimplemented.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/unreachable.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/internal/1.0.14/build_message.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/internal/1.0.14/diff.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/internal/1.0.14/diff_str.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/internal/1.0.14/format.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/internal/1.0.14/styles.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/internal/1.0.14/types.js +0 -0
- /package/esm/{testing/npm/src/mod.js → mod.js} +0 -0
- /package/esm/{testing/npm/src/src → src}/journal.js +0 -0
- /package/esm/{testing/npm/src/src → src}/test-api.js +0 -0
- /package/esm/{testing/npm/src/src → src}/use-testing.js +0 -0
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Workflow-enabled effects — durable equivalents of Effection's built-in
|
|
3
|
-
* operations.
|
|
4
|
-
*
|
|
5
|
-
* Each returns a Workflow<T> (a generator that yields a single DurableEffect).
|
|
6
|
-
* These are the building blocks for durable workflows.
|
|
7
|
-
*
|
|
8
|
-
* See integration doc §6.
|
|
9
|
-
*/
|
|
10
|
-
import { call } from "effection";
|
|
11
|
-
import { createDurableEffect, createDurableOperation } from "./effect.ts";
|
|
12
|
-
/**
|
|
13
|
-
* Durable sleep — pauses the workflow for `ms` milliseconds.
|
|
14
|
-
*
|
|
15
|
-
* During replay, resolves synchronously with the stored result.
|
|
16
|
-
* During live execution, uses setTimeout and persists the Yield event.
|
|
17
|
-
*
|
|
18
|
-
* Description: { type: "sleep", name: "sleep" }
|
|
19
|
-
*/
|
|
20
|
-
export function* durableSleep(ms) {
|
|
21
|
-
yield createDurableEffect({ type: "sleep", name: "sleep" }, (resolve) => {
|
|
22
|
-
const id = setTimeout(() => resolve({ status: "ok" }), ms);
|
|
23
|
-
return () => clearTimeout(id);
|
|
24
|
-
});
|
|
25
|
-
}
|
|
26
|
-
/**
|
|
27
|
-
* Durable call — wraps a function for durable execution.
|
|
28
|
-
*
|
|
29
|
-
* Accepts functions returning either a Promise or an Operation.
|
|
30
|
-
* Effection's call() handles the dispatch at runtime: Promises are
|
|
31
|
-
* bridged, Operations run with full structured concurrency.
|
|
32
|
-
*
|
|
33
|
-
* The function is called during live execution; its resolved value is
|
|
34
|
-
* serialized and persisted. During replay, the stored value is returned
|
|
35
|
-
* without calling the function.
|
|
36
|
-
*
|
|
37
|
-
* Description: { type: "call", name }
|
|
38
|
-
*
|
|
39
|
-
* IMPORTANT: The function's return value must be JSON-serializable.
|
|
40
|
-
*
|
|
41
|
-
* @param name Stable identifier for the effect (used for divergence detection)
|
|
42
|
-
* @param fn Function returning a Promise or Operation (only called during live execution)
|
|
43
|
-
*/
|
|
44
|
-
export function* durableCall(name, fn) {
|
|
45
|
-
// call() dispatches at runtime: if fn() returns a Promise, it bridges
|
|
46
|
-
// via action(); if it returns an Operation, it evaluates directly.
|
|
47
|
-
// The cast is safe because call() always resolves to T regardless of
|
|
48
|
-
// which branch fn() takes.
|
|
49
|
-
return (yield createDurableOperation({ type: "call", name }, () => call(fn)));
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* Durable action — generic effect with a custom executor.
|
|
53
|
-
*
|
|
54
|
-
* Like Effection's action(), but durable. The executor receives resolve/reject
|
|
55
|
-
* callbacks and returns a teardown function.
|
|
56
|
-
*
|
|
57
|
-
* Description: { type: "action", name }
|
|
58
|
-
*/
|
|
59
|
-
export function* durableAction(name, executor) {
|
|
60
|
-
return (yield createDurableEffect({ type: "action", name }, (protocolResolve, reject) => {
|
|
61
|
-
return executor((value) => protocolResolve({ status: "ok", value: value }), reject);
|
|
62
|
-
}));
|
|
63
|
-
}
|
|
64
|
-
/**
|
|
65
|
-
* Version gate — enables safe code evolution for durable workflows.
|
|
66
|
-
*
|
|
67
|
-
* During live execution, resolves with `maxVersion`. During replay, the
|
|
68
|
-
* stored version determines which code path the workflow takes.
|
|
69
|
-
*
|
|
70
|
-
* Description: { type: "version_gate", name }
|
|
71
|
-
*
|
|
72
|
-
* See spec §9.
|
|
73
|
-
*/
|
|
74
|
-
export function* versionCheck(name, opts) {
|
|
75
|
-
if (opts.minVersion > opts.maxVersion) {
|
|
76
|
-
throw new Error(`versionCheck("${name}"): minVersion (${opts.minVersion}) ` +
|
|
77
|
-
`cannot exceed maxVersion (${opts.maxVersion})`);
|
|
78
|
-
}
|
|
79
|
-
const version = (yield createDurableEffect({ type: "version_gate", name }, (resolve) => {
|
|
80
|
-
resolve({ status: "ok", value: opts.maxVersion });
|
|
81
|
-
return () => { };
|
|
82
|
-
}));
|
|
83
|
-
if (version < opts.minVersion || version > opts.maxVersion) {
|
|
84
|
-
throw new Error(`versionCheck("${name}"): replayed version ${version} is outside ` +
|
|
85
|
-
`supported range [${opts.minVersion}, ${opts.maxVersion}]`);
|
|
86
|
-
}
|
|
87
|
-
return version;
|
|
88
|
-
}
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ReplayGuard API — pluggable validation for replay staleness detection.
|
|
3
|
-
*
|
|
4
|
-
* The durable execution protocol's default behavior is "logs are authoritative"
|
|
5
|
-
* — the journal is unconditionally trusted during replay. ReplayGuard extends
|
|
6
|
-
* this with opt-in validation: guards can examine effect descriptions and
|
|
7
|
-
* result values to validate that recorded results are still valid against
|
|
8
|
-
* current state before allowing replay to proceed.
|
|
9
|
-
*
|
|
10
|
-
* Guards access `event.description.*` for effect input fields (e.g., file
|
|
11
|
-
* path, URL, encoding) and `event.result.value.*` for effect output fields
|
|
12
|
-
* (e.g., content hash, status code). There is no separate metadata field —
|
|
13
|
-
* inputs belong in the effect description, outputs belong in the result.
|
|
14
|
-
*
|
|
15
|
-
* The API has two phases:
|
|
16
|
-
*
|
|
17
|
-
* 1. **check** (before replay begins): Runs in generator context inside
|
|
18
|
-
* `durableRun`, after the journal is loaded but before the workflow starts.
|
|
19
|
-
* I/O is allowed — this is where file hashing, network checks, and other
|
|
20
|
-
* observation-gathering happens. Results are cached in middleware closures.
|
|
21
|
-
*
|
|
22
|
-
* 2. **decide** (during replay): Runs synchronously inside
|
|
23
|
-
* `DurableEffect.enter()`, after identity matching succeeds but before
|
|
24
|
-
* the stored result is fed to the generator. Must be pure and side-effect-
|
|
25
|
-
* free. Reads from the cache populated during the check phase.
|
|
26
|
-
*
|
|
27
|
-
* Multiple guards compose via Effection's `Api.around()`. A guard that has
|
|
28
|
-
* an opinion returns an outcome directly; one that doesn't calls `next(event)`
|
|
29
|
-
* to delegate. The first `error` outcome wins — the chain short-circuits.
|
|
30
|
-
*
|
|
31
|
-
* See replay-guard-spec.md for the full design.
|
|
32
|
-
*/
|
|
33
|
-
import { createApi } from "effection/experimental";
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
// Default implementation (pass-through)
|
|
36
|
-
// ---------------------------------------------------------------------------
|
|
37
|
-
/**
|
|
38
|
-
* Default check — no-op. Events pass through without observation.
|
|
39
|
-
*/
|
|
40
|
-
function* defaultCheck(_event) {
|
|
41
|
-
// No observation — pass through to next middleware or default.
|
|
42
|
-
}
|
|
43
|
-
/**
|
|
44
|
-
* Default decide — always replay. This preserves "logs are authoritative"
|
|
45
|
-
* as the default behavior. Guards must be explicitly installed to add
|
|
46
|
-
* validation.
|
|
47
|
-
*/
|
|
48
|
-
function defaultDecide(_event) {
|
|
49
|
-
return { outcome: "replay" };
|
|
50
|
-
}
|
|
51
|
-
// ---------------------------------------------------------------------------
|
|
52
|
-
// The ReplayGuard API instance
|
|
53
|
-
// ---------------------------------------------------------------------------
|
|
54
|
-
/**
|
|
55
|
-
* The ReplayGuard API.
|
|
56
|
-
*
|
|
57
|
-
* Default behavior is pass-through: `check` does nothing, `decide` returns
|
|
58
|
-
* `{ outcome: "replay" }`. This preserves "logs are authoritative" unless
|
|
59
|
-
* middleware says otherwise.
|
|
60
|
-
*
|
|
61
|
-
* Install guards via `yield* ReplayGuard.around({ ... })` before calling
|
|
62
|
-
* `durableRun`. Guards are inherited by child scopes through Effection's
|
|
63
|
-
* context inheritance.
|
|
64
|
-
*
|
|
65
|
-
* Example:
|
|
66
|
-
* ```ts
|
|
67
|
-
* function* myWorkflow(): Operation<void> {
|
|
68
|
-
* const scope = yield* useScope();
|
|
69
|
-
* yield* ReplayGuard.around({
|
|
70
|
-
* *check([event], next) {
|
|
71
|
-
* // Gather observations (I/O allowed here)
|
|
72
|
-
* return yield* next(event);
|
|
73
|
-
* },
|
|
74
|
-
* decide([event], next) {
|
|
75
|
-
* // Make decision (pure, synchronous)
|
|
76
|
-
* if (isStale(event)) {
|
|
77
|
-
* return { outcome: "error", error: new StaleInputError(...) };
|
|
78
|
-
* }
|
|
79
|
-
* return next(event);
|
|
80
|
-
* },
|
|
81
|
-
* });
|
|
82
|
-
*
|
|
83
|
-
* yield* durableRun(workflow, { stream });
|
|
84
|
-
* }
|
|
85
|
-
* ```
|
|
86
|
-
*/
|
|
87
|
-
export const ReplayGuard = createApi("DurableEffection.ReplayGuard", { check: defaultCheck, decide: defaultDecide });
|
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ReplayIndex — derived, in-memory structure built from the stream on startup.
|
|
3
|
-
*
|
|
4
|
-
* Provides per-coroutine cursored access to Yield events and keyed access
|
|
5
|
-
* to Close events. See spec §4.1.
|
|
6
|
-
*/
|
|
7
|
-
export class ReplayIndex {
|
|
8
|
-
yields = new Map();
|
|
9
|
-
cursors = new Map();
|
|
10
|
-
closes = new Map();
|
|
11
|
-
/** Coroutines where replay has been disabled (run-live mode). */
|
|
12
|
-
disabled = new Set();
|
|
13
|
-
constructor(events) {
|
|
14
|
-
for (const event of events) {
|
|
15
|
-
if (event.type === "yield") {
|
|
16
|
-
let list = this.yields.get(event.coroutineId);
|
|
17
|
-
if (!list) {
|
|
18
|
-
list = [];
|
|
19
|
-
this.yields.set(event.coroutineId, list);
|
|
20
|
-
}
|
|
21
|
-
list.push({
|
|
22
|
-
description: event.description,
|
|
23
|
-
result: event.result,
|
|
24
|
-
});
|
|
25
|
-
}
|
|
26
|
-
if (event.type === "close") {
|
|
27
|
-
this.closes.set(event.coroutineId, event);
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
/**
|
|
32
|
-
* Disable replay for a coroutine (run-live mode).
|
|
33
|
-
*
|
|
34
|
-
* Once disabled, peekYield() returns undefined and hasClose() returns
|
|
35
|
-
* false for this coroutine, so all subsequent effects execute live
|
|
36
|
-
* and no further divergence checks are triggered.
|
|
37
|
-
*/
|
|
38
|
-
disableReplay(coroutineId) {
|
|
39
|
-
this.disabled.add(coroutineId);
|
|
40
|
-
}
|
|
41
|
-
/** Returns true if replay has been disabled for this coroutine. */
|
|
42
|
-
isReplayDisabled(coroutineId) {
|
|
43
|
-
return this.disabled.has(coroutineId);
|
|
44
|
-
}
|
|
45
|
-
/**
|
|
46
|
-
* Returns the next unconsumed yield for this coroutine,
|
|
47
|
-
* or undefined if the cursor is past the end or replay is disabled.
|
|
48
|
-
*/
|
|
49
|
-
peekYield(coroutineId) {
|
|
50
|
-
if (this.disabled.has(coroutineId))
|
|
51
|
-
return undefined;
|
|
52
|
-
const list = this.yields.get(coroutineId);
|
|
53
|
-
const cursor = this.cursors.get(coroutineId) ?? 0;
|
|
54
|
-
return list?.[cursor];
|
|
55
|
-
}
|
|
56
|
-
/** Advances the cursor for this coroutine by one position. */
|
|
57
|
-
consumeYield(coroutineId) {
|
|
58
|
-
const cursor = this.cursors.get(coroutineId) ?? 0;
|
|
59
|
-
this.cursors.set(coroutineId, cursor + 1);
|
|
60
|
-
}
|
|
61
|
-
/** Returns the current cursor position for this coroutine. */
|
|
62
|
-
getCursor(coroutineId) {
|
|
63
|
-
return this.cursors.get(coroutineId) ?? 0;
|
|
64
|
-
}
|
|
65
|
-
/** Returns true if a Close event exists for this coroutine (and replay is not disabled). */
|
|
66
|
-
hasClose(coroutineId) {
|
|
67
|
-
if (this.disabled.has(coroutineId))
|
|
68
|
-
return false;
|
|
69
|
-
return this.closes.has(coroutineId);
|
|
70
|
-
}
|
|
71
|
-
/** Returns the Close event for this coroutine, or undefined. */
|
|
72
|
-
getClose(coroutineId) {
|
|
73
|
-
if (this.disabled.has(coroutineId))
|
|
74
|
-
return undefined;
|
|
75
|
-
return this.closes.get(coroutineId);
|
|
76
|
-
}
|
|
77
|
-
/**
|
|
78
|
-
* Returns true if any non-disabled coroutine still has unconsumed yields.
|
|
79
|
-
*
|
|
80
|
-
* This is used by durableRun to detect early-return divergence even when
|
|
81
|
-
* unconsumed entries belong to child coroutines rather than the root.
|
|
82
|
-
*/
|
|
83
|
-
hasAnyUnconsumedYields() {
|
|
84
|
-
for (const [coroutineId, entries] of this.yields.entries()) {
|
|
85
|
-
if (this.disabled.has(coroutineId))
|
|
86
|
-
continue;
|
|
87
|
-
const cursor = this.cursors.get(coroutineId) ?? 0;
|
|
88
|
-
if (cursor < entries.length)
|
|
89
|
-
return true;
|
|
90
|
-
}
|
|
91
|
-
return false;
|
|
92
|
-
}
|
|
93
|
-
/**
|
|
94
|
-
* Return the first non-disabled coroutine with unconsumed yields.
|
|
95
|
-
*
|
|
96
|
-
* NOTE: Closed coroutines are skipped because their yields were consumed
|
|
97
|
-
* by the child's own replay path (via runDurableChild). This means
|
|
98
|
-
* orphaned children (recorded in the journal but never spawned in the
|
|
99
|
-
* current run) are not detected here. Orphan detection requires tracking
|
|
100
|
-
* which coroutine IDs were visited during the current run, which is a
|
|
101
|
-
* future enhancement.
|
|
102
|
-
*/
|
|
103
|
-
firstUnconsumed() {
|
|
104
|
-
for (const [coroutineId, entries] of this.yields.entries()) {
|
|
105
|
-
if (this.disabled.has(coroutineId))
|
|
106
|
-
continue;
|
|
107
|
-
if (this.closes.has(coroutineId))
|
|
108
|
-
continue;
|
|
109
|
-
const cursor = this.cursors.get(coroutineId) ?? 0;
|
|
110
|
-
if (cursor < entries.length) {
|
|
111
|
-
return { coroutineId, cursor, totalYields: entries.length };
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
return undefined;
|
|
115
|
-
}
|
|
116
|
-
/**
|
|
117
|
-
* Returns true if the cursor for this coroutine has been fully consumed
|
|
118
|
-
* AND a Close event exists. This means the coroutine completed in a
|
|
119
|
-
* previous run and can be treated as fully replayed.
|
|
120
|
-
*
|
|
121
|
-
* Returns false if replay is disabled (run-live mode).
|
|
122
|
-
*/
|
|
123
|
-
isFullyReplayed(coroutineId) {
|
|
124
|
-
if (this.disabled.has(coroutineId))
|
|
125
|
-
return false;
|
|
126
|
-
return this.peekYield(coroutineId) === undefined && this.hasClose(coroutineId);
|
|
127
|
-
}
|
|
128
|
-
/** Returns the total number of yield entries for this coroutine. */
|
|
129
|
-
yieldCount(coroutineId) {
|
|
130
|
-
return this.yields.get(coroutineId)?.length ?? 0;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* durableRun — entry point for durable workflow execution.
|
|
3
|
-
*
|
|
4
|
-
* An Operation<T> that reads the event stream, builds the ReplayIndex,
|
|
5
|
-
* sets DurableContext on the current scope, runs the workflow, and emits
|
|
6
|
-
* a Close event when the workflow terminates.
|
|
7
|
-
*
|
|
8
|
-
* Because durableRun is an Operation, it inherits the caller's Effection
|
|
9
|
-
* scope — including any middleware installed via Api.around(). This is
|
|
10
|
-
* how divergence policy overrides work: the caller installs middleware
|
|
11
|
-
* before yield*-ing into durableRun. See DEC-032.
|
|
12
|
-
*
|
|
13
|
-
* See integration doc §10, protocol spec §4.
|
|
14
|
-
*/
|
|
15
|
-
import { useScope } from "effection";
|
|
16
|
-
import { DurableCtx } from "./context.ts";
|
|
17
|
-
import { EarlyReturnDivergenceError } from "./errors.ts";
|
|
18
|
-
import { ReplayGuard } from "./replay-guard.ts";
|
|
19
|
-
import { ReplayIndex } from "./replay-index.ts";
|
|
20
|
-
import { deserializeError, serializeError } from "./serialize.ts";
|
|
21
|
-
/**
|
|
22
|
-
* Run the ReplayGuard check phase over all Yield events.
|
|
23
|
-
*
|
|
24
|
-
* This is Phase 1 of replay guard validation — it runs before the workflow
|
|
25
|
-
* starts, in generator context where I/O is allowed. Middleware uses this
|
|
26
|
-
* phase to gather observations (hash files, check timestamps) and cache
|
|
27
|
-
* results for the decide phase.
|
|
28
|
-
*
|
|
29
|
-
* See replay-guard-spec.md §5.5.
|
|
30
|
-
*/
|
|
31
|
-
function* runCheckPhase(events, scope) {
|
|
32
|
-
for (const event of events) {
|
|
33
|
-
if (event.type === "yield") {
|
|
34
|
-
yield* ReplayGuard.invoke(scope, "check", [event]);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
/**
|
|
39
|
-
* Execute a durable workflow.
|
|
40
|
-
*
|
|
41
|
-
* 1. Reads all events from the stream and builds a ReplayIndex.
|
|
42
|
-
* 2. Sets DurableContext on the current scope (inherited from caller).
|
|
43
|
-
* 3. Runs the workflow — replayed effects resolve synchronously from
|
|
44
|
-
* the index; live effects execute and persist before resuming.
|
|
45
|
-
* 4. On completion, appends a Close event to the stream.
|
|
46
|
-
* 5. On error, appends a Close(err) event.
|
|
47
|
-
*
|
|
48
|
-
* Returns the workflow's result value.
|
|
49
|
-
*
|
|
50
|
-
* Usage:
|
|
51
|
-
* // From async code (standalone):
|
|
52
|
-
* await run(() => durableRun(workflow, { stream }));
|
|
53
|
-
*
|
|
54
|
-
* // From inside an Effection generator (inherits scope):
|
|
55
|
-
* const result = yield* durableRun(workflow, { stream });
|
|
56
|
-
*/
|
|
57
|
-
export function* durableRun(workflow, options) {
|
|
58
|
-
const { stream, coroutineId = "root" } = options;
|
|
59
|
-
// Read all events and build replay index
|
|
60
|
-
const events = yield* stream.readAll();
|
|
61
|
-
const replayIndex = new ReplayIndex(events);
|
|
62
|
-
// Inherit the caller's scope — middleware (e.g., Divergence, ReplayGuard)
|
|
63
|
-
// is already installed by the caller before yield*-ing into durableRun.
|
|
64
|
-
const scope = yield* useScope();
|
|
65
|
-
scope.set(DurableCtx, {
|
|
66
|
-
replayIndex,
|
|
67
|
-
stream,
|
|
68
|
-
coroutineId,
|
|
69
|
-
childCounter: 0,
|
|
70
|
-
});
|
|
71
|
-
// ── REPLAY GUARD: Check phase ──
|
|
72
|
-
// Run before the workflow starts. Middleware can yield* for I/O (hash
|
|
73
|
-
// files, make network requests) to gather observations for the decide
|
|
74
|
-
// phase. The check loop iterates all Yield events in journal order.
|
|
75
|
-
// See replay-guard-spec.md §5.5.
|
|
76
|
-
yield* runCheckPhase(events, scope);
|
|
77
|
-
// If the root coroutine already has a Close event in the journal,
|
|
78
|
-
// the workflow completed in a previous run. Return the stored result
|
|
79
|
-
// directly without re-running the workflow.
|
|
80
|
-
if (replayIndex.hasClose(coroutineId)) {
|
|
81
|
-
const closeEvent = replayIndex.getClose(coroutineId);
|
|
82
|
-
if (closeEvent.result.status === "ok") {
|
|
83
|
-
return closeEvent.result.value;
|
|
84
|
-
}
|
|
85
|
-
else if (closeEvent.result.status === "err") {
|
|
86
|
-
throw deserializeError(closeEvent.result.error);
|
|
87
|
-
}
|
|
88
|
-
else {
|
|
89
|
-
throw new Error("Workflow was cancelled");
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
try {
|
|
93
|
-
// Workflow<T> is structurally assignable to Operation<T>, so
|
|
94
|
-
// yield* accepts it directly — no cast needed.
|
|
95
|
-
const result = yield* workflow();
|
|
96
|
-
// §6.3: Check for early return divergence.
|
|
97
|
-
// If the generator returned but the replay index has unconsumed yields,
|
|
98
|
-
// the workflow has diverged. Skip this check when replay has been
|
|
99
|
-
// disabled (run-live mode) — the workflow intentionally diverged and
|
|
100
|
-
// the Divergence API already approved it.
|
|
101
|
-
if (!replayIndex.isReplayDisabled(coroutineId)) {
|
|
102
|
-
const unconsumed = replayIndex.firstUnconsumed();
|
|
103
|
-
if (unconsumed) {
|
|
104
|
-
throw new EarlyReturnDivergenceError(unconsumed.coroutineId, unconsumed.cursor, unconsumed.totalYields);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
const closeEvent = {
|
|
108
|
-
type: "close",
|
|
109
|
-
coroutineId,
|
|
110
|
-
result: { status: "ok", value: result },
|
|
111
|
-
};
|
|
112
|
-
yield* stream.append(closeEvent);
|
|
113
|
-
return result;
|
|
114
|
-
}
|
|
115
|
-
catch (error) {
|
|
116
|
-
// Normalize the error once — use the same Error object for both the
|
|
117
|
-
// Close event and the rethrow so that live runs and replayed Close
|
|
118
|
-
// events carry identical error shapes.
|
|
119
|
-
const primary = error instanceof Error ? error : new Error(String(error));
|
|
120
|
-
const closeEvent = {
|
|
121
|
-
type: "close",
|
|
122
|
-
coroutineId,
|
|
123
|
-
result: {
|
|
124
|
-
status: "err",
|
|
125
|
-
error: serializeError(primary),
|
|
126
|
-
},
|
|
127
|
-
};
|
|
128
|
-
try {
|
|
129
|
-
yield* stream.append(closeEvent);
|
|
130
|
-
}
|
|
131
|
-
catch (appendError) {
|
|
132
|
-
const appendFailure = appendError instanceof Error ? appendError : new Error(String(appendError));
|
|
133
|
-
throw new AggregateError([primary, appendFailure], "Workflow failed and Close append also failed");
|
|
134
|
-
}
|
|
135
|
-
throw primary;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Serialization utilities for the durable execution protocol.
|
|
3
|
-
*
|
|
4
|
-
* Converts between:
|
|
5
|
-
* - Protocol Result ({ status: "ok" | "err" | "cancelled" })
|
|
6
|
-
* - Effection Result ({ ok: true, value } | { ok: false, error })
|
|
7
|
-
* - Error ↔ SerializedError
|
|
8
|
-
*/
|
|
9
|
-
// ---------------------------------------------------------------------------
|
|
10
|
-
// Error serialization
|
|
11
|
-
// ---------------------------------------------------------------------------
|
|
12
|
-
/** Serialize an Error to a JSON-safe SerializedError. */
|
|
13
|
-
export function serializeError(error) {
|
|
14
|
-
return {
|
|
15
|
-
message: error.message,
|
|
16
|
-
name: error.name,
|
|
17
|
-
stack: error.stack,
|
|
18
|
-
};
|
|
19
|
-
}
|
|
20
|
-
/** Deserialize a SerializedError back to an Error. */
|
|
21
|
-
export function deserializeError(se) {
|
|
22
|
-
const error = new Error(se.message);
|
|
23
|
-
if (se.name)
|
|
24
|
-
error.name = se.name;
|
|
25
|
-
if (se.stack)
|
|
26
|
-
error.stack = se.stack;
|
|
27
|
-
return error;
|
|
28
|
-
}
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
// Result conversion: Protocol ↔ Effection
|
|
31
|
-
// ---------------------------------------------------------------------------
|
|
32
|
-
/**
|
|
33
|
-
* Convert a protocol Result to an Effection Result.
|
|
34
|
-
*
|
|
35
|
-
* - ok → { ok: true, value }
|
|
36
|
-
* - err → { ok: false, error } (deserialized)
|
|
37
|
-
* - cancelled → { ok: false, error } with a CancelledError
|
|
38
|
-
*
|
|
39
|
-
* The value is returned as-is (Json). The caller is responsible for any
|
|
40
|
-
* narrowing to a specific type T.
|
|
41
|
-
*/
|
|
42
|
-
export function protocolToEffection(result) {
|
|
43
|
-
switch (result.status) {
|
|
44
|
-
case "ok":
|
|
45
|
-
return { ok: true, value: result.value };
|
|
46
|
-
case "err":
|
|
47
|
-
return { ok: false, error: deserializeError(result.error) };
|
|
48
|
-
case "cancelled":
|
|
49
|
-
return { ok: false, error: new Error("cancelled") };
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
/**
|
|
53
|
-
* Convert an Effection Result to a protocol Result.
|
|
54
|
-
*
|
|
55
|
-
* The value must be JSON-serializable. This function does NOT validate
|
|
56
|
-
* serializability — that is the caller's responsibility.
|
|
57
|
-
*/
|
|
58
|
-
export function effectionToProtocol(result) {
|
|
59
|
-
if (result.ok) {
|
|
60
|
-
return { status: "ok", value: result.value };
|
|
61
|
-
}
|
|
62
|
-
else {
|
|
63
|
-
return { status: "err", error: serializeError(result.error) };
|
|
64
|
-
}
|
|
65
|
-
}
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DurableStream interface and in-memory implementation.
|
|
3
|
-
*
|
|
4
|
-
* The interface is intentionally abstract — protocol-specification.md §11
|
|
5
|
-
* does not prescribe a physical encoding or transport.
|
|
6
|
-
*/
|
|
7
|
-
function cloneEvent(event) {
|
|
8
|
-
return structuredClone(event);
|
|
9
|
-
}
|
|
10
|
-
function cloneEvents(events) {
|
|
11
|
-
return events.map(cloneEvent);
|
|
12
|
-
}
|
|
13
|
-
/**
|
|
14
|
-
* In-memory DurableStream implementation for testing.
|
|
15
|
-
*
|
|
16
|
-
* Provides optional hooks for:
|
|
17
|
-
* - Tracking append calls (to verify no re-execution during replay)
|
|
18
|
-
* - Injecting failures (for persist-before-resume testing)
|
|
19
|
-
*/
|
|
20
|
-
export class InMemoryStream {
|
|
21
|
-
events = [];
|
|
22
|
-
/** Count of append calls, useful for verifying replay doesn't re-execute. */
|
|
23
|
-
appendCount = 0;
|
|
24
|
-
/** If set, append() will reject with this error. */
|
|
25
|
-
injectFailure = null;
|
|
26
|
-
/** Optional callback invoked on each append, before persistence. */
|
|
27
|
-
onAppend = null;
|
|
28
|
-
constructor(initialEvents = []) {
|
|
29
|
-
this.events = cloneEvents(initialEvents);
|
|
30
|
-
}
|
|
31
|
-
// deno-lint-ignore require-yield
|
|
32
|
-
*readAll() {
|
|
33
|
-
return cloneEvents(this.events);
|
|
34
|
-
}
|
|
35
|
-
// deno-lint-ignore require-yield
|
|
36
|
-
*append(event) {
|
|
37
|
-
if (this.injectFailure) {
|
|
38
|
-
throw this.injectFailure;
|
|
39
|
-
}
|
|
40
|
-
const cloned = cloneEvent(event);
|
|
41
|
-
this.onAppend?.(cloneEvent(cloned));
|
|
42
|
-
this.events.push(cloned);
|
|
43
|
-
this.appendCount++;
|
|
44
|
-
}
|
|
45
|
-
/** Get a snapshot of current events (for test assertions). */
|
|
46
|
-
snapshot() {
|
|
47
|
-
return cloneEvents(this.events);
|
|
48
|
-
}
|
|
49
|
-
/** Reset the stream (for test setup). */
|
|
50
|
-
reset(events = []) {
|
|
51
|
-
this.events = cloneEvents(events);
|
|
52
|
-
this.appendCount = 0;
|
|
53
|
-
this.injectFailure = null;
|
|
54
|
-
this.onAppend = null;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Protocol types for the two-event durable execution protocol.
|
|
3
|
-
*
|
|
4
|
-
* Protocol types (Json, Result, DurableEvent, etc.) are the fixed contract
|
|
5
|
-
* defined by protocol-specification.md and do not depend on Effection.
|
|
6
|
-
*
|
|
7
|
-
* Effection integration types (CoroutineView, DurableEffect, Workflow) are
|
|
8
|
-
* in the second section and bridge the protocol with Effection's runtime.
|
|
9
|
-
*/
|
|
10
|
-
export {};
|