kymora-workflow
kymora-workflow is a Kyo-native DAG task engine with Mill-aligned incremental caching semantics. A workflow is built from typed Task[A] nodes, executed through the Workflow effect, and backed by a Workflow.Runtime that supplies VFS access, cache layout, observability, and run configuration.
The module is published for JVM, Scala.js, Scala.js WASM, and Scala Native. WASM tests run on Node.js 24+.
See the acceptance plan for the architecture and conventions.
Overview
Task[A]— sealed trait. Variants:Task.Cached,Task.Persistent,Task.Activity,Task.Source,Task.Sources,Task.Input,Task.Command. All built viaTask.<kind>smart constructors:Task.cached(canonical) /Task.init(alias) — cachedTask.persistent— persisted-outputTask.activity— graph-internal work that always runs and never stores its own outputTask.source/Task.sourceQuick— single-path file inputTask.sources/Task.sourcesQuick— multi-path (MillSourcesanalogue); produces an orderedChunk[VPathRef]with an order-sensitive aggregate fingerprintTask.input— pure-value inputTask.command— always-runs command, intended for CLI/tooling edgesAnyTask— top-level opaque wrapper for heterogeneous internal collections of tasks without exposingTask[?]as the public ergonomic story.- Parameterized variants:
Task.cached[A, P],Task.persistent[A, P], andTask.command[A, P]return aP => Task[A](orP => Command[A]). For Cached/Persistent,Pparticipates in the cache key viaHashable[P]— differentPvalues produce different cache entries against the sameTaskId. Commands carry noparamHash(they never cache). Workflow.scope(prefix)— definition-scope helper (compile-time validated).Workflow— first-class Kyo effect for task execution. Handle it withWorkflow.handle(runtime)(...).Workflow.Runtime(vfs)carries execution dependencies: writable VFS backend plus default run config, cache root, observer, and value codec. Override those defaults with named arguments when needed.Workflow.Runtime.defaultcreates an in-memory runtime using the same defaults.Workflow.context/Workflow.destexpose the task context while a task value is being evaluated.Workflow.run(goal)/runAll(goals*)— engine entry points; signatures takeAsync & Workflow & Abort[WorkflowError].task()is shorthand forWorkflow.run(task).io.eleven19.kymora.workflow.cli.Cli.runWith(task, tokens)— bridges kyo-case-app argument parsing to parameterized commands. Provide a case class withcaseapp.Parser/caseapp.Helpinstances in scope, build the command viaTask.commandA, Args { args => ... }, then run withCli.runWith(cmd, args)under the ambientWorkfloweffect.CacheStore— cache-store utility API. Workflow execution uses its runtime VFS backend and internal cache layout directly;VfsDirStoreremains available for tests and standalone store use.WorkflowEvent+Observer— observability stream. The defaultConsoleObserver/JsonLinesObserverwrite through Kyo'sConsoleeffect (notSystem.out) so a test runner can capture output.
Basic Usage
import io.eleven19.kymora.vfs.*
import io.eleven19.kymora.workflow.*
import kyo.*
val compile = Task.cached("compile") {
"compiled"
}
val program =
for
backend <- Vfs.inMemory.init
runtime = Workflow.Runtime(backend)
result <- Workflow.handle(runtime) {
Workflow.run(compile)
}
yield resultInside an active Workflow, task() is shorthand for Workflow.run(task):
val compileAgain = Task.cached("compile-again") {
"compiled"
}
val shorthand =
for
backend <- Vfs.inMemory.init
runtime = Workflow.Runtime(backend)
result <- Workflow.handle(runtime) {
compileAgain()
}
yield resultTask Workspaces
Task values can access their engine-managed destination directory through Workflow.dest and use normal VFS path syntax:
val writeReport = Task.cached("report") {
for
dest <- Workflow.dest
file = dest / "report.txt"
_ <- file.write("generated report")
yield file
}Cached tasks run in a temporary .dest.tmp workspace and seal it into .dest after success. A later cache hit decodes the stored value and does not evaluate the task value again. Persistent tasks run directly in .dest, so state can survive invalidating invocations:
val stateful = Task.persistent("stateful") {
for
dest <- Workflow.dest
marker = dest / "marker.txt"
vfs <- Vfs.get
exists <- vfs.exists(marker)
value <- if exists then marker.read else marker.write("first").map(_ => "first")
yield value
}Task Kinds
Each task kind is useful in a different part of the graph. These examples all wire dependencies so the cache behavior is visible.
Source
Use a single path as a content-hashed file input:
val source = Task.source("source")(VPath.root / "src" / "Main.scala")
val compile = Task.cached("compile")(source) { ref =>
s"compiled ${ref.path.show} at ${ref.fingerprint.value}"
}Sources
Use ordered multi-path inputs when reordering is meaningful:
val sourceFiles =
Task.sources("sources")(
VPath.root / "src" / "Main.scala",
VPath.root / "src" / "Util.scala",
)
val digest = Task.cached("digest")(sourceFiles) { refs =>
refs.map(_.fingerprint.value).mkString("\n")
}Input
Use Task.input for non-file values that should influence downstream cache keys:
var scalaVersion = "3.8.4"
val version = Task.input("scalaVersion")(scalaVersion)
val report = Task.cached("version-report")(version) { v =>
s"compiled with Scala $v"
}Cached
Task.cached stores a typed TaskRecord[A]. A valid hit decodes the stored value and skips evaluation:
final case class Report(name: String, total: Int) derives Schema
val count = new java.util.concurrent.atomic.AtomicInteger(0)
val report = Task.cached("report") {
Report("run", count.incrementAndGet())
}Persistent
Task.persistent has the same typed record semantics as Task.cached, but it evaluates in a preserved .dest directory when invalidated:
var revision = 1
val input = Task.input("revision")(revision)
val stateful = Task.persistent("stateful")(input) { _ =>
for
dest <- Workflow.dest
marker = dest / "marker.txt"
vfs <- Vfs.get
exists <- vfs.exists(marker)
value <- if exists then marker.read else marker.write("first").map(_ => "first")
yield value
}Activity
Task.activity is non-cached graph-internal work. It evaluates once per workflow execution, never writes a record, and still hashes its value for cached dependents:
val clock = Task.activity("clock")(java.lang.System.currentTimeMillis())
val formatted = Task.cached("formatted-clock")(clock) { millis =>
s"clock=$millis"
}Command
Task.command is for CLI/tooling entrypoints. Commands always run when selected as goals; their dependencies still cache normally:
val source = Task.source("publish-source")(VPath.root / "src" / "Main.scala")
val packageJar = Task.cached("package")(source) { ref =>
s"jar for ${ref.path.show}"
}
val publish = Task.command("publish")(packageJar) { jar =>
Console.printLine(s"publishing $jar").map(_ => jar)
}Prefer Task.activity for ordinary graph-internal non-cached work. Use Task.command at the edge where a tool or CLI action is the selected goal.
Cache Typeclasses
Task.cached, Task.init, and Task.persistent require Cacheable[A] and Hashable[A] for their output type. For most values, deriving Schema is enough because workflow provides Schema-backed defaults:
final case class Report(name: String, total: Int) derives Schema
val report = Task.cached("report") {
Report("run", 1)
}You can also derive Cacheable explicitly:
final case class ExplicitReport(name: String) derives Schema, CacheableDefine a manual Cacheable[A] if the encoded cache value needs a custom schema or migration strategy, and define a custom Hashable[A] when downstream invalidation should ignore or normalize part of the value:
final case class Seed(stable: Int, volatile: Int) derives Schema
given Hashable[Seed] =
seed => summon[Hashable[Int]].hash(seed.stable)Use TaskVersion to intentionally invalidate a task value:
val bundle = Task.cached("bundle", TaskVersion(2, 0, 0)) {
"new bundle format"
}Errors And Observability
Workflow execution fails through Abort[WorkflowError]. Task values may fail with either ordinary Throwables or WorkflowErrors; throwables are bridged to WorkflowError.TaskFailed.
val invalid = Task.cached[Int]("invalid") {
Abort.fail(WorkflowError.InvalidTaskId("bad id", "contains spaces"))
}
val recovered =
for
backend <- Vfs.inMemory.init
runtime = Workflow.Runtime(backend)
result <- Workflow.handle(runtime) {
Abort.run[WorkflowError](invalid())
}
yield resultObservers receive structured WorkflowEvents such as TaskQueued, TaskStarted, TaskCached, TaskCompleted, and TaskFailed. Use Observer.NoOp for silent runs, ConsoleObserver for human-readable output, or JsonLinesObserver for machine-readable event streams.
Examples
See kymora-examples for:
smile-build— Mill-like build DSL.agent-skills— workflow-backed agent skills.
Testing
See kymora-workflow-testkit for WorkflowTestDriver, TestClock, CollectingObserver, InMemoryCacheStore, and TaskBuilder ObjectMothers.
Behavior requirements are documented in docs/behavior.ears.md. Requirement families map to the workflow test suites named in that file.
Gotchas
- Always construct tasks via
Task.<kind>. There are no top-levelSource/Input/Command/Cmdaliases — every kind is reached throughTask.cached(or itsTask.initalias),Task.persistent,Task.source,Task.input, orTask.command. This sidesteps thekyo.Commandshadow thatimport kyo.*introduces. - CLI argument parsing uses kyo-case-app. The engine no longer ships a
Task.cliconstructor orWorkflow.runClientry point. Instead, build a parameterized command viaTask.commandA, Args { ... }and invoke it throughio.eleven19.kymora.workflow.cli.Cli.runWith, which threads acaseapp.Parser[Args]+caseapp.Help[Args]into the sameWorkflow.runpath.
Hashing
Fingerprint.ofBytes is backed by the pure-Scala BLAKE3 implementation in pt.kcry::blake3 — cross-platform across JVM, Scala.js, Scala.js WASM, and Scala Native. Hashes are byte-identical on every platform, so cache manifests written on one platform are valid on any other.