Files
openwork/docs/alchemy-effect-testkit-migration.md

754 lines
39 KiB
Markdown

# Alchemy + Effect migration proposal for OpenWork testkit
Status: **Proposed**
Decision requested: approve an Effect-first refactor and one gated Alchemy
provider pilot; do not approve a wholesale testkit rewrite.
Research snapshot: **2026-08-21**, Alchemy `2.0.0-beta.72`.
## Executive summary
Alchemy, Effect, and `@openwork/testkit` solve different problems:
- **Effect** is a runtime and composition model for typed failures,
dependencies, concurrency, retries, interruption, and scoped acquisition and
release.
- **Alchemy** is a desired-state engine built on Effect. It adds resource
identity, dependency graphs, plan/apply/destroy, provider lifecycle methods,
and persisted state.
- **OpenWork testkit** is an application/E2E harness. It decides placement,
starts local or Daytona resources, drives OpenWork, records witness evidence,
and maps outcomes to Passed/Incomplete/Failed.
The current testkit is mostly **ephemeral orchestration**, not infrastructure
reconciliation. Effect is therefore a strong fit for its internals. Alchemy is
a fit only where a resource is external, durable enough to outlive the Node
process, discoverable after interruption, and safely reconcilable. The first
plausible Alchemy candidate is the desktop sandbox created by the Daytona suite
preparer; local child processes, ports, temporary profiles, mocks, users, and
organizations should remain scoped Effect resources or application fixtures.
The recommendation is:
1. Keep the public `@openwork/testkit` API, Vitest runner, witnesses, claims,
ambient evidence, and verdict rules unchanged.
2. Introduce Effect behind that API and migrate one lifecycle at a time.
3. Build one custom Alchemy provider for a suite-owned Daytona desktop sandbox
only after the Effect boundary is stable.
4. Compare reliability, cleanup, runtime, and maintenance cost against the
existing implementation before expanding.
5. Remove the Alchemy pilot if it does not materially improve interrupted-run
recovery or leak prevention. Do not keep it merely for architectural
consistency.
This is intentionally not a big-bang migration.
## Why consider a change
The authoring contract is already good. Specs acquire resources in dependency
order with `needs()` -> `server()` -> `app()`, use normal observable
assertions, and get ambient evidence without passing recorder handles
(`evals/README.md`). The migration should preserve that.
The maintenance pressure is below the facade:
- `server()` contains attached, Daytona, and local lifecycles in one function,
with hand-written acquisition rollback and disposal paths
(`evals/packages/testkit/src/server.ts`).
- Local Den provisioning coordinates MySQL, Redis, an ephemeral database,
ports, two child processes, readiness probes, organization fixtures, logs,
and teardown.
- Daytona provisioning coordinates sandbox creation or reuse, checkout,
install, display/browser readiness, public URLs, seeded identities, mocks,
and conditional deletion (`evals/packages/hosts/src/provision.ts`).
- Most resources expose `AsyncDisposable`, but every composition site still
decides how partial acquisition, retry, cancellation, and cleanup errors are
represented.
- Placement and command execution are concrete dependencies, which makes
failure-path testing harder than it needs to be.
Effect can centralize those mechanics without changing what a spec sees.
Alchemy may additionally help recover a remotely created sandbox after the
test process is interrupted, where in-process disposal cannot run, provided a
retry uses stable ownership identity or a janitor can rediscover the resource.
## What Alchemy would and would not replace
Alchemy's core loop is:
```text
Stack program
-> desired resource graph
-> diff against persisted state
-> dependency-ordered reconcile/delete
-> updated state
```
A custom provider implements required `reconcile` and `delete` behavior plus
optional hooks such as `read`, `diff`, and `list` as an Effect Layer. A stage
isolates physical resources and state. Alchemy's Vitest adapter can deploy and
destroy stacks, but it does not provide OpenWork's application-driving,
witness, evidence, or verdict semantics.
Alchemy would **not** replace:
- `evals/specs/**/*.test.ts` or Vitest;
- `@openwork/test-evidence`, `briefTest`, `prove.<claim>()`, screenshots, or PR
publication;
- `@openwork/labs` deterministic provider witnesses;
- `@openwork/behaviors`, CDP surfaces, or matchers;
- requirement checks and named skips from `needs()`;
- product code or production deployment;
- Daytona, Docker, MySQL, Redis, Electron, or their underlying APIs.
It would replace only selected imperative **external-resource lifecycle** code
after an OpenWork-specific provider exists. Alchemy has no built-in Daytona
provider, so adopting it does not remove the need to understand or test the
Daytona lifecycle.
There is also a known runner incompatibility in the reviewed versions. OpenWork
evals use Vitest `^3.2.4`. `alchemy@2.0.0-beta.72` requires Effect
`>=4.0.0-beta.105` and depends on the matching Effect 4 `@effect/vitest`, whose
peer range requires Vitest `>=4.1.0 <5`. The Alchemy Vitest adapter therefore
cannot be installed as the OpenWork test fixture without upgrading the eval
runner. This proposal does not upgrade Vitest: the pilot may use only Alchemy's
core programmatic lifecycle behind OpenWork's existing fixture. If pnpm cannot
isolate Alchemy's transitive Vitest 4 from the Vitest 3 runner, the Alchemy
pilot stops. The repository's existing Effect `4.0.0-beta.83` also does not
satisfy Alchemy's peer floor, so the isolated eval workspace would initially
carry a newer pinned Effect version.
## Decision axes
Effect adoption and Alchemy adoption are separate decisions.
| Concern | Effect | Alchemy | Current testkit |
| --- | --- | --- | --- |
| Primary abstraction | Typed program with services and scopes | Desired resource with provider lifecycle | Promise-returning test fixture/handle |
| Normal lifetime | Request, test, process, or explicit Scope | Across deploys through persisted state | One test or prepared suite |
| Cleanup | Scope finalizers and interruption | `destroy`/provider `delete` | `AsyncDisposable` plus manual rollback |
| Dependency graph | Service/Layer composition | Resource inputs/outputs | Call order and nested helpers |
| Recovery after process death | No; the process is gone | Possible through state or deterministic identity plus `read` | No automatic recovery |
| Drift/reconciliation | Application-defined | Provider-defined plan/apply | Recreate, reuse, or health-check |
| Best OpenWork fit | Processes, DB handles, mocks, desktop, fixtures | Owned remote sandboxes/snapshots if discoverable | Spec API, evidence, user journeys |
Alchemy is not an always-on controller. A killed CI job still needs a later
rerun or janitor to invoke reconciliation/deletion. Its normal plan is also
based on persisted state; provider code must observe live state when it
reconciles. It must not be presented as universal drift detection.
## Goals
1. Preserve the existing spec-authoring and evidence contracts.
2. Make acquisition, partial failure, interruption, and reverse-order release
explicit and testable.
3. Replace unstructured lifecycle errors with a small typed error taxonomy
while preserving actionable messages at the Vitest boundary.
4. Make local, attached-Den, and Daytona implementations swappable through
Layers rather than branches spread through resource constructors.
5. Determine with evidence whether Alchemy improves remote-resource recovery
enough to justify its state and provider cost.
6. Keep pnpm and Node.js 22+ as the supported eval toolchain. Bun must not
become required.
## Non-goals
- Rewriting all specs to `Effect.gen`.
- Replacing OpenWork's `test` fixture with `alchemy/Test/Vitest`.
- Treating users, org membership, OAuth grants, or seeded product data as IaC.
- Persisting test credentials in Alchemy props, outputs, or state.
- Managing an attached Den, an externally supplied reuse, or a borrowed
suite-owned sandbox from per-test code.
- Moving eval state to Cloudflare or adding production cloud dependencies.
- Adopting Alchemy for local ports, temporary directories, child processes, or
desktop profiles solely because they can be modeled as resources.
- Changing skip, evidence, or verdict semantics as part of the migration.
## Proposed architecture
```text
evals/specs + Vitest
|
v
@openwork/testkit public Promise/AsyncDisposable facade unchanged
|
v
internal Effect programs and Layers new
- EvalConfig / Place / TestCancellation
- CommandExecutor / Clock / PortAllocator
- LocalDatabase / LocalProcess / MockRuntime
- DenRuntime / DesktopRuntime / DaytonaClient
|
+--> existing @openwork/hosts, @openwork/labs,
| @openwork/behaviors, @openwork/cdp
|
`--> optional Alchemy stack adapter gated pilot
`--> OpenWork DaytonaDesktopSandbox provider
```
### Stable public boundary
Existing specs continue to look like this:
```ts
const requirements = needs({ placement: "local" });
await using den = await server({ place, ...requirements });
await using desktop = await app({ den, as: "admin", place });
```
Internally, the facade opens an Effect Scope, acquires the resource, and
returns the existing handle shape. Calling `Symbol.asyncDispose` closes that
Scope. This lets migration happen resource by resource without forcing Effect
types into every spec or coupling evidence recording to a new test adapter.
That disposal bridge covers normal lexical exit, not a timeout by itself. The
test fixture must retain Vitest's abort signal, interrupt the owning Effect
fiber when the signal fires, and close all registered scopes after the test
body. The current wrapper narrows the callback context to `place`, `evidence`,
and `skip`; carrying cancellation internally is part of Phase 1. Process death
still requires startup cleanup or an external janitor.
### Internal Effect services
The first internal services should be narrow capabilities, not a mirror of
every existing module:
| Service | Responsibility | Implementations |
| --- | --- | --- |
| `EvalConfig` | Validated run ID, placement, paths, timeouts | Environment-backed test Layer |
| `TestCancellation` | Interrupt fibers and close scopes on runner abort | Vitest signal, deterministic fake |
| `CommandExecutor` | Run local or sandbox commands with bounded output | Local, Daytona, fake |
| `PortAllocator` | Reserve/release ports | Local, deterministic fake |
| `DatabaseRuntime` | Acquire/drop an isolated database | Local MySQL, unavailable on Daytona |
| `MockRuntime` | Start/expose/stop a witness | Local, Daytona |
| `DenRuntime` | Acquire an attached, local, or Daytona Den | Layer selected once from `Place` |
| `DesktopRuntime` | Acquire and stop an isolated app surface | Local, Daytona |
Use scoped acquisition/release for resources and bounded Schedule-based retry
for readiness. Expected operational failures should be tagged errors such as
`RequirementUnavailable`, `ProvisionFailed`, `ReadinessTimedOut`, and
`CleanupFailed`. Unknown defects remain defects. At the public boundary:
- `RequirementUnavailable` maps to the existing named `SkipError` only when it
represents a declared requirement;
- provisioning/readiness failures fail the test;
- cleanup behavior remains compatible with each existing handle: several
disposers currently log and swallow cleanup errors, and this RFC does not
change their verdict semantics;
- error messages retain the command context and bounded log tail used today.
Dedicated lifecycle specs and pilot post-run audits must still fail when they
observe a leaked owned resource. That verifies the migration without silently
changing the generic Passed/Incomplete/Failed contract. A separate decision is
required if all cleanup errors should become test failures.
### What belongs in Alchemy
A resource is eligible only if all answers are yes:
1. Can it outlive the test Node process?
2. Can the provider find it again by stable identity without trusting stale
local memory?
3. Can `reconcile` safely run more than once after partial success?
4. Can `delete` safely run more than once and refuse foreign resources?
5. Is there value in planning/recovery beyond scoped acquire/release?
Initial classification:
| Resource | Owner | Rationale |
| --- | --- | --- |
| Local port or temporary directory | Effect Scope | Process-local and cheap; persisted state adds no recovery value |
| Local MySQL database | Effect Scope + startup cleanup | Random per-run name; scoped teardown handles live runners, while a TTL cleanup path is needed after process death |
| Den API/Web child processes | Effect Scope + startup cleanup | Process supervision, readiness, and interruption are runtime concerns, but detached children can survive a killed runner |
| MCP mock/fault proxy | Effect Scope | Deterministic witness with a short test lifetime |
| Electron profile/process | Effect Scope | Caller drives a live process; not desired cloud state |
| Den org/users/grants | Behavior fixture | Application data with authorization semantics, not infrastructure |
| Attached Den or externally supplied reuse | External reference | Caller-owned; testkit must never reconcile or delete it |
| Suite-prepared Daytona sandbox | Runner-owned resource borrowed by tests | Created and deleted in `evals/runner/prepare-stack.ts`; the normal Daytona path and first Alchemy pilot candidate |
| On-demand Daytona sandbox | Test-owned resource | Also eligible later, but ownership must remain distinct from external and suite-prepared reuse |
| Shared Daytona base snapshot | Later candidate | Durable desired artifact, but cache/version/ownership policy must be designed first |
Environment variables currently make a suite-prepared sandbox look similar to
reuse at the `server()`/`app()` layer. The migration must carry provenance
explicitly: `external`, `suite-owned`, or `test-owned`, plus the component that
is allowed to delete it. A borrowed per-test handle never deletes a
suite-owned sandbox; global setup owns its cleanup.
The first provider should expose one `OpenWork.DaytonaDesktopSandbox` resource
inside the suite preparer. Its desired props contain only non-secret identity
and configuration: requested git ref, snapshot name, retry-stable ownership ID,
and a resolved Daytona organization/account/target fingerprint. Returned
attributes contain the sandbox ID, ownership marker, and that fingerprint only.
Preview URLs are resolved by a scoped Effect service after deployment and are
not persisted in Alchemy state.
Den is deliberately not the first provider. Its provisioner obtains 24-hour
signed preview hostnames and bakes those exact hosts into OAuth/MCP identity.
Every fresh `daytona preview-url` call can mint a different hostname. Persisting
that URL risks storing a signed capability and later returning an expired
endpoint on an Alchemy no-op; regenerating it can break issuer identity. A Den
provider requires a separate endpoint-lifetime design.
Provider rules:
- `read` verifies the sandbox exists and carries the expected run ownership
when state is absent or interrupted;
- every operation validates the active Daytona context against the persisted
non-secret organization/account/target fingerprint and fails before create or
delete on context drift;
- a foreign same-name sandbox fails with a provider-specific ownership error;
it is not returned as `Unowned`, because Alchemy's `--adopt` mode can take
over an `Unowned` resource;
- `reconcile` revalidates context, fence, and ownership before its first remote
mutation, then observes/ensures and converges checkout and readiness;
- `delete` treats not-found as success and deletes only owned resources;
- `diff` updates a git ref in place only if the existing checkout/install path
proves convergence; snapshot or ownership changes replace the sandbox;
- replacement uses a generation-specific physical name so create-before-delete
cannot collide, or explicitly uses delete-first if Daytona cannot support
overlapping generations;
- the first provider omits `list`; account-wide `alchemy unsafe nuke` remains
unsupported, and TTL cleanup uses a separately ownership-filtered janitor;
- caller-provided `reuse` is represented as an external reference outside the
Stack, never as a managed Resource;
- `alchemy unsafe nuke` is not part of any OpenWork workflow.
If Daytona CLI/API behavior cannot support those rules, the Alchemy pilot is a
no-go. Wrapping the same create/delete script in a provider without reliable
`read` and ownership would add state without adding reconciliation.
Restored terminal Alchemy state is not live proof: unchanged state normally
plans a no-op, so neither `read` nor `reconcile` is guaranteed to run. Before a
restored suite slot is published, the adapter performs a read-only preflight of
Daytona context, fence, and ownership; only then may it invoke a supported
forced reconcile. The provider repeats those checks immediately before its
first mutation to avoid a check/use race. A final live preflight verifies
existence, running status, requested checkout, and readiness. A stopped owned
sandbox is restarted, a missing owned sandbox is recreated, and a
foreign-owned sandbox fails with zero mutations. If Alchemy's core API cannot
force this path without the incompatible Vitest adapter, same-state recovery
is a no-go.
### State, identity, and cleanup
The pilot uses a retry-stable recovery ID supplied by the eval orchestrator,
not `Date.now()`. In CI it should derive from the workflow run, job, explicit
matrix/shard identity, and worker slot while intentionally excluding the retry
attempt. A rerun of one shard retains its key; concurrent shards must differ.
Local runs may generate an invocation ID when cross-run recovery is not
requested. A unique stage and resource owner derive from that value, for example
`eval-<recovery>-w<worker>`.
Alchemy beta.72's built-in `localState()` does not accept a path and captures
`process.cwd()` at module load, so it cannot directly satisfy run-scoped state
under the eval result area. Phase 0 must choose one of two isolated mechanisms:
- a conformant custom State Layer rooted under that run's result directory; or
- an Alchemy helper subprocess whose cwd is the run result directory before its
first Alchemy import.
Do not mutate cwd inside the shared Vitest process. Preserve the isolated state
as a diagnostic artifact on failure and remove it after verified destroy on
success. Local state alone does not recover across a new CI machine.
A custom State Layer must prove the same safety properties the pilot depends
on: atomic replacement, recovery after interrupted write/delete, defensive
handling of missing directories/records, and concurrent disjoint stages. The
helper subprocess is preferred if it can reuse Alchemy's built-in local store;
path convenience alone does not justify maintaining a state backend.
Cross-machine recovery is a separate gate and does not inherently require
remote state. There are three mechanisms to test independently:
- preserve the run-scoped state artifact and resume with the same recovery ID;
- start with empty state but the same deterministic identity and let provider
`read` rediscover the owned sandbox;
- run a Daytona ownership/TTL janitor for jobs that are never retried.
A remote state store is optional. It is useful only if retaining Alchemy's full
intermediate lifecycle is worth the additional locking, access-control, and
retention surface, and only after parallel workers are proven safe.
Retries that intentionally share a recovery ID need a single-writer lease and
fencing token scoped by Daytona context, recovery ID, shard, and worker. Every
reconcile, delete, janitor action, and borrowed-host mutation validates the
current token. The token is carried into `CommandExecutor`/the Daytona Host;
lease loss interrupts the old test worker and rejects its next operation. A
takeover must use a new sandbox generation unless prior-attempt termination is
positively confirmed, so an old in-flight command cannot mutate the new
attempt's sandbox. The lease is heartbeated by the active attempt; a janitor may
delete only after both lease expiry and ownership checks. If this cannot be
enforced with the Daytona API and runner, shared-identity retry is a no-go.
The janitor is required before claiming that the migration prevents leaks.
Alchemy only cleans up when a process runs `destroy`; it cannot finalise a job
after `SIGKILL` by itself.
No token, cookie, password, API key, signed preview URL, or raw environment map
may enter Alchemy state. Secrets and transient endpoints stay in Effect
services and are resolved inside or immediately after provider operations.
Remote state is out of scope until its encryption, access control, retention,
and locking are reviewed.
Effect Scope also cannot clean detached local children or databases after
`SIGKILL`. Phase 2 may only claim in-process cleanup improvements unless it adds
an ownership-aware startup/TTL cleanup path and proves that path against a
forcibly terminated runner.
## Migration plan
### Phase 0: compatibility and boundary spike (2-3 engineer-days)
No production lifecycle moves in this phase.
1. Pin exact `alchemy`, `effect`, and `@effect/platform-node` versions in the
isolated `evals/` workspace; do not depend on `@next` and do not add
`@effect/vitest` to OpenWork's runner.
2. Use an Effect version satisfying Alchemy's reviewed
`>=4.0.0-beta.105` peer range. Verify pnpm + Node.js 22 execution,
TypeScript 5.9, Vitest 3.2, ESM exports, and the existing eval tsconfig. Bun
remains optional.
3. Prove an internal Effect Scope can return an existing `AsyncDisposable`
facade, that ambient evidence survives Effect fibers, and that a Vitest abort
signal interrupts the fiber and closes the Scope.
4. Exercise a fake lifecycle provider from a normal `@openwork/testkit`
app-less spec, using Alchemy core rather than `alchemy/Test/Vitest`. Cover
create, update, replace, repeated reconcile, partial failure, delete, and
not-found delete.
5. Prove either the custom State Layer or pre-import helper-process approach can
isolate state under one eval result directory; the built-in `localState()`
is not configurable enough in the reviewed release. If custom, test atomic
writes, interrupted write/delete recovery, missing records/directories, and
concurrent disjoint stages.
6. Prove the Alchemy core API can force reconciliation of unchanged terminal
state without adopting `alchemy/Test/Vitest`.
7. Measure install-size and cold-import impact of Alchemy's transitive graph.
**Gate:** stop Alchemy work if it requires replacing the OpenWork test fixture,
requires upgrading Vitest, leaks a second Vitest into runner resolution,
requires Bun, or cannot run through the existing pnpm/Vitest lane. Effect-only
work may continue.
### Phase 1: Effect foundation and one scoped resource (3-5 engineer-days)
1. Add the internal Scope-to-`AsyncDisposable` bridge and typed lifecycle
errors.
2. Carry Vitest cancellation into the fixture and interrupt all registered
Effect fibers/scopes while the process remains alive.
3. Add fake `CommandExecutor`, `Clock`, and `PortAllocator` Layers.
4. Migrate `mcpMock()` acquisition/readiness/stop behind the existing API.
5. Fault-test failure before acquisition, during readiness, in the test body,
on timeout/cancellation, and during finalization.
**Gate:** existing local specs are source-compatible; evidence output and skip
classification are unchanged; timeout interrupts the fiber; no process or port
remains after any in-process injected failure.
### Phase 2: local Den orchestration (5-8 engineer-days)
Migrate the local `server()` branch to a composed Effect program:
```text
requirements
-> mocks + database + ports
-> db push
-> den-api + optional den-web
-> health/auth readiness
-> org/member fixtures
-> Den handle
```
Keep organization provisioning as an application fixture in testkit; moving
the current private provisioning/deletion functions to `@openwork/behaviors`
is not part of this phase. Keep attached Den as a non-owning Layer. Preserve
existing logs and endpoint shapes.
**Gate:** targeted specs pass in both the normal path and injected failures;
database, services, mocks, and organizations are released in reverse order;
cleanup logging and verdict behavior match the existing handles. Dedicated
lifecycle specs assert that every finalizer is attempted. Any claim about
cleanup after process death additionally requires a local stale-resource
janitor and forced-termination proof.
### Phase 3: one Alchemy Daytona pilot (5-8 engineer-days)
1. Add the `OpenWork.DaytonaDesktopSandbox` resource/provider in a pilot-only
internal package or module.
2. Use an in-memory state store for provider lifecycle tests and run-scoped
local state for one real E2E journey.
3. Integrate it at `evals/runner/prepare-stack.ts`, where suite ownership and
global cleanup actually live. Per-test consumers continue borrowing the
prepared desktop sandbox and cannot delete it.
4. Add an explicit force-suite-preparation mode for the comparison. A single
explicitly selected test currently bypasses suite preparation, so each arm
must assert that it received a suite-owned slot and record whether the old or
Alchemy preparer created it.
5. Put the provider behind an explicit pilot selection; default Daytona tests
continue using the current suite provisioner during comparison.
6. Run the same selected journey through old and pilot paths against the same
git ref and resolved Daytona context fingerprint.
7. Coordinate the current Den branch and Alchemy desktop branch so an outer
failure waits for both acquisitions to settle, then destroys every owned
resource before returning. Inject a Den failure while desktop creation is in
flight and require the ownership audit to find nothing.
8. Simulate interruption after remote create, then prove both same-state resume
through forced live reconcile and empty-state `read` recovery with the same
recovery ID. Seed terminal state against stopped, missing, wrong-checkout,
unready, and foreign-owned sandboxes; the foreign-owned case must record zero
remote mutations. Prove the TTL janitor handles a run that is never resumed
and does not delete an active newer retry sharing that recovery ID.
9. Keep Den provisioning on the current path; signed endpoint identity is a
separate design gate.
10. Run overlapping retry attempts: lease loss must interrupt the old worker,
reject its next host mutation, and prevent it from changing the new
generation.
**Gate:** do not make Alchemy the default until the go/no-go criteria below are
met. Do not migrate desktop and Den sandboxes simultaneously in the first
pilot.
### Phase 4: decide, then expand or remove (2 engineer-days for decision)
Possible outcomes:
- **Effect only:** remove Alchemy dependencies and retain the scoped runtime
refactor.
- **Selective Alchemy:** make the proven desktop sandbox provider the Daytona
suite-preparation default; consider a separately reviewed snapshot provider.
- **Stop/revert:** keep the original lifecycle behind the stable facade and
remove the pilot modules/state.
A full conversion of all testkit resources is not an expected outcome and
would require a new RFC.
## Go/no-go criteria
The pilot is a **go** only when all are true:
### Correctness
- Existing selected specs require no source changes.
- Every existing claim still has observable test evidence.
- Comparison evidence proves suite preparation ran and identifies the selected
old or Alchemy preparer; an on-demand fallback cannot satisfy the pilot.
- External reuses and per-test borrows of suite resources are never deleted;
suite global cleanup deletes the suite-owned sandbox exactly once.
- Owned resources are deleted after pass, assertion failure, readiness
failure, and runner cancellation while the process remains alive.
- A post-create interruption has a tested recovery or TTL janitor path.
- Restored terminal state is forced through live reconciliation/preflight;
stopped, missing, stale-checkout, unready, and foreign-owned resources cannot
pass as a no-op.
- Read-only ownership/context/fence validation runs before forced reconcile and
again before its first mutation; the foreign-owned case makes zero mutations.
### Reliability
- Repeated reconcile and delete are idempotent in provider lifecycle specs.
- A 20-run comparison leaves zero local processes/databases and zero owned
Daytona sandboxes in both paths.
- Parallel workers use disjoint names and state; no state corruption or
cross-run deletion is observed.
- Concurrent matrix shards receive distinct recovery identities, while a rerun
of the same shard retains its identity.
- Retrying under a different Daytona organization/account/target fails before
reconcile or delete and cannot leak or duplicate a resource silently.
- Overlapping attempts with the same recovery ID are fenced to one writer, and
a TTL janitor cannot delete the active newer attempt's sandbox.
- Lease loss interrupts the old worker and blocks borrowed-host mutations; a
takeover does not hand the same sandbox generation to both attempts.
- Injected Den failure during desktop creation waits for the desktop branch and
leaves no suite-owned sandbox.
- A dedicated post-run ownership audit fails the pilot comparison if any
sandbox remains. Generic disposer errors retain current log-and-continue
behavior until a separate verdict-policy decision.
- Same-state resume and fresh-state `read` recovery both use a documented,
retry-stable identity rather than a new timestamp.
### Performance
- Local testkit cold start regresses by no more than 10% at p50.
- The selected Daytona journey regresses by no more than 10% at p50 and 20% at
p95, unless the added time demonstrably replaces flaky retry work.
- Dependency install and cache impact is measured and accepted explicitly.
### Maintainability
- The custom provider plus adapter is smaller and easier to fault-test than the
imperative suite lifecycle it replaces.
- Error output remains at least as actionable as current timed-step and log-tail
messages.
- At least two maintainers can modify the provider without relying on Alchemy
internals or undocumented APIs.
Failing any ownership, endpoint-security, cleanup, state-concurrency, or
fixture-compatibility criterion is a no-go, not a follow-up to absorb during
rollout.
## Tradeoffs
### Benefits of Effect-first
- One model for acquisition, interruption, timeout, retry, and finalization.
- Dependencies become replaceable Layers, enabling deterministic failure-path
tests without shelling out to real infrastructure.
- Typed operational errors make skip/fail classification deliberate.
- Local and Daytona implementations can share orchestration while differing at
capability boundaries.
- Incremental adoption is possible behind Promise and `AsyncDisposable` APIs.
- OpenWork already has limited Effect 4 usage in `packages/codemode` and
`ee/apps/den-api`, so the concepts are not entirely new to the repository.
### Costs of Effect-first
- Effect, Layer, Scope, Cause, and interruption add a learning curve.
- Poorly chosen services can turn straightforward code into indirection.
- Effect failure channels plus Vitest errors plus evidence verdicts create three
taxonomies that must have one explicit boundary.
- Stack traces and debugging can worsen if every helper is wrapped rather than
only lifecycle boundaries.
- Effect 4 is currently beta in this repository, so version pinning and upgrade
work remain real.
- Alchemy's peer floor is newer than the Effect beta currently pinned elsewhere
in OpenWork, creating temporary version skew even though evals are isolated.
- Scoped finalizers improve timeout and in-process failure handling but do not
clean detached processes or databases after a killed runner; a janitor is a
separate mechanism.
### Additional benefits of selective Alchemy
- Stable resource identity and a dependency graph replace implicit remote
creation order.
- Plan/apply/destroy makes ownership and replacement decisions inspectable.
- Persisted intermediate lifecycle state can support recovery after partial
remote provisioning.
- Provider lifecycle tests exercise create/update/replace/delete consistently.
- Stages give parallel runs an explicit isolation key.
### Additional costs and risks of Alchemy
- Alchemy's README labels the project alpha and its current v2 package is beta;
breaking changes should be expected.
- The reviewed Alchemy/Effect Vitest adapter requires Vitest 4.1+, while
OpenWork evals use Vitest 3.2; using the adapter would force an unrelated
runner migration.
- It brings a broad IaC dependency graph into an otherwise narrow eval
workspace.
- There is no built-in Daytona provider. OpenWork must own `read`, `diff`,
`reconcile`, `delete`, auth, ownership, and upgrade compatibility.
- Most testkit resources are short-lived runtime resources; modeling them in
persisted desired state would be conceptual and operational overhead.
- State introduces retention, locking, concurrency, corruption, and secret
handling concerns that do not exist for in-memory handles.
- The reviewed local state backend has no configurable path; isolation requires
a helper process or a correctly crash-safe custom State Layer.
- Retry reuse needs leasing and fencing across both IaC lifecycle calls and the
test processes borrowing a sandbox, which may outweigh the provider benefit.
- Daytona Den preview hosts are expiring signed values and OAuth/MCP identity;
persisting them as attributes or refreshing them on a no-op is unsafe without
a dedicated endpoint-lifetime design.
- A custom provider can become the same homegrown provisioner behind a more
complex interface. Alchemy is valuable only if the provider truly converges.
- Alchemy's documented test posture favors real cloud resources. OpenWork must
retain deterministic witnesses because identity, failure, and policy claims
require controlled observations.
- Alchemy cannot clean up after a dead runner without a subsequent command or
external janitor.
- Adopting Alchemy's Vitest adapter directly could conflict with OpenWork's
evidence fixture and skip mapping; this proposal avoids that coupling.
## Alternatives considered
| Option | Advantages | Disadvantages | Decision |
| --- | --- | --- | --- |
| Keep current implementation | No migration or dependency cost; familiar debugging | Manual rollback and concrete dependencies remain; no interrupted remote recovery | Valid fallback |
| Effect-only internals | Best fit for ephemeral resources; incremental; deterministic failure testing | No persisted desired state or post-process recovery | **Recommended baseline** |
| Effect + selective Alchemy | Adds recovery/plan semantics where remote resources justify them | Provider/state/maturity cost | **Recommended gated pilot** |
| Full Alchemy rewrite | One resource vocabulary everywhere | Poor fit for app fixtures and local processes; highest churn; risks evidence contract | Reject |
| Terraform/Pulumi/CDK for evals | Mature IaC/state ecosystems | Separate language/runtime or heavier bridge; still does not replace test orchestration | Reject for this scope |
| Kubernetes/Docker-only test environments | Strong declarative runtime where supported | Does not cover Electron/Daytona/attached Den and changes current lanes | Out of scope |
## Rollback strategy
- The public testkit facade remains the rollback seam throughout.
- Each migrated resource keeps one implementation selected at composition
time; specs do not branch on implementation.
- Pilot resource names and state are isolated from current Daytona names.
- No production or shared long-lived state is converted.
- Reverting means selecting the existing provisioner, destroying pilot-owned
resources, deleting pilot state, and removing Alchemy dependencies.
- Effect phases are independently reversible; no spec should require Effect
types to compile.
## Open questions before Phase 3
1. Does the Daytona API expose stable metadata/tags, or only CLI names? This
determines whether ownership-safe `read` is possible.
2. What exact workflow/job/matrix-shard/worker fields form a retry-stable and
shard-unique recovery ID across local, GitHub, and Daytona lanes?
3. Which stable Daytona organization/account/target identifiers can be resolved
without persisting credentials?
4. Where does the single-writer lease/fencing record live, and how is its
heartbeat distinguished from a cancelled attempt? Can takeover isolate the
new sandbox generation from old in-flight commands?
5. What is the concurrency/locking contract of the selected state store under
parallel Vitest workers and retried CI jobs?
6. Can Alchemy's programmatic deploy/destroy path be used without installing a
second Vitest fixture around the test, despite its transitive Effect 4
`@effect/vitest` dependency?
7. Which exact Alchemy/Effect versions are mutually compatible with the eval
workspace at implementation time?
8. Where should the ownership/TTL janitor run so it still executes when a CI job
is cancelled and never retried?
9. Can a future Den provider avoid persisting signed preview hosts while
preserving the exact issuer identity baked into the sandbox?
Whether generic cleanup errors should become Failed or Incomplete is a
separate evidence-policy question. The current implementation often logs and
swallows them; this migration preserves that behavior rather than silently
choosing a new verdict.
## Requested decision
Approve Phases 0-2 as an **Effect-first internal migration** and approve Phase
3 only as a time-boxed Alchemy experiment. Do not approve Alchemy as the new
testkit foundation until the provider meets every go/no-go criterion.
The likely end state is a hybrid:
```text
Vitest + OpenWork evidence contract
+ Effect for test resource orchestration
+ Alchemy for a small set of owned, recoverable remote resources
```
That uses each tool at the layer it is designed for and avoids replacing a
working E2E harness with an IaC engine that does not solve its core evidence
problem.
## References
OpenWork:
- `evals/README.md`
- `evals/packages/testkit/src/fixture.ts`
- `evals/packages/testkit/src/server.ts`
- `evals/packages/testkit/src/app.ts`
- `evals/packages/testkit/src/place.ts`
- `evals/packages/hosts/src/provision.ts`
- `evals/packages/test-evidence/src/ambient.ts`
- `evals/runner/prepare-stack.ts`
- `evals/runner/stack-env.ts`
- `evals/runner/stack-suite.ts`
- `.devcontainer/test-server-on-daytona.sh`
Alchemy (reviewed 2026-08-21):
- [Repository and alpha notice](https://github.com/alchemy-run/alchemy)
- [What is Alchemy?](https://alchemy.run/what-is-alchemy)
- [Custom providers](https://alchemy.run/infrastructure-as-code/custom-provider)
- [Resource lifecycle](https://alchemy.run/infrastructure-as-code/resource-lifecycle)
- [State store](https://alchemy.run/state-store)
- [Test harness](https://alchemy.run/testing/test-harness)
- [Local development](https://alchemy.run/environments/local-development)
- [Getting started and Node.js support](https://alchemy.run/getting-started)