@loomwork/effect-runtime 0.0.0-stage → 0.1.0

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 (53) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/README.md +65 -2
  4. package/dist/EffectRuntime.d.ts +121 -0
  5. package/dist/EffectRuntime.d.ts.map +1 -0
  6. package/dist/EffectRuntime.js +422 -0
  7. package/dist/EffectRuntime.js.map +1 -0
  8. package/dist/authz.d.ts +228 -0
  9. package/dist/authz.d.ts.map +1 -0
  10. package/dist/authz.js +281 -0
  11. package/dist/authz.js.map +1 -0
  12. package/dist/effects/A2AEffect.d.ts +14 -0
  13. package/dist/effects/A2AEffect.d.ts.map +1 -0
  14. package/dist/effects/A2AEffect.js +21 -0
  15. package/dist/effects/A2AEffect.js.map +1 -0
  16. package/dist/effects/HumanEffect.d.ts +13 -0
  17. package/dist/effects/HumanEffect.d.ts.map +1 -0
  18. package/dist/effects/HumanEffect.js +21 -0
  19. package/dist/effects/HumanEffect.js.map +1 -0
  20. package/dist/effects/LazyEffect.d.ts +10 -0
  21. package/dist/effects/LazyEffect.d.ts.map +1 -0
  22. package/dist/effects/LazyEffect.js +28 -0
  23. package/dist/effects/LazyEffect.js.map +1 -0
  24. package/dist/effects/LlmEffect.d.ts +27 -0
  25. package/dist/effects/LlmEffect.d.ts.map +1 -0
  26. package/dist/effects/LlmEffect.js +27 -0
  27. package/dist/effects/LlmEffect.js.map +1 -0
  28. package/dist/effects/ToolEffect.d.ts +38 -0
  29. package/dist/effects/ToolEffect.d.ts.map +1 -0
  30. package/dist/effects/ToolEffect.js +31 -0
  31. package/dist/effects/ToolEffect.js.map +1 -0
  32. package/dist/index.d.ts +12 -0
  33. package/dist/index.d.ts.map +1 -0
  34. package/dist/index.js +30 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/policy.d.ts +76 -0
  37. package/dist/policy.d.ts.map +1 -0
  38. package/dist/policy.js +212 -0
  39. package/dist/policy.js.map +1 -0
  40. package/dist/types.d.ts +416 -0
  41. package/dist/types.d.ts.map +1 -0
  42. package/dist/types.js +28 -0
  43. package/dist/types.js.map +1 -0
  44. package/package.json +56 -3
  45. package/src/EffectRuntime.ts +484 -0
  46. package/src/authz.ts +424 -0
  47. package/src/effects/HumanEffect.ts +25 -0
  48. package/src/effects/LazyEffect.ts +25 -0
  49. package/src/effects/LlmEffect.ts +39 -0
  50. package/src/effects/ToolEffect.ts +59 -0
  51. package/src/index.ts +48 -0
  52. package/src/policy.ts +296 -0
  53. package/src/types.ts +428 -0
@@ -0,0 +1,416 @@
1
+ import type { TaintedValue } from "@loomwork/classify";
2
+ import type { EffectKind, EffectMode } from "@loomwork/core";
3
+ import type { PrincipalChain } from "./authz";
4
+ export type { EffectKind, EffectMode };
5
+ /**
6
+ * A tool was invoked from the wrong side of the determinism boundary — an
7
+ * `ambient` effect inside an execution, or a `journaled` effect transiently.
8
+ *
9
+ * This is a contract violation, not an authorization denial: it is thrown
10
+ * plainly rather than recorded as an EFFECT_DENIED fact, because the caller used
11
+ * the wrong entry point (a programming error) rather than lacking authority.
12
+ */
13
+ export declare class EffectModeViolationError extends Error {
14
+ readonly toolId: string;
15
+ readonly declared: EffectMode;
16
+ /** The seam the call actually came through. */
17
+ readonly attempted: "journaled" | "ambient";
18
+ constructor(toolId: string, declared: EffectMode, attempted: "journaled" | "ambient");
19
+ }
20
+ /**
21
+ * Running resource totals for the owning execution (Loom 2.0 K3). Threaded into
22
+ * each effect's context so accounting/quota middleware can meter usage and enforce
23
+ * budgets. Accumulated durably in `variables._resources` via RESOURCE_METERED, so
24
+ * the snapshot is deterministic on replay.
25
+ */
26
+ export interface ResourceSnapshot {
27
+ tokens: number;
28
+ costUsd: number;
29
+ ms: number;
30
+ count: number;
31
+ memBytes?: number;
32
+ wallMs?: number;
33
+ netBytes?: number;
34
+ /**
35
+ * ADR 0115 — open metering dimensions (`gpu_seconds`, `credits`, ...), present only once
36
+ * something meters one. Read with `Object.hasOwn`: the keys are caller-chosen names.
37
+ */
38
+ custom?: Readonly<Record<string, number>>;
39
+ /** Set once a quota has been exceeded. */
40
+ exceeded?: boolean;
41
+ }
42
+ /**
43
+ * ADR 0119 — one live chunk of a streaming effect's output, delivered to the per-call
44
+ * sink a caller attached ({@link EffectContext.onDelta}).
45
+ *
46
+ * - TEXT chunk: `delta` carries the text (never empty), WRAPPED as untrusted content
47
+ * (`TaintedValue<string>`, ADR 0085): model output is third-party text, and the
48
+ * wrapper makes `guardRequest` refuse it if it is fed back into a prompt. A UI tail
49
+ * unwraps explicitly (`unwrapTainted`) to display it.
50
+ * - REASONING chunk (`reasoning` set, `delta` empty; ADR 0133 D6): a readable increment of the
51
+ * model's reasoning, a channel SEPARATE from text so no consumer of text deltas sees it by
52
+ * accident. Wrapped as untrusted (`llm-reasoning:<stepId>`). It passes the middleware
53
+ * `deltaGuard`s (a guard instance of its own, so reasoning never pollutes the running text a
54
+ * guard accumulates) and reaches the PER-CALL sink only, never the effect's configured
55
+ * `deltaSink`. Live-only and never journaled.
56
+ * - RESET chunk (`reset: true`, `attempt: n`, empty `delta`): attempt `n > 1` of the
57
+ * call is starting (retry middleware re-ran the effect) — discard everything shown
58
+ * so far and re-render from this attempt's deltas.
59
+ * - TERMINAL chunk (`done: true`, empty `delta`, `usage` if the provider reported
60
+ * one): sent ONLY after the call has succeeded (middleware output phase and parse
61
+ * included). On failure or abort there is no terminal chunk — the call's promise
62
+ * settlement is authoritative.
63
+ */
64
+ export interface EffectDelta {
65
+ readonly delta: TaintedValue<string>;
66
+ /** ADR 0133 D6 - a reasoning increment; `delta` is empty on such a chunk. */
67
+ readonly reasoning?: TaintedValue<string>;
68
+ readonly done?: true;
69
+ readonly reset?: true;
70
+ readonly attempt?: number;
71
+ readonly usage?: {
72
+ readonly inputTokens: number;
73
+ readonly outputTokens: number;
74
+ };
75
+ }
76
+ /** A per-call, live-only, synchronous, fire-and-forget delta sink (ADR 0119). */
77
+ export type DeltaSink = (chunk: EffectDelta) => void;
78
+ export interface EffectContext {
79
+ executionId: string;
80
+ stepId: string;
81
+ workflowId: string;
82
+ attempt: number;
83
+ /**
84
+ * The resolved effect id for this invocation. The runtime sets it on the view
85
+ * of the context the MIDDLEWARE chain receives (additive — hand-built contexts
86
+ * simply leave it unset, and the effect itself still receives the caller's
87
+ * context unchanged). Lets cross-cutting middleware key per-tool configuration
88
+ * — e.g. claim-check digest declarations — without a lookup side channel.
89
+ */
90
+ readonly toolId?: string;
91
+ /**
92
+ * ADR 0140 - the `TaskDefinition.kind` of the durable frame this effect runs in (`"tool"` for an
93
+ * `agentLoop$` tool call, `"llm"` for its model turn, `"memory"` for its compaction), set by the workflow
94
+ * runtime on durable steps. Absent on a transient (`invokeTransient`) call and on hand-built contexts.
95
+ * Lets middleware that must touch only an agent's TOOL frames (the opt-in claim-check) tell them from
96
+ * a model or summarizer call that shares no id vocabulary with them.
97
+ */
98
+ readonly taskKind?: string;
99
+ /**
100
+ * L30 — set only on a transient call stamped as an execution (`ctx.decide`'s
101
+ * scope). Several such calls share `(executionId, stepId = toolId)`, so a site
102
+ * that mints an id from those two (A2A task id, redaction secret-ref id) MUST
103
+ * append this when present, or two calls collide. Undefined on durable steps
104
+ * (their stepId is unique) and on bare transient calls.
105
+ */
106
+ readonly callKey?: string;
107
+ /**
108
+ * ADR 0119 — the per-call delta sink of a transient call that asked for one. A
109
+ * streaming effect (`createLlmEffect({ stream: true })`) calls it for each delta;
110
+ * the runtime forwards to the effect's CONFIGURED sink ({@link Effect.deltaSink}) from
111
+ * here, after the guards, so the configured sink is governed on every path. Live-only: never journaled, and not
112
+ * called on replay. The runtime hands the effect a wrapper that goes silent once the
113
+ * call settles or aborts and swallows a throw of the CALLER's sink. It throws in ONE
114
+ * case: a middleware delta guard (e.g. a safety delta validator) refused the running
115
+ * text — that throw means "sever the stream", so an effect MUST let it propagate
116
+ * (do not catch it around the call). Absent when no runtime is
117
+ * involved or neither a caller nor the effect has a sink. When the runtime REFUSED streaming
118
+ * (an output validator has no delta form) it is a silent no-op — never absent.
119
+ */
120
+ readonly onDelta?: DeltaSink;
121
+ emit(eventType: string, payload: unknown): void;
122
+ /**
123
+ * Cooperative cancellation for this effect. Aborts when the owning execution is
124
+ * cancelled. Long-running/I/O effects (LLM, tool, A2A) should forward it to
125
+ * `fetch`/providers and check it at await points. Optional and undefined by
126
+ * default, so non-cancellable effects pay nothing.
127
+ */
128
+ readonly signal?: AbortSignal;
129
+ /**
130
+ * Running resource totals for this execution BEFORE this effect (K3). Undefined
131
+ * when accounting is not wired, so effects/middleware that don't meter pay nothing.
132
+ */
133
+ readonly resources?: ResourceSnapshot;
134
+ /**
135
+ * K9.2 — deterministic idempotency key for this effect invocation:
136
+ * `executionId:stepId` (no attempt component — the folded attempt counter
137
+ * advances on crash-recovery re-execution, which is exactly when the key must
138
+ * stay stable). Recovery re-runs of the same logical attempt therefore carry
139
+ * the SAME key, so tool authors can deduplicate at the provider. A fresh key
140
+ * (`executionId:stepId:rN`) is minted only by a durable new-logical-attempt
141
+ * fact — an EFFECT_INDOUBT "retry-fresh" verdict.
142
+ */
143
+ readonly idempotencyKey?: string;
144
+ /**
145
+ * Scheduling priority for this effect (M6), used when the EffectRuntime routes
146
+ * effects through a Scheduler with a finite concurrency cap. A `@loomwork/scheduler`
147
+ * Priority value (0 = Immediate … 3 = Idle). Undefined ⇒ Normal.
148
+ */
149
+ readonly priority?: number;
150
+ /**
151
+ * K12.1 — the authenticated principal chain for the owning execution, carried
152
+ * opaquely and handed to the K2 authorizer. Set at the trust boundary
153
+ * (start/resume). Undefined ⇒ no principal (today's behavior).
154
+ */
155
+ readonly principal?: PrincipalChain;
156
+ }
157
+ /**
158
+ * Options for a transient (non-durable) effect invocation — {@link
159
+ * EffectRuntime.invokeTransient}. Every field is optional; the empty case is a
160
+ * bare one-shot call with no cancellation and no principal.
161
+ *
162
+ * There is deliberately no `executionId`/`stepId`/`attempt`/`emit` here: those
163
+ * are the internal {@link EffectContext} shape the façade OWNS and synthesizes,
164
+ * so a caller never couples to it (R2). None of these fields is ever persisted —
165
+ * a transient invocation writes nothing (R1).
166
+ */
167
+ export interface TransientInvokeOptions {
168
+ /**
169
+ * Optional cooperative cancellation (R5). Forwarded verbatim as
170
+ * `EffectContext.signal`, so it propagates to the effect's `fetch`/sandbox/
171
+ * provider and aborts the in-flight call. Lets a caller bound a transient
172
+ * invocation with a deadline.
173
+ */
174
+ readonly signal?: AbortSignal;
175
+ /**
176
+ * Optional authenticated principal chain. If an authorizer is wired on the
177
+ * runtime (K2 protection ring), it is still enforced transiently — an
178
+ * unauthorized effect never runs. Undefined ⇒ no principal (today's behavior).
179
+ */
180
+ readonly principal?: PrincipalChain;
181
+ /**
182
+ * A trace/metric tag, never persisted (there is nothing to persist to).
183
+ * Defaults to the `toolId`. Without `as` it is NOT only a tag: it becomes the
184
+ * synthetic context's `executionId` (`transient:<label>`), which the authorizer
185
+ * and middleware see — a `GrantTable`/`PolicyAuthorizer` configured to grant
186
+ * per execution decides on it. Wrappers that memoise must key on it.
187
+ */
188
+ readonly label?: string;
189
+ /**
190
+ * L30 — run AS an existing execution instead of the throwaway `transient`
191
+ * identity: the synthesized {@link EffectContext} carries this `workflowId`/
192
+ * `executionId`, so the K2 authorizer's flow-scoped grants, delegation checks and
193
+ * trace/middleware correlation see the deciding execution. It changes only WHAT
194
+ * IS CLAIMED, never what is written: the call is still transient (R1-R5
195
+ * unchanged — no journal, no-op `emit`, same middleware chain, `ambient`
196
+ * effect-mode check). `stepId` defaults to the toolId, exactly as a bare call, so
197
+ * stepId-keyed per-tool configuration (redaction, pricing, retry) matches.
198
+ *
199
+ * Trust model: this is the transient analogue of the EffectContext a holder of
200
+ * the runtime can already hand-build via `execute`; it is NOT a capability check.
201
+ * `ctx.decide`'s scope is the only thing flow code is handed, and it pre-stamps
202
+ * the identity, so flow code cannot choose one. Do not forward untrusted input
203
+ * into `as`.
204
+ *
205
+ * Because the identity is an AUTHORIZATION INPUT (`GrantTable` keys on
206
+ * `workflowId`), any wrapper that memoises, dedupes or caches transient calls
207
+ * MUST key on the identity (`as`) as well as `[principal, input]` — and on
208
+ * `label` too, which names the transient execution when `as` is absent (the
209
+ * simplest safe key is every option except `signal`) — otherwise a result
210
+ * produced under one execution's grants can be served to another's.
211
+ */
212
+ readonly as?: TransientIdentity;
213
+ /**
214
+ * Running resource totals of the execution the call is stamped as, so metering /
215
+ * `nestedBudget` ADMISSION control can refuse an over-budget execution. The
216
+ * CHARGE is not recorded: `emit` is the no-op sink, so a transient call's spend
217
+ * never reaches the fold (a decider's LLM spend is invisible to the budget).
218
+ */
219
+ readonly resources?: ResourceSnapshot;
220
+ /**
221
+ * ADR 0119 — a live delta sink for this call, surfaced as `EffectContext.onDelta`.
222
+ * Synchronous and fire-and-forget; a throw is swallowed (and warned), never fails
223
+ * the call. Does not change what the call records (nothing — it is transient).
224
+ */
225
+ readonly onDelta?: DeltaSink;
226
+ }
227
+ /** The execution identity a transient invocation is stamped with (L30). */
228
+ export interface TransientIdentity {
229
+ readonly workflowId: string;
230
+ readonly executionId: string;
231
+ /** Defaults to the toolId. Keep it that way if per-tool config is keyed by stepId. */
232
+ readonly stepId?: string;
233
+ /**
234
+ * Disambiguates calls that share `(executionId, stepId)` (several calls to one
235
+ * tool within an execution): surfaces as {@link EffectContext.callKey}.
236
+ */
237
+ readonly callKey?: string;
238
+ }
239
+ /**
240
+ * Advisory pre-warm hook (M6). Called off the durable path before an effect runs so a
241
+ * backend can keep a model/connection/cache hot. It is latency-only — it MUST NOT emit
242
+ * events or influence outcomes (P10 advisory plane). A failed/absent warm just means a
243
+ * cold call.
244
+ */
245
+ export interface Warmer {
246
+ warm(toolId: string): void;
247
+ }
248
+ /**
249
+ * Minimal structural view of a Zod schema's `safeParse` — kept structural so
250
+ * `@loomwork/effect-runtime` stays zod-free (the same dependency-light idiom as
251
+ * core's AgentSkillDef). A `Record<string, z.ZodSchema>` satisfies it directly.
252
+ */
253
+ export interface ErrorChannelSchema {
254
+ safeParse(input: unknown): {
255
+ success: true;
256
+ data: unknown;
257
+ } | {
258
+ success: false;
259
+ error: {
260
+ message: string;
261
+ };
262
+ };
263
+ }
264
+ /**
265
+ * ADR 0133 D7 - app-level defaults for model-reasoning capture, structural so the composition root
266
+ * needs no dependency on `@loomwork/llm`. `store` is the sidecar where readable reasoning is kept
267
+ * (a content store satisfies it); `capture` defaults to on iff a store is configured.
268
+ */
269
+ export interface EffectReasoningDefaults {
270
+ store?: {
271
+ put(bytes: Uint8Array): Promise<{
272
+ readonly __loomContent: string;
273
+ }>;
274
+ get(ref: {
275
+ readonly __loomContent: string;
276
+ }): Promise<Uint8Array | null>;
277
+ };
278
+ capture?: boolean | ((ctx: EffectContext) => boolean);
279
+ live?: boolean;
280
+ }
281
+ /**
282
+ * ADR 0133 D7 - what the composition root may ask of an LLM effect about model reasoning: whether its
283
+ * provider can return any, how it is configured now, and to adopt app-level defaults (the effect's
284
+ * own settings win field by field). Not part of the call path.
285
+ */
286
+ export interface EffectReasoningHook {
287
+ /** The provider declares `capabilities.reasoning`. */
288
+ readonly capable: boolean;
289
+ /** `keeps`: a store is configured and capture is not off. `off`: capture is explicitly off. `unconfigured`: neither. */
290
+ posture(): "keeps" | "off" | "unconfigured";
291
+ /** Adopt defaults; `false` is the explicit, silent opt-out (capture off) for whatever the effect did not set itself. */
292
+ adopt(defaults: EffectReasoningDefaults | false): void;
293
+ }
294
+ export interface Effect<TIn = unknown, TOut = unknown> {
295
+ kind: EffectKind;
296
+ /** ADR 0133 D7 - present on an LLM effect built by `createLlmEffect`. */
297
+ readonly reasoning?: EffectReasoningHook;
298
+ /**
299
+ * K9.3 — the effect's declared delivery semantic. `false` marks a mutating,
300
+ * non-idempotent effect: recovery refuses to silently re-run an attempt in an
301
+ * unknown state (it parks the execution at EFFECT_INDOUBT for an operator
302
+ * verdict). `true` asserts re-runs are safe (dedupe at the provider via
303
+ * `EffectContext.idempotencyKey`). Undeclared (`undefined`) keeps the legacy
304
+ * at-least-once re-run — existing effects behave byte-identically (G2).
305
+ */
306
+ readonly idempotent?: boolean;
307
+ /**
308
+ * DE-4 (ADR 0060) — the effect's declared failure channels (channel name →
309
+ * payload schema). Like `idempotent`, a declaration the kernel reads from the
310
+ * registry: a `TypedFailure` thrown on a declared channel is recorded as a
311
+ * typed STEP_FAILED and substituted on replay. A step-level `errors:`
312
+ * declaration wins over this registry fallback. Undeclared channels keep the
313
+ * legacy untyped at-least-once failure semantics (G2).
314
+ */
315
+ readonly errors?: Record<string, ErrorChannelSchema>;
316
+ /**
317
+ * Which invocation seam this effect belongs to — `"journaled"` (inside an
318
+ * execution) or `"ambient"` (transient only). Like `idempotent`, a declaration
319
+ * the runtime reads from the registry: `execute` refuses an `ambient` effect
320
+ * and `invokeTransient` refuses a `"journaled"` one, both with
321
+ * {@link EffectModeViolationError}. Undeclared keeps today's behaviour on both
322
+ * paths (G2).
323
+ *
324
+ * This is a boundary check, not a journaling mode: an `ambient` effect is not
325
+ * "an effect that skips its journal" — it is one that may not enter an
326
+ * execution at all, so there remains exactly one replay model.
327
+ */
328
+ readonly mode?: EffectMode;
329
+ /**
330
+ * ADR 0119 — the effect's own CONFIGURED live-delta sink (e.g. `createLlmEffect({ onDelta })`).
331
+ * Declared here so the RUNTIME owns delivery: the effect calls `ctx.onDelta` and the runtime
332
+ * forwards text and reset chunks (never the terminal one) to this sink only AFTER the
333
+ * middleware delta guards ran, on durable and transient calls alike; it is silent (fail
334
+ * closed) under an output validator that has no delta form. Called directly by the effect
335
+ * only when there is no runtime (`ctx.onDelta` absent).
336
+ */
337
+ readonly deltaSink?: (chunk: EffectDelta, ctx: EffectContext) => void;
338
+ /**
339
+ * ADR 0133 D6 - the effect's configured live REASONING sink (e.g. `createLlmEffect({ reasoning:
340
+ * { live: true, onDelta } })`). Reasoning is a channel of its own, so it never reaches
341
+ * {@link deltaSink}; the runtime forwards a reasoning increment here only AFTER the middleware
342
+ * delta guards ran, still wrapped as untrusted content, and is silent under an output validator
343
+ * that has no delta form (fail closed), exactly like the text sink.
344
+ */
345
+ readonly reasoningSink?: (delta: TaintedValue<string>, ctx: EffectContext) => void;
346
+ /**
347
+ * R12 — the sub-resource one call of this effect targets, for per-resource authorization.
348
+ * ONE registered effect can front many resources (`mcp.invoke` fronts every tool on an MCP
349
+ * server); an authorizer that only sees the effect id can allow all of them or none. When
350
+ * declared, the runtime evaluates it on the call's input and puts the result on
351
+ * `CapabilityRequest.resource`, so a grant or policy can name `<effectId>:<resource>`.
352
+ *
353
+ * Pure and cheap — it runs before the effect, on the raw (pre-redaction) input. Returning
354
+ * `undefined` (or throwing) means "no resource": only an effect-wide grant then applies, so a
355
+ * per-resource grant fails CLOSED rather than matching nothing-in-particular.
356
+ *
357
+ * It is evaluated on the input as the call site passed it, BEFORE any middleware runs: a
358
+ * middleware that rewrites the input (e.g. the tool name) after authorization is not seen by
359
+ * the authorizer, so never rely on a middleware to change what the authorization decision
360
+ * names.
361
+ */
362
+ resourceOf?(input: TIn): string | undefined;
363
+ /**
364
+ * Advisory pre-warm (latency-only, off the durable path). If present, the
365
+ * kernel may call it BEFORE the effect is needed to spin up a cold resource
366
+ * the effect depends on — a K7 isolated child process, an LLM provider
367
+ * connection/model cache, a remote worker. Like the {@link Warmer} seam it is
368
+ * strictly latency: it MUST NOT emit events or influence outcomes (P10), and a
369
+ * failure is a silent no-op → the first real call just runs cold. Idempotent
370
+ * and safe to call repeatedly.
371
+ */
372
+ warm?(): void | Promise<void>;
373
+ execute(input: TIn, ctx: EffectContext): Promise<TOut>;
374
+ }
375
+ /**
376
+ * Invokes the downstream middleware, or finally the effect itself. Call `next()`
377
+ * to pass this middleware's input through unchanged; call `next(override)` to
378
+ * feed a TRANSFORMED input to the effect (e.g. K10.3 redaction resolving secret
379
+ * references). The override flows to the effect body, not back to the log.
380
+ */
381
+ export type EffectNext = (input?: unknown) => Promise<unknown>;
382
+ /**
383
+ * Wraps a single effect execution — the cross-cutting seam (retries, logging,
384
+ * tracing, idempotency) around every tool/LLM/human/a2a call.
385
+ *
386
+ * Replay-safety: middleware runs ONLY when an effect actually executes. Completed
387
+ * steps short-circuit in `WorkflowContext.run` before `EffectRuntime.execute` is
388
+ * called, so replayed/resumed runs never re-invoke middleware for work already in
389
+ * the log. The one in-flight step that re-runs on crash-recovery re-runs its
390
+ * middleware too, so any middleware with external side effects must tolerate
391
+ * at-least-once (prefer emitting durable events via `ctx.emit`).
392
+ *
393
+ * Ordering is static (fixed at `EffectRuntime` construction) so it is
394
+ * deterministic across replay: `middleware[0]` is outermost.
395
+ */
396
+ export interface EffectMiddleware {
397
+ (ctx: EffectContext, input: unknown, next: EffectNext): Promise<unknown>;
398
+ /**
399
+ * ADR 0119 — declares that this middleware INSPECTS THE OUTPUT of the effect (an
400
+ * output-phase safety validator). A streamed call would deliver text to a live sink
401
+ * before that inspection could run, so: if any middleware in the chain has
402
+ * `inspectsOutput: true`, the runtime REFUSES streaming to BOTH the per-call sink and
403
+ * the effect's configured `deltaSink` (warns; the call completes non-streamed). Fail-closed — no configuration streams text past an
404
+ * output check that cannot see it. A middleware whose every output check has a delta
405
+ * form covers it with {@link deltaGuard} and leaves this unset.
406
+ */
407
+ readonly inspectsOutput?: boolean;
408
+ /**
409
+ * ADR 0119 — a per-call-attempt guard over the RUNNING text of a streamed call.
410
+ * Called once per attempt to build a `(delta) => void`, which the runtime invokes for
411
+ * each text delta BEFORE it is delivered; it THROWS to sever the stream (the
412
+ * offending delta is not delivered).
413
+ */
414
+ readonly deltaGuard?: (ctx: EffectContext) => (delta: string) => void;
415
+ }
416
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/dist/types.js ADDED
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EffectModeViolationError = void 0;
4
+ /**
5
+ * A tool was invoked from the wrong side of the determinism boundary — an
6
+ * `ambient` effect inside an execution, or a `journaled` effect transiently.
7
+ *
8
+ * This is a contract violation, not an authorization denial: it is thrown
9
+ * plainly rather than recorded as an EFFECT_DENIED fact, because the caller used
10
+ * the wrong entry point (a programming error) rather than lacking authority.
11
+ */
12
+ class EffectModeViolationError extends Error {
13
+ toolId;
14
+ declared;
15
+ /** The seam the call actually came through. */
16
+ attempted;
17
+ constructor(toolId, declared, attempted) {
18
+ super(declared === "ambient"
19
+ ? `Effect "${toolId}" is declared ambient and must not be invoked inside an execution — its result would be journaled, and a call whose count/order is user-driven poisons replay. Use EffectRuntime.invokeTransient (ADR 0066).`
20
+ : `Effect "${toolId}" is declared journaled and must not be invoked transiently — a transient call has zero durable footprint, so the durable fact the caller expected would silently not exist. Use ctx.execute inside an execution.`);
21
+ this.name = "EffectModeViolationError";
22
+ this.toolId = toolId;
23
+ this.declared = declared;
24
+ this.attempted = attempted;
25
+ }
26
+ }
27
+ exports.EffectModeViolationError = EffectModeViolationError;
28
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":";;;AAMA;;;;;;;GAOG;AACH,MAAa,wBAAyB,SAAQ,KAAK;IACxC,MAAM,CAAS;IACf,QAAQ,CAAa;IAC9B,+CAA+C;IACtC,SAAS,CAA0B;IAC5C,YAAY,MAAc,EAAE,QAAoB,EAAE,SAAkC;QAClF,KAAK,CACH,QAAQ,KAAK,SAAS;YACpB,CAAC,CAAC,WAAW,MAAM,8MAA8M;YACjO,CAAC,CAAC,WAAW,MAAM,mNAAmN,CACzO,CAAC;QACF,IAAI,CAAC,IAAI,GAAG,0BAA0B,CAAC;QACvC,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;QACzB,IAAI,CAAC,SAAS,GAAG,SAAS,CAAC;IAC7B,CAAC;CACF;AAhBD,4DAgBC"}
package/package.json CHANGED
@@ -1,6 +1,59 @@
1
1
  {
2
2
  "name": "@loomwork/effect-runtime",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Effect isolation layer for tool, LLM, human, and A2A effects",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "require": "./dist/index.js",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "dependencies": {
15
+ "@loomwork/core": "^0.1.0",
16
+ "@loomwork/event-runtime": "^0.1.0",
17
+ "@loomwork/replay-vm": "^0.1.0",
18
+ "@loomwork/scheduler": "^0.1.0",
19
+ "@loomwork/classify": "^0.1.0"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^22.0.0",
23
+ "typescript": "^5.6.3",
24
+ "vitest": "^2.1.8"
25
+ },
26
+ "license": "Apache-2.0",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/chhetrisushil/loom.git",
30
+ "directory": "packages/effect-runtime"
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "src",
35
+ "!src/**/*.test.ts",
36
+ "!src/**/*.test.tsx",
37
+ "!src/**/__tests__",
38
+ "!src/**/__fixtures__",
39
+ "!src/**/__snapshots__",
40
+ "!src/**/__golden__",
41
+ "LICENSE",
42
+ "NOTICE"
43
+ ],
44
+ "publishConfig": {
45
+ "access": "public",
46
+ "tag": "next"
47
+ },
48
+ "engines": {
49
+ "node": ">=20.0.0"
50
+ },
51
+ "scripts": {
52
+ "build": "tsc --build",
53
+ "dev": "tsc --build --watch",
54
+ "test": "vitest run",
55
+ "test:watch": "vitest",
56
+ "clean": "rm -rf dist tsconfig.tsbuildinfo",
57
+ "typecheck:tests": "tsc -p tsconfig.test.json"
58
+ }
6
59
  }