Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/remove-slots-and-harden-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@humanlayer/effect-machine": minor
---

Remove the deprecated Slot API in favor of Effect services supplied through Layers to `.task()`, `.spawn()`, and `.background()`.

This release also strengthens the package's type and runtime boundaries:

- Requirement-growing builder methods are copy-on-write, keeping earlier machine aliases unchanged and truthfully typed.
- Heterogeneous actor registries expose an eventless `ActorHandle`; exact typed spawn results remain `ActorRef<State, Event>`.
- Transition and state-effect registries retain their state/event correlations without chained assertions.
- State-effect contexts expose explicit `$init` and `$enter` lifecycle events.
- Entity machines own their RPC protocol; remote Ask replies are decoded with event-specific schemas and client errors remain typed.
- Persistence writes encode state and events through machine codecs, while loaded records remain `unknown` until full schema decoding.
- Local and entity Ask paths support transforming reply codecs without duplicate decoding or stranded deferred replies.
- Repeated and concurrent `actor.start` callers observe the original startup failure cause.
- Source enforces unsafe, chained, widening, and unnecessary type-assertion rules. Tests remain exempt from unsafe and unnecessary assertion checks.

Intentional API changes:

- Remove `Slot`, slot schemas/types/errors, `Machine.make({ slots })`, `ctx.slots`, and slot provision options.
- `toEntity(machine)` returns a machine-owned `MachineEntity`; call `EntityMachine.layer(entity, options?)`.
- Call `makeEntityActorRef(entity, client, entityId)` so the wrapper can decode replies and preserve client errors.
- `system.get`, `system.actors`, system events, and `actor.children` expose `ActorHandle`.
- Transition and spawn-effect introspection expose guarded `matches` / `run` operations instead of erased handlers.
- Legacy compatible constructors without a static tag can use `Machine.tagged(tag, constructor)`.
18 changes: 4 additions & 14 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@
"import/no-duplicates": "error",
"no-underscore-dangle": "off",
"no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
"typescript/no-unsafe-type-assertion": "off",
"typescript/no-unsafe-type-assertion": "error",
"typescript/no-unnecessary-type-parameters": "off",
"typescript/no-unnecessary-type-assertion": "off",
"typescript/no-unnecessary-type-assertion": "error",
"typescript/consistent-return": "off",
"typescript/no-unnecessary-type-arguments": "off",
"typescript/unbound-method": "off",
Expand All @@ -66,14 +66,14 @@
{
"files": ["**/*.test.ts"],
"rules": {
"anti-slop/no-chained-type-assertions": "off",
"anti-slop/no-known-value-widening": "off",
"anti-slop/no-object-parameters": "off",
"anti-slop/no-unknown-type-aliases": "off",
"anti-slop/no-unsafe-dictionary-type": "off",
"anti-slop/no-widen-then-assert": "off",
"anti-slop/require-safety-comment-for-type-assertion": "off",
"typescript/no-non-null-assertion": "off",
"typescript/no-unnecessary-type-assertion": "off",
"typescript/no-unsafe-type-assertion": "off",
"typescript/no-explicit-any": "off"
}
},
Expand All @@ -82,16 +82,6 @@
"rules": {
"anti-slop-effect/no-service-constructor-imports": "off"
}
},
{
"files": ["src/slot.ts"],
"rules": {
"anti-slop/no-known-value-widening": "off",
"anti-slop/no-unknown-parameters": "off",
"anti-slop/no-unknown-returns": "off",
"anti-slop/no-unsafe-dictionary-type": "off",
"anti-slop/require-safety-comment-for-type-assertion": "off"
}
}
]
}
12 changes: 6 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const machine = Machine.make({ state, event, initial })
.final(State.Done);
```

- Builder methods mutate `this`, return `this`
- Transition/final/postpone methods mutate `this`; requirement-growing `.spawn()`, `.task()`, `.timeout()`, and `.background()` methods are copy-on-write
- Builder chain ends naturally — no terminal method needed
- `.onAny()` fires when no specific `.on()` matches for that event

Expand Down Expand Up @@ -297,15 +297,15 @@ Wire machines to `@effect/cluster` for distributed actors:
import { toEntity, EntityMachine } from "@humanlayer/effect-machine/cluster";

const OrderEntity = toEntity(orderMachine, { type: "Order" });
const OrderEntityLayer = EntityMachine.layer(OrderEntity, orderMachine, {
const OrderEntityLayer = EntityMachine.layer(OrderEntity, {
initializeState: (entityId) => OrderState.Pending({ orderId: entityId }),
persistence: { strategy: "journal" },
});
```

- `toEntity` generates Entity with Send/Ask/GetState/WatchState RPCs
- `EntityMachine.layer` wires machine to cluster via shared runtime kernel
- `EntityActorRef`: typed client wrapper (send/ask/snapshot/watch/waitFor)
- `toEntity` generates a machine-owned Entity with Send/Ask/GetState/WatchState RPCs
- `EntityMachine.layer` wires the Entity's machine to cluster via the shared runtime kernel
- `EntityActorRef`: `makeEntityActorRef(entity, client, id)` decodes Ask replies and preserves client errors

### Entity Persistence

Expand All @@ -320,7 +320,7 @@ Opt-in via `EntityMachineOptions.persistence`:
### Cluster Gotchas

- Entity tests use `Entity.makeTestClient` + `ShardingConfig.layer` + `Effect.scoped`
- `EntityMachine.layer` accepts raw `Machine`
- `EntityMachine.layer` accepts the `MachineEntity` returned by `toEntity`; the entity owns its machine and protocol
- Entity RPCs use `.tag` field (not `._tag`) to distinguish request types
- WatchState test skipped due to effect beta Queue bug

Expand Down
47 changes: 30 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,15 @@ A few things to notice:
- `.onAny(...)` is a fallback; a specific `.on(...)` wins.
- `.task(...)` runs work on state entry, sends mapped completion events, and cancels work on state exit.

Generated state and event constructors carry a static `_tag`, so `State.Active(...)` and
`Event.Start(...)` remain directly usable in builder methods. If an older constructor returns a
compatible tagged value but does not expose a static tag, adapt it explicitly instead of invoking
it during registration:

```ts
const LegacyActive = Machine.tagged("Active", legacyActiveConstructor);
```

## Transitions And Effects

The fluent builder keeps state behavior beside the transitions that make it relevant:
Expand All @@ -103,9 +112,21 @@ The fluent builder keeps state behavior beside the transitions that make it rele

Use `self.send(...)` from a state effect to feed work back into the machine. State effects can use Effect services and can be asynchronous; transition handlers stay pure.

Methods that can add Effect requirements—`.spawn(...)`, `.task(...)`, `.timeout(...)`, and `.background(...)`—are copy-on-write. Always use their returned machine. This keeps an earlier alias truthful and unchanged:

```ts
const base = Machine.make({ state, event, initial });
const withWorker = base.spawn(State.Running, worker);

base.spawnEffects.length; // 0
withWorker.spawnEffects.length; // 1
```

State-effect contexts expose an honest lifecycle event union: initial effects receive `$init`, while effects started after a state transition receive `$enter`.

## Services And Layers

New machines use Effect's service system for dependencies, not actor-local slot maps. Define a dependency with `Context.Service` (the Effect v4 replacement for `ServiceMap.Service`), access it with `yield*` inside a state effect, and provide an implementation with a `Layer` at the program boundary.
Machines use Effect's service system for dependencies. Define a dependency with `Context.Service` (the Effect v4 replacement for `ServiceMap.Service`), access it with `yield*` inside a state effect, and provide an implementation with a `Layer` at the program boundary.

Requirements from `.task()`, `.spawn()`, and `.background()` are inferred by the machine and flow through `Machine.spawn`, `system.spawn`, and `EntityMachine.layer`. Transition handlers remain pure: they cannot require services or fail. Move I/O into a state effect and communicate its outcome with an event.

Expand All @@ -124,19 +145,6 @@ const program = Effect.gen(function* () {

This also makes testing conventional Effect code: provide a test layer around the actor program. `simulate` and `createTestHarness` do not run state effects, so they do not require their services.

### Migrating From Slots

`Slot`, `Machine.make({ slots })`, handler `({ slots })`, and `{ slots }` spawn options remain as deprecated compatibility APIs. Use them only while migrating an existing machine; they are not the DI mechanism for new code.

| Legacy slot pattern | Effect service replacement |
| --------------------------------------- | ------------------------------------------------------------------------ |
| `Slot.define({ charge: Slot.fn(...) })` | `class Payments extends Context.Service<...>()("@app/Payments") {}` |
| `Machine.make({ ..., slots })` | Read the service in `.task(...)`, `.spawn(...)`, or `.background(...)` |
| `Machine.spawn(machine, { slots })` | `Machine.spawn(machine).pipe(Effect.provide(PaymentsLive))` |
| `system.spawn(id, machine, { slots })` | Provide `PaymentsLive` around the program that calls `system.spawn(...)` |

Legacy slot handlers must still be supplied explicitly at every execution boundary that uses them, such as `Machine.spawn`, `system.spawn`, `simulate`, `createTestHarness`, and `Machine.replay`. Their dependencies are not inferred through the machine type, so migrate them to Effect services when possible.

## Request And Reply

Declare a reply schema on an event to make it valid for `actor.ask(...)`. Its transition returns `Machine.reply(nextState, value)`, so the reply type is inferred from the schema.
Expand Down Expand Up @@ -206,7 +214,7 @@ const program = Effect.gen(function* () {
}).pipe(Effect.provide(ActorSystemDefault), Effect.provide(PaymentsLive));
```

`ActorSystemService` also exposes `get(id)`, `stop(id)`, a snapshot `actors` map, an event `Stream`, and `subscribe(...)` for synchronous `ActorSpawned`, `ActorRestarted`, and `ActorStopped` notifications.
`ActorSystemService` also exposes `get(id)`, `stop(id)`, a snapshot `actors` map, an event `Stream`, and `subscribe(...)` for synchronous `ActorSpawned`, `ActorRestarted`, and `ActorStopped` notifications. Typed `spawn` returns a full `ActorRef<State, Event>`. Heterogeneous lookups, maps, child collections, and system events expose `ActorHandle`, which supports lifecycle and read-only observation but cannot accept an event without a type witness.

## Recovery, Durability, And Supervision

Expand Down Expand Up @@ -306,19 +314,24 @@ import { EntityMachine, toEntity } from "@humanlayer/effect-machine/cluster";

const CheckoutEntity = toEntity(checkoutMachine, { type: "Checkout" });

const CheckoutEntityLayer = EntityMachine.layer(CheckoutEntity, checkoutMachine, {
const CheckoutEntityLayer = EntityMachine.layer(CheckoutEntity, {
initializeState: (entityId) => CheckoutState.ReviewingCart({ cartId: entityId, totalCents: 0 }),
persistence: { strategy: "journal" },
});
```

`toEntity` requires a machine made with `Machine.make({ state, event, initial })`, then creates `Send`, `Ask`, `GetState`, and `WatchState` RPCs. `makeEntityActorRef(client, entityId)` wraps that protocol with a typed `send`, `ask`, `snapshot`, `watch`, and `waitFor` API.
`toEntity` requires a machine made with `Machine.make({ state, event, initial })`, then returns a machine-owned entity with canonical `Send`, `Ask`, `GetState`, and `WatchState` RPCs. `EntityMachine.layer(entity, options?)` uses the machine carried by that entity, preventing protocol/machine mismatches. `makeEntityActorRef(entity, client, entityId)` wraps the protocol with typed `send`, `ask`, `snapshot`, `watch`, and `waitFor`; remote Ask values are decoded with the event's reply schema and client transport errors remain in each operation's error channel.

Persistence is opt-in and resolves `PersistenceAdapter` from the entity layer's services:

- **Snapshot** is the default. It saves on each state change unless `snapshotSchedule` controls the cadence, then restores on reactivation.
- **Journal** appends every `Send` and `Ask` event inline, replays events after the latest snapshot, and saves a snapshot when the entity deactivates.

Adapter writes encode runtime state and events through the machine codecs, and load methods return
unknown stored records. Entity activation decodes the complete snapshot or journal
record—including payload, version, and timestamp—exactly once before hydration or replay;
malformed storage data defects activation rather than entering the machine.

Entity options also include `maxIdleTime`, `mailboxCapacity`, `defectRetryPolicy`, and `disableFatalDefects`, which are forwarded to `@effect/cluster`.

## License
Expand Down
18 changes: 9 additions & 9 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ interface ProcessEventResult<S> {
const unsub = system.subscribe((event) => console.log(event._tag, event.id));

// Sync snapshot of all registered actors
const actors: ReadonlyMap<string, ActorRef> = system.actors;
const actors: ReadonlyMap<string, ActorHandle> = system.actors;

// Async stream (late subscribers miss prior events)
system.events.pipe(Stream.take(10), Stream.runCollect);
Expand Down Expand Up @@ -251,19 +251,19 @@ import { toEntity, EntityMachine, PersistenceAdapter } from "@humanlayer/effect-

const OrderEntity = toEntity(orderMachine, { type: "Order" });

const OrderEntityLayer = EntityMachine.layer(OrderEntity, orderMachine, {
const OrderEntityLayer = EntityMachine.layer(OrderEntity, {
initializeState: (entityId) => OrderState.Pending({ orderId: entityId }),
persistence: { strategy: "journal" }, // or "snapshot" (default)
});
```

| Export | Purpose |
| --------------------------------------------- | ------------------------------------------------------------------- |
| `toEntity(machine, { type })` | Generate `Entity` definition with Send/Ask/GetState/WatchState RPCs |
| `EntityMachine.layer(entity, machine, opts?)` | Wire machine to cluster Entity layer |
| `makeEntityActorRef(client, id)` | Typed client wrapper (send/ask/snapshot/watch/waitFor) |
| `PersistenceAdapter` | Service tag for storage backend |
| `makeInMemoryPersistenceAdapter` | In-memory adapter for testing |
| Export | Purpose |
| ---------------------------------------- | ------------------------------------------------------------------- |
| `toEntity(machine, { type })` | Generate `Entity` definition with Send/Ask/GetState/WatchState RPCs |
| `EntityMachine.layer(entity, opts?)` | Wire the entity's machine to a truthful cluster Layer |
| `makeEntityActorRef(entity, client, id)` | Typed client wrapper; decodes Ask replies and preserves errors |
| `PersistenceAdapter` | Service tag for storage backend |
| `makeInMemoryPersistenceAdapter` | In-memory adapter for testing |

**Persistence strategies:**

Expand Down
Loading
Loading