Skip to content

Move reconciliation to the durable Yieldstar runtime - #45

Merged
djgrant merged 36 commits into
mainfrom
yieldstar-durable-cutover
Jul 28, 2026
Merged

Move reconciliation to the durable Yieldstar runtime#45
djgrant merged 36 commits into
mainfrom
yieldstar-durable-cutover

Conversation

@djgrant

@djgrantdjgrant commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

What this is

This PR moves deploys onto a durable workflow runtime (Yieldstar). Before, a deploy ran in memory: if the process crashed halfway, all progress was lost and state could be left inconsistent. Now every step of a deploy is saved to a local SQLite database as it completes. A crashed or interrupted deploy can be resumed and will continue from the last saved step instead of starting over.

How it works

  • A deploy or destroy runs as a workflow. Each step (a provider call, a state write, an emitted event) is checkpointed. Replaying a workflow returns the saved results instead of redoing the work, so nothing runs twice.
  • Only one execution may change a deployment at a time. An execution takes a "hold" on the deployment and keeps it until it finishes. If an execution dies and will never be resumed, an operator can clear the hold with takeOverDeploymentHold.
  • Waiting is durable too. When a provider says "not ready, try again later", the workflow parks and resumes after the delay — it can even survive a restart in between.
  • Resource state now lives in workflow stores inside .notation/workflows.db. On first run, an existing legacy JSON state file is imported and then renamed to *.migrated.

Changes by package

PackageChange
@notation/reconcilerThe reconcile algorithm (decide → read remote → create/update/delete → persist) is one shared library. A new durable/ module (exported as @notation/reconciler/durable) runs it as Yieldstar workflows. The old in-process Reconciler class is deleted, along with its locking and conflict-retry code.
@notation/coreNew NodeDurableRuntime: owns the SQLite database, the workflow event loop, and the legacy state import. The deploy/destroy/plan workflows run through it. The refresh workflow is gone (destroy now sweeps orphans itself).
@notation/clideploy and destroy gain --execution-id (resume a run) and --json (stream events as NDJSON).
@notation/stateThe lease API is removed — only the deleted driver used it.
@notation/dashboardReads state through a read-only interface.
docs, examples/reconcilerRewritten for the durable runtime, plus a runnable example.

Notes for review

djgrant added 20 commits July 27, 2026 17:33
Introduce the durable reconciliation modules under durable/, with the
Yieldstar runtime confined to a single adapter seam. Adds
@notation/utils and moves error-matcher classification next to
ErrorMatcher in @notation/resource as findErrorMatcher. The legacy
reconciler remains in place until the runtime cutover.
The durable planner and operations now treat an undefined read as
absence, and a tagged ResourceNotReadyError as a wait: durable
operations retry it, while planning reports an indeterminate node
carrying the message.
Run deploy, plan, and destroy through NodeDurableRuntime, unify CLI
error handling, and remove the legacy reconciler, operation workflows,
and state leasing that the old runtime required.
The durable path reimplemented create, update, read and delete inline
rather than using the StepRunner seam those operations were built for,
leaving two copies of the same provider protocol to keep in step.
Persistence becomes a driver-supplied step. In process that is a
compare-and-set against the revision read before the operation; in a
workflow it is a store write stamped with the step that made it, so the
applied-step ledger and the state change commit together and a replay
returns the recorded result instead of retrying a stale compare-and-set.
Event emission becomes a step for the same reason.
Scoped step keys let one operation body run at several call sites, which
replaces the hand-threaded key prefixes and the parameter bags that
carried them.
With writes owned by the workflow, DurableStateBackend is read-only for
the planner and reporting, so the revision arithmetic and the creation
token that guarded create-if-absent both go.
Also collapses the three copies of the reconciler event types, delegates
Reconciler.plan to createPlan, and exports the durable subsystem, which
no consumer could previously import.
A delay whose deadline has already passed is continued inline rather than
suspended, so a one millisecond retry could resume inside the execution
that scheduled it once the heap write outlived it. The tests then saw two
provider calls where they expected the workflow to have parked after one.
Names the interval and puts it comfortably beyond a SQLite write. The
assertions are unchanged: the provider is still called once before the
workflow parks, and once more after it resumes.
The hold was released in a finally, so every failure mode released it —
except a crash, where the runner abandons the workflow generator without
calling .throw() and the finally never runs. That split matters because
yieldstar caches steps per execution: a resumed execution replays `take`
from the cache without re-acquiring, and replays `release` as a cached
no-op, so a resumption that followed the release path proceeds holding
nothing while it mutates the deployment.
Unify every failure mode on the crash path's semantics. The hold is
released in exactly one place, after the body completes.
An execution that will never resume now holds its deployment forever,
which is why takeOverDeploymentHold lands with it: force-clearing the
coordination store through storeClient.updateStore is unconditional and
undocumented, so without a named, compare-holder takeover this commit
would turn any transient provider error into a wedge with no runbook.
The test cannot be written against a failing step: a cached StepError is
rethrown before the step's function runs again, so the resumption would
never reach uncached work. It fails in plain generator code between two
resources instead, and asserts the holder observed from inside the second
resource's create — null before this change, the execution id after it.
createResourceWorkflow, readResourceWorkflow, updateResourceWorkflow and
deleteResourceWorkflow had no consumers, and each was typed `unknown`, so
nothing could have called one without casting anyway. They were also the
only runtime yieldstar import reachable from the base entry point, which
made the durable runtime look like a dependency of the in-process driver.
YieldstarApi goes with them: `export type`, so never a runtime import,
and equally unused.
operation.workflows.test.ts exercises the operations directly despite its
name, so it keeps its coverage. After this, the only "yieldstar" imports
under src are in durable/.
Both are persisted by create and update, but the schema was loose, so
neither appeared in StoredResourceState and nothing checked that a
durable record carried the same fields as an in-process one.
The store stays loose. PersistedResourceState deliberately carries an
index signature, and v.object strips unknown entries on parse, so
tightening the object would silently drop forward-compatible fields at
the store boundary. The schema is a floor on the record, not a
description of it.
Validation runs on write rather than read, so records persisted before
this commit are still readable; the next write to one supplies both
fields. The test fixture that seeds a record directly now supplies them.
Operations resolved two things for themselves that the driver had already
decided against: the desired params, through resource.getParams(), and
the persisted record, through a `state: Pick<State, "get">` seam that
existed for a single read in operation.read.ts.
Both are now passed in. getParams is resolved once per scheduled
reconciliation — conflict recovery included, so recovery compares the
remote against the same params the failed attempt decided on — and
persistedOutput replaces the state seam, which also deletes the fake
`state: { get: async () => stateNode }` adapter the durable driver had to
construct.
This is not a live correctness fix. getParams spreads config over
deriveParams(id, config, deps) and never reads this.output, so repeated
calls do not diverge today. The win is that deriveParams is user code:
a non-deterministic one would let the emitted decision diff and the
persisted params record disagree with nothing able to reconcile them.
Deletion keeps resolving params lazily, on the recovery path only, since
that is the sole consumer there and an orphan hydrated from state should
not be asked to derive params just to be deleted.
Three construction sites changed: reconciler.ts, durable/operations.ts
and planner.ts, which already had both the state node and the resolved
plan params in hand and was resolving params a second time inside the
drift read.
Also adds the typecheck script the package never had, which is how the
call sites still passing the long-deleted expectedRev field surfaced.
Making the package typecheck exposed two more things: the durable store
schema now requires groupId and groupType that PersistedResourceState did
not declare (declared here, since StateNode only carries them through its
index signature), and a batch of test fixtures that were never type
checked. The fixture casts are annotated: a resource declared without API
types cannot express a named schema key at all.
Scoping was a durable-only wrapper that rebuilt a step object. It is now
part of the StepRunner contract: scope(prefix) is the identity in process,
because createStepRunner ignores keys entirely, and the prefixing wrapper
in a workflow.
scopeStore goes with it. A store outlives the scope that opened it, so
prefixing its caller-supplied keys with whichever scope happened to open
the handle was arbitrary; the three call sites now qualify their own keys,
as state:persist:<id>, state:delete:<id> and the notation:coordination:*
keys, which were already fully qualified.
The keyless run/delay overloads are gone. Yieldstar derives a key from the
call site for those, and through a scoping wrapper that call site is the
wrapper's, not the caller's — so any two keyless steps reached through one
scope would have collided. Nothing called them; removing them makes that
structural rather than a convention. Keys cost nothing in process, where
they are ignored.
BLAST RADIUS. Store keys change, so an execution that was in flight
before this commit and resumes after it must be drained or abandoned
first. It will not refuse cleanly: plain run steps have no idempotency
ledger, so a resumed execution takes a cache miss and RE-EXECUTES the
provider mutations behind those keys. It then fails safe at the first
conditional state write, because updateFrom/deleteFrom carry a snapshot
that is now stale and abort with RevConflict — but the remote calls will
already have happened. Registered store waiters are keyed by stepKey too,
so a `take` suspended on the deployment hold re-registers under its new
key when it next wakes.
The key shapes now in use are documented at the durable entry point,
where they can be read as the contract they are.
The durable driver threaded dryRun into the drift read's params, so
readResourceOperation returned {} without calling the provider. decideAction
then diffed toComparable({}) against the desired params, found every param
"missing" from the remote, and upgraded a noop into a drift-update carrying
a fabricated diff. Every dry-run test set driftDetection: false, so nothing
caught it.
The in-process driver never had this: #readForDrift deliberately omits
dryRun, and createPlan does the same. The contract is that a dry run
suppresses mutations, not reads — a dry run that cannot read the remote
cannot report drift, which is most of what it is for. So this matches the
in-process behaviour rather than guarding the read.
The new test pins both halves: the provider read happens during the dry
run, and the decision stays noop with no drift event.
Conflict recovery re-reads the remote, re-decides against it, and can
land on drift-update — but it emitted only deploy.decision, so the
drift.detected event that the same decision produces on a first pass was
missing. Recovery after a conflict is drift adoption, so it is owed.
This is a visible change for anything diffing event sequences: a
recovering deployment now emits reconciler.drift.detected before its
deploy.decision, where before it emitted only the decision. It lands on
its own so that it is not buried in the driver unification that follows.
takeOverDeploymentHold is the only exit from a hold left by an execution
that will never resume, so it ships tested: it takes when the named
holder still holds, and refuses — reporting the actual holder, leaving
the record alone — when the hold has moved on.
The key map gains the destroy-path orphan sweep, which it omitted, and a
note that resource ids are spliced in unescaped. That ambiguity predates
the map and is recorded rather than fixed, since changing key composition
invalidates in-flight executions.
The per-resource algorithm existed twice — #deployResourceOnce plus
#recoverDeployResource in process, reconcileResource in the workflow —
and the copies had already drifted apart. There is now one generator,
src/reconcile.ts, that owns hydration, the decision, the drift read,
event emission, dispatch, recovery adoption and deletion recovery. Both
drivers run it.
What stays with a driver is what genuinely differs: dependency-level
scheduling and concurrency (Promise.all under a mutation lease against a
sequential yield* under a deployment hold), the conflict retry policy,
how a state session is opened, and how a step is run.
Two seams carry that split. The driver passes openSession(resource), a
factory rather than an open session, because recovery re-reads and
persists against what the winning writer left; the first pass opens once
and a recovering pass opens again. And ResourceStateSession is a
discriminated union, so `remove` exists only alongside a `node` — the
precondition deleteResource expressed as an early return and destroy
expressed as skip-when-absent, now expressed in the type. Keeping the
writes bound to the read that produced them is what stops a node being
paired with a precondition from a different read.
Recovery is a recoverFrom parameter rather than a second near-copy of
the algorithm. The behaviours that were easy to lose in that collapse
are each still pinned by an unchanged test: a recovering noop persists
the adopted record with lastOperation "drift" at the re-read revision
(guarded by dryRun), a resource that cannot be read cannot be recovered,
and the drift read carries no dryRun.
Orphan sweeping is aligned too. In-process destroy now sweeps, which
also means destroyApp no longer runs a refresh pass first — but the
Reconciler it builds must be given the registry, or the sweep silently
falls back to the types of the resources still declared and skips, with
a warning, exactly the orphans a destroy exists to remove. registry is
optional, so nothing else would have caught that. Note the ordering
flip: in-process destroy used to sweep before deleting declared
resources and now sweeps after, matching the durable driver and deploy's
act-then-sweep shape. Public refresh is unchanged, for standalone
cleanup.
The #emit/#emitStep split collapses with it: OperationLifecycleEvent is
a subset of ReconcilerEvent, so one scoped emit seam serves decisions,
drift and operation lifecycle alike.
reconciler.ts 511 -> 361 lines, durable/operations.ts 252 -> 197, with
248 shared between them.
Combines the yieldstar-node-runtime-cutover branch with the last eleven
foundation commits it predated. The cutover's core, CLI, docs and state
changes land as written; inside the reconciler package the foundation's
architecture wins: the shared operations library and reconcile algorithm
stay, and the cutover contributes the deletions of the in-process
Reconciler driver and its tests.
Resolutions beyond file picks:
- Core and the example import the durable driver from the
@notation/reconciler/durable subpath rather than a root re-export.
- The legacy-state migration writes through the store client's
getOrCreateStore, since DurableStateBackend is now read-only.
- The dashboard declares the read-only state it actually needs.
The hand-rolled standard-schema validator predated the convergence on
Valibot for store schemas; this brings the one store defined outside the
reconciler in line with the others, and lets the schema type the store
state instead of a cast.
# Conflicts:
#	pnpm-lock.yaml
#	pnpm-workspace.yaml
The two-driver seam (reconcile.ts with OpenStateSession, EmitFromStep and
the recoverFrom recovery paths) outlived its second driver: the in-process
reconciler was deleted when the runtime cut over, leaving the durable
driver as the sole caller and the recovery machinery unreachable. Inline
the algorithm into durable/operations.ts, preserving every step key, and
drop the dead recovery code. The RevConflict tests still expect conflicts
to propagate, which is what the durable driver always did.
Also extract withRuntime so deployApp, destroyApp and planApp share one
runtime acquire/close lifecycle instead of three copies.
The runtime's event pump routed completion through a shared promise, a
completed flag and three callbacks, with a dead branch for replay errors
on executions the pump never feeds it. Replace it with driveToCompletion:
run the trigger event, then loop the queue until the runner returns a
result, deferring foreign tasks exactly as before. Errors now propagate
by throwing instead of by rejecting a captured resolver.
Also extract runDurableWorkflow so deployApp and destroyApp share the
one-command-one-workflow wrapper instead of each building a router, and
declare DurableStepRunner as an interface extending StepRunner rather
than restating its members alongside a conditional-type assertion.
The legacy-state migration verified every shared record twice: once from
the durable listing, then again per legacy node through a fresh store read
whose mismatch branch could no longer fire. Import from the ids the first
pass already verified instead, dropping a store round-trip per record.
DurableStateBackend.has had no callers, so remove it.
- coordination.ts -> deployment-hold.ts; acquire/releaseDeploymentCoordination
-> acquire/releaseDeploymentHold. One concept, one name: the identifiers now
all say hold, and the module head records that the persisted store name,
step keys, and event string keep the older coordination wording.
- durable/operations.ts -> reconcile.ts, so 'operations' means only resource
CRUD, as it does everywhere else in the package.
- DurableOperationOptions -> DurableWorkflowOptions for the same reason.
- Docs: correct the store names (resource-state, deployment-coordination) that
three docs still gave with a dropped notation/ prefix, point the manual's
embedded example at @notation/reconciler/durable, and document the
deployment-hold escape hatch (takeOverDeploymentHold) where operators will
look. Drop the Yieldstar version pin from prose that would drift; it stays
in package.json and the RFC's scope line.
- Say why the custom event pump exists (Yieldstar's event loop is a resident
server; a command needs one execution run to completion), what initialize()
is for, and that store names are persisted contract like step keys.
Nothing on this branch has shipped: @notation/reconciler and
@notation/state were never published, and the released core@0.11.1 never
wrote .notation/state.json. So the persisted strings, the legacy
migration, and the file/SQLite state backends had no dependents.
- Rename the deployment hold's persisted strings to match its name:
the deployment-hold store, notation:hold:* step keys, and the
reconciler.hold.waiting event. Delete the comment that apologised
for the old coordination wording.
- URI-encode resource ids inside step scopes and give the orphan sweep
one scope (notation:orphans) in both workflows, making the key
delimiter unambiguous instead of documenting the ambiguity.
- Delete the legacy state.json migration, and with it initialize(),
the legacyStatePath option, and FileStateBackend.
- Delete the orphaned @notation/state-sqlite package.
- Move the orphan-deletion-skipped event next to the other events and
inline its single-use factory; name event types after their event
strings (DeployDecisionEvent, DriftDetectedEvent, HoldWaitingEvent,
OrphanDeletionSkippedEvent).
- Drop the runtime's unused params option, its redundant per-task
re-binding, and its Yieldstar-branded log and error wording.
- Trim comments to what is and what binds: no history, no defences.
- Namespace every Notation-owned store name as notation/<name>:
notation/resource-state and notation/deployment-hold join
notation/execution-binding, so an application sharing a store client
with these workflows cannot collide with them. Documented in the
durable index key map and the manuals.
- Rename NOTATION_STATE_PATH to NOTATION_DATABASE_PATH: it selects the
whole workflows SQLite database (heap, timers, task queue, stores),
not a state file, and the code's own constant already said so.
- Split decideAction: the driftRead arms that could not fire (create on
an absent remote, plain update) existed only because one function
served two moments. decideDriftAction now takes over after a noop,
where a state node necessarily exists and local params match it.
- Gate the drift read on resource.read in the durable driver, as the
planner already did: a resource with no read has no remote to compare,
and its noop now stands without read skip/success lifecycle noise.
A new test locks the shared behaviour in.
- One name per concept: drop the State alias of StateBackend, the
DurableDestroyOptions alias of DurableWorkflowOptions, and the unused
StateBackend.has().
- Callers now pre-scope the step for reconcileResource as they already
did for deleteResource; step keys are unchanged.
- Rename the statePatch test helper to resourceStateRecord: it returns
a full record, not a patch.
- withRuntime rejects runtime plus databasePath instead of silently
ignoring the path.
Wire emit back through planApp and the CLI plan command, as on main:
deploy and destroy still surface reconciler events through the same
seam, and plan-time drift reads call remote providers, so their
lifecycle belongs in front of the user. createPlan's emit option has a
caller again.
Drop the example's undeclared-in-source dependencies, state the hold
inventory's and waiting event's exact bindings, and let sweepOrphans
document itself once.
takeOverDeploymentHold clears the hold, full stop: the unused
toExecutionId transfer parameter is gone. Deployment identity now lives
only on DurableStateBackend, which already carried it in its store
prefix; DurableWorkflowOptions no longer repeats it and the deployment
hold keys off the backend. The durable module is imported as `durable`
everywhere instead of shadow-naming the reconciler package. Emitter
absence is absorbed in one layer — the adapters (toEmitStep,
durableEmitter) — so operations always hold an emit step and neither
the planner nor emitLifecycleEvent guards again. Docs drop the sample's
duplicated deploymentId and the sentences describing what state is not.
- lastOperation persists only what is written: create and update. Drift
repair persists update, and delete removes the store, so the drift and
delete members were unreachable; the package is unpublished, so no
persisted record carries them.
- Drop the unconsumed ResourceApi, StateApi and DeepObjectDiffApi exports.
- StateBackend is now the read interface its consumers use; the
compare-and-swap update/delete half existed only for test seeding, which
MemoryStateBackend's constructor already covers.
- Cover notation:orphans:list in the durable key map and give the emit
entry the *: scope prefix its keys always carry.
- Say "registered" where the code says resource-type-not-registered, keep
"load the persisted record" distinct from the drift read, name
toEmitStep/durableEmitter/createStepRunner instead of "the in-process
driver", align the decision table with delete-orphan, and state the
no-tombstone fact once, in docs/internals/state.md.
- Type store snapshots as PersistedResourceState and drop StoredResourceState;
a satisfies assertion pins the valibot schema's output to it.
- Delete the PlannerState alias; createPlan takes StateBackend.
- Expose the store version directly as StateNode.version, removing the
one-based rev shim; RevConflict becomes VersionConflict, its unread
fields now constructor arguments that only feed the message.
- Inline the vestigial resourceParams aliases in the create/update/read
operations.
- Rename ReadResourceParams to ResolvedResourceParams: the neutral base
create and update extend, and what read takes exactly.
- Rename runWithCliErrorHandling to runWithErrorHandling, matching its
file name.
… crash-window contract one home per audience
- removeResourceState now distinguishes a version conflict (naming the
actual version) from a genuinely absent record, instead of reporting
every failed deleteFrom as missing; the delete-race test locks the
distinction in.
- resourceType is ResourceType on all four resource-bearing reconciler
events instead of string on two of them.
- The crash-window/idempotency contract is stated once for CLI readers
(docs/cli/deploy.md) and once for internals readers
(docs/internals/reconciler.md, moved to Waiting and replay where it
applies to all provider calls); destroy.md, the manual, and the RFC
now reference those homes instead of restating it.
- Inline OperationEventEmitter as EmitStep<OperationLifecycleEvent>, so
"emitter" names only the plain callback and the step seam is named as
a step.
- Rename the deploy scope key notation:resource:<id> to
notation:deploy:<id>, symmetric with notation:destroy:<id>, and drop
the key map's parentheticals.
- Rename the scoped step key state:snapshot to persisted-record, so the
state: prefix means only store-handle keys, as the key map says.
- Extract applyDriftDetection: the noop-plus-drift-read gate and its
driftDetection default now live once, used by both reconcileResource
and createPlan; the redundant outer defaults in deployApp and planApp
are gone too.
- Require executionId through runtime.run, runDurableWorkflow, and
deployApp/destroyApp: the outermost caller generates the resume
handle and reports it, so no layer silently defaults an ID the caller
can never learn.
- Reorder deploy.md's "What happens": ordering precedes reconciliation.
…rant
- Rename takeOverDeploymentHold to clearDeploymentHold (DeploymentHoldTakeover
to DeploymentHoldClearance, taken to cleared): the operation clears a hold,
it does not transfer one, and "take" already means acquire in this file.
- deleteResourceOperation now treats ResourceNotFoundError from a delete
handler as success: absence is delete's goal state, which makes crash-window
replayed deletes idempotent as the docs already claimed. Test added.
- docs/cli/deploy.md: the compiled output is imported in-process by the CLI;
the worker does not exist yet.
@djgrant
djgrant merged commit cb317d5 into mainJul 28, 2026
1 check failed
@djgrant
djgrant deleted the yieldstar-durable-cutover branch July 28, 2026 20:19
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@djgrant