dhole

module
v0.0.0-...-7d97447 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 9, 2026 License: Apache-2.0

README

Dhole

kw deployment is managed by Kuvryn Sync.

An everything-pipeline engine: durable, typed workflows that run anywhere, driven by anything, editable by humans and agents alike.

Status: usable, not finished. Pipelines run end to end from a single binary and from a distributed control plane, and the same definition produces identical results against SQLite or Postgres, embedded or out-of-process NATS, and filesystem or S3 object stores.

In place: the event-sourced run store, the scheduler, the outbox, durable waits and human approval, leases and fencing, the content-addressed store and the cache, the engine wire protocol with a conformance suite an engine in any language is held to, the executor interface with host-process and Kubernetes backends, CEL policy with trust tiers and audit, taint tracking, identity and OIDC federation, the plugin catalog with oci:// and cas:// resolution, signature verification, federated upstreams and lockfile resolution at save, the definition store with revisions and a one-way git mirror, four triggers (schedule, HTTP, git webhook, pipeline completion), the LLM, bounded-loop and agent step types, the ConnectRPC API with a CLI derived from its descriptors, the React Flow editor, importers for four other CI formats, and OpenTelemetry throughout.

Several things are deliberately unfinished — see Still open below, and .procoder/plans/dhole.md for the task-by-task truth.

What it is

Dhole is a general workflow engine. CI is one profile of it, not its purpose.

A pipeline is a typed DAG. Steps declare their inputs and outputs, so the graph is derived from data flow rather than authored as an ordering — which makes content-addressed caching correct by construction, parallelism free, and local execution possible. Runs are durable and event-sourced, so a workflow can wait three days for an approval and survive a restart without noticing.

Work is executed by engines: separate processes, written in any language, that pull self-contained jobs from a message bus and publish status and logs back. They connect outbound only, so they run in a homelab, on a build Mac, on a GPU box, or behind CGNAT. The control plane restarting is a non-event for work already in flight.

Triggers are pluggable and bind to a pipeline's typed inputs, so the same pipeline can be started by a schedule, a webhook, an API call, a queue message, a human, or an agent.

Design goals

  • Not another CI tool. CI, infrastructure automation, and LLM/agent orchestration are three profiles over one core.
  • Caching that is correct rather than advisory. Content-addressed, derived from declared inputs, not from cache keys someone invented.
  • Side effects are declared. Every step carries an effect class, and it governs both caching and retry — so "send the message" is never silently replayed.
  • Engines are polyglot. The wire schema is the contract; write one in any language.
  • Agents are first-class clients. The drag-and-drop GUI has no privileged endpoints — it drives the same API an agent does.
  • Low overhead. Single binary with an embedded bus, embedded database, and an in-process engine for the homelab case; the same code paths scale out.

Architecture decisions

Every significant decision is recorded in .procoder/adr/, with the constraint that forced it and the price it pays. Records are immutable — a change of mind writes a superseding record.

# Decision
0001 Typed content-addressed DAG replaces the shared mutable workspace
0002 Effect classes govern caching and retry
0003 Runs are durable and event-sourced
0004 Control plane and data plane split with polyglot engines
0005 NATS JetStream is the bus and the availability floor
0006 Executors are pluggable and sandbox lifetime is an explicit lease
0007 Triggers are pluggable and bind to typed pipeline inputs
0008 Definitions live in the server database with a one-way git mirror
0009 Content-addressed cache is a v1 core primitive
0010 Runtime engine registry is separate from the durable catalog
0011 Plugin artifacts resolve through one scheme-addressed resolver
0012 Policy is a first-class subsystem keyed on trust tier
0013 One API contract serves GUI, CLI and agents equally
0014 Tenancy exists in the data model from the first commit
0015 Agent loops are bounded nodes and untrusted data is tainted
0016 v1 is three acceptance pipelines built in parallel
0017 The project is named Dhole
0018 Dhole is licensed Apache-2.0
0019 Policy is expressed in CEL

Definition of done for v1

Three acceptance pipelines, one per profile, all running end to end:

  1. CI — a container build that demonstrably hits the cache on a second run with unchanged inputs.
  2. Automation — schedule and API triggers, a long wait, execution across more than one engine type.
  3. Agent — a schema-validated structured output, a bounded loop, a human approval gate, and recorded token cost.

Running it

make build
./dhole serve

With no flags that is the single binary: an embedded NATS server with JetStream, a SQLite run store, filesystem object stores, one engine hosted beside the control plane, and the API on 127.0.0.1:7777. The engine is not called in-process — it dials the embedded bus and takes its work off the same job.dispatch.* work queue an engine in another datacentre would, which is what makes a laptop and a cluster the same system rather than two that resemble each other.

Flag Default What it selects
--mode embedded embedded or distributed (control plane only)
--store-dsn <user config dir>/dhole/dhole.db a Postgres DSN, or any other value as a SQLite path
--bus-url — the NATS server to dial; ignored in embedded mode
--blob-root <user config dir>/dhole where the CAS, the blob store and the embedded bus live
--api-addr 127.0.0.1:7777 where the one contract is served (DHOLE_API_ADDR)
--no-api off serve no contract at all; nothing can then talk to it
--api-allowed-origin none a browser origin allowed to make cross-origin API calls
The first credential

The API has no unauthenticated call — one contract serves the GUI, the CLI and agents, and all three authenticate (ADR 0013). So a plane that had no way to hand out a first credential would be a plane nobody can reach, and that pressure is how control planes end up with an anonymous mode. dhole serve therefore mints a bootstrap service token at start-up, prints it once, and writes it mode 0600 to <blob-root>/bootstrap.token:

dhole v0.1.0 (abc1234): embedded control plane, bus nats://127.0.0.1:4222, API http://127.0.0.1:7777
bootstrap credential (valid 24h0m0s, also written to ~/.config/dhole/bootstrap.token):
  export DHOLE_TOKEN=dht_default_…

It is an ordinary service token: the store keeps only its SHA-256, it expires, and it is authenticated by exactly the code path every other credential is. For a second tenant, a CI account or a replacement for one that leaked, mint another beside the plane's database:

dhole token issue --tenant default --subject ci --ttl 720h

That is a local administrative command — it takes --store-dsn rather than --server — because the contract has no identity service yet, and inventing one that only the CLI could reach would be the ADR 0013 mistake with the CLI in the privileged seat.

dhole version prints the version and commit the binary was built from. SIGINT and SIGTERM stop the plane rather than killing it: unacknowledged dispatches go back to the queue and unsent outbox rows are still owed.

Still open

Named rather than glossed. Each of these is a real gap, and the plan carries the task that closes it.

  • dhole serve runs no step types, no triggers and no timer poll. It imports none of internal/steps/{llm,loop,approval,agent}, none of internal/trigger/*, and never runs the durable timer's poll. The three acceptance pipelines pass — CI with a real cache hit, automation across a deliberate restart, the agent profile with a bounded loop and an approval — but the acceptance harness is what drives those subsystems, not the server. Until that wiring lands, "three profiles over one core" is demonstrated by the tests and not yet by the product.
  • Arming a durable gate is not atomic with the scheduler's readiness decision, so a wait can be skipped: the gate event is written in its own transaction and can land in the log after the dispatch it should have blocked.
  • The containerd/OCI executor is not built (Task 36). There is no container runtime on the machine this was developed on, and a backend nobody can run is a backend nobody has tested. internal/executor/containerd holds only the lazy-pull decision; the executors that exist are host-process and Kubernetes, and both pass the same shared contract.
  • The lazy-pull byte assertion is unwritten, and honestly so: proving an eStargz image transfers fewer bytes needs a containerd with the stargz snapshotter and the executor above. No mock-registry byte count was written, because that number would mean nothing.
  • Environment identity belongs to the executor, not the sandbox (Task 37b). One Kubernetes executor running steps with different images reports one identity for several environments, so run one executor per image where cache correctness matters.
  • Three subsystems are built, tested, and not wired to a caller. The fair queue and per-pipeline budgets (scheduler.Queue, scheduler.Budgets), and the tenant quota enforcer and CAS guard (tenancy.Enforcer, GuardCAS). Each was finished while the file that would call it belonged to another change. This shape — everything built, one end unconnected — is the single most common defect this project has produced, and it is why each is named here rather than assumed done.
  • Nothing publishes to the catalog. catalog.Publish has no caller outside tests, so a plugin's declaration can be read through GetPlugin and there is no supported way to put one there.
  • Four run-view end-to-end cases still fail, and a fifth is skipped saying why: no operation builds a loop node, so there is no loop pipeline to look at.
  • Not started: the three acceptance pipelines, the VM executor with snapshot restore, and multiplayer editing.

Documentation

docs/ — start with the quickstart, whose every command is executed by the test suite.

Page For
Quickstart a pipeline running in fifteen minutes
The engine wire contract the protocol every engine is written against
Writing an engine contract, engine, make conformance
Writing a plugin manifests, capabilities, effect classes
Policy authoring CEL rules and how to test them
Step secrets giving a step a credential, and nothing else
Deployment topologies laptop, homelab, cluster, Helm
Upgrades and version skew what N and N-1 obliges

There is a reference page for every step type, trigger kind and executor kind, and docs/docs_test.go enumerates them from the source tree — so a backend added next month fails the gate until it is documented.

Installing a release

Releases publish dhole and dhole-engine for Linux, macOS and Windows on amd64 and arm64, multi-arch images on ghcr.io, and a Helm chart. Every image is signed keylessly by the workflow that built it and carries an SPDX SBOM attestation:

cosign verify ghcr.io/azrtydxb/dhole:<version> \
  --certificate-identity-regexp '^https://github.com/azrtydxb/dhole/\.github/workflows/release\.yml@' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

helm install dhole charts/dhole deploys the control plane, engines, and — for a deployment that has not brought its own — NATS and Postgres. See deployment topologies for what to set before it matters.

Stack

Go control plane · NATS + JetStream · SQLite or Postgres · protobuf over ConnectRPC · CEL for policy · React and React Flow.

License

Apache-2.0. See ADR 0018 for why, and what it gives up.

Name

A dhole is a pack-hunting wild dog. The name was chosen for a clear namespace as much as the metaphor — see ADR 0017 for the alternatives and why each was rejected.

Directories

Path Synopsis
cmd
dhole command
Command dhole is the Dhole control plane binary and its command line.
Command dhole is the Dhole control plane binary and its command line.
dhole-engine command
Command dhole-engine runs a Dhole engine: it dials the bus, announces itself, and works the dispatches for its trust tier.
Command dhole-engine runs a Dhole engine: it dials the bus, announces itself, and works the dispatches for its trust tier.
dhole/cli
Package cli is the Dhole command line, and it is deliberately not a convenience wrapper over a favourite subset of the API.
Package cli is the Dhole command line, and it is deliberately not a convenience wrapper over a favourite subset of the API.
Package conformance is the executable half of docs/wire-contract.md.
Package conformance is the executable half of docs/wire-contract.md.
cmd/dhole-conformance command
Command dhole-conformance runs the engine conformance suite against any engine command, in any language.
Command dhole-conformance runs the engine conformance suite against any engine command, in any language.
gen
internal
api
Package api serves the one contract the GUI, the CLI and agents all use.
Package api serves the one contract the GUI, the CLI and agents all use.
blobstore
Package blobstore holds bytes addressed by a key the control plane chose: step logs and large artifacts.
Package blobstore holds bytes addressed by a key the control plane chose: step logs and large artifacts.
bus
Package bus carries transport between the control plane and its engines.
Package bus carries transport between the control plane and its engines.
cache
Package cache turns a step's declared inputs into a content-addressed key, so a step whose inputs have been seen before is skipped and its recorded outputs reused (ADR 0009).
Package cache turns a step's declared inputs into a content-addressed key, so a step whose inputs have been seen before is skipped and its recorded outputs reused (ADR 0009).
cas
Package cas is the content-addressed store: blobs named by the hash of their bytes, scoped per tenant.
Package cas is the content-addressed store: blobs named by the hash of their bytes, scoped per tenant.
catalog
Package catalog is the durable registry of the types a tenant may use: step, trigger and engine types with their schemas, digests, effect classes and capabilities.
Package catalog is the durable registry of the types a tenant may use: step, trigger and engine types with their schemas, digests, effect classes and capabilities.
dag
Package dag derives a pipeline's execution order from the edges between its typed ports.
Package dag derives a pipeline's execution order from the edges between its typed ports.
defstore
Package defstore is the canonical store of pipeline definitions.
Package defstore is the canonical store of pipeline definitions.
dynamic
Package dynamic is the step type that decides at RUNTIME what work there is: a generator emits a pipeline fragment — a matrix from an API, one step per file it found — and that fragment is spliced into the run.
Package dynamic is the step type that decides at RUNTIME what work there is: a generator emits a pipeline fragment — a matrix from an API, one step per file it found — and that fragment is spliced into the run.
effects
Package effects derives retry behaviour from a step's declared effect class.
Package effects derives retry behaviour from a step's declared effect class.
engine
Package engine is the agent that runs steps: the outbound-only half of the wire contract in docs/wire-contract.md.
Package engine is the agent that runs steps: the outbound-only half of the wire contract in docs/wire-contract.md.
executor
Package executor defines the boundary between the control plane and the place a step actually runs.
Package executor defines the boundary between the control plane and the place a step actually runs.
executor/containerd
Package containerd runs steps as OCI containers on a containerd daemon.
Package containerd runs steps as OCI containers on a containerd daemon.
executor/executortest
Package executortest holds the executor conformance contract: the behaviour every executor backend must exhibit, expressed once so a container, VM or remote backend can be dropped in unchanged.
Package executortest holds the executor conformance contract: the behaviour every executor backend must exhibit, expressed once so a container, VM or remote backend can be dropped in unchanged.
executor/kubernetes
Package kubernetes runs steps as pods in a Kubernetes cluster.
Package kubernetes runs steps as pods in a Kubernetes cluster.
executor/pool
Package pool keeps sandboxes warm between runs.
Package pool keeps sandboxes warm between runs.
executor/process
Package process runs steps as bare host processes.
Package process runs steps as bare host processes.
executor/vm
Package vm runs steps in microVMs: the strong-isolation tier, where the boundary between a step and everything else is a guest kernel rather than a set of namespaces.
Package vm runs steps in microVMs: the strong-isolation tier, where the boundary between a step and everything else is a guest kernel rather than a set of namespaces.
executor/vm/agent command
Command dhole-vm-agent is the guest half of the VM executor: the init process of a Dhole microVM.
Command dhole-vm-agent is the guest half of the VM executor: the init process of a Dhole microVM.
executor/vm/vmwire
Package vmwire is the protocol spoken between the VM executor on the host and the guest agent inside a microVM.
Package vmwire is the protocol spoken between the VM executor on the host and the guest agent inside a microVM.
health
Package health answers the two questions an orchestrator asks.
Package health answers the two questions an orchestrator asks.
identity
Package identity authenticates callers of the control plane.
Package identity authenticates callers of the control plane.
importers
Package importers translates the pipeline formats of other systems into Dhole pipelines.
Package importers translates the pipeline formats of other systems into Dhole pipelines.
lease
Package lease owns step leases and the fence tokens that make duplicate delivery safe.
Package lease owns step leases and the fence tokens that make duplicate delivery safe.
mirror
Package mirror is the one-way git export of the canonical definition store.
Package mirror is the one-way git export of the canonical definition store.
obs
Package obs is Dhole's telemetry: the traces and metrics that answer "where did this run's time go?".
Package obs is Dhole's telemetry: the traces and metrics that answer "where did this run's time go?".
outbox
Package outbox bridges the run store and the bus.
Package outbox bridges the run store and the bus.
plugins
Package plugins resolves plugin references to digest-addressed artifacts and fetches their bytes.
Package plugins resolves plugin references to digest-addressed artifacts and fetches their bytes.
policy
Package policy is the one place a trust-tier decision is made and the one place it is recorded (ADR 0012).
Package policy is the one place a trust-tier decision is made and the one place it is recorded (ADR 0012).
registry
Package registry is the runtime engine registry: which engine instances are alive right now, what they can do, and what state they are in.
Package registry is the runtime engine registry: which engine instances are alive right now, what they can do, and what state they are in.
runstore
Package runstore is the durable source of truth for run state: an append-only, tenant-scoped event log.
Package runstore is the durable source of truth for run state: an append-only, tenant-scoped event log.
scheduler
Package scheduler decides what a run does next.
Package scheduler decides what a run does next.
secrets
Package secrets is the redemption half of the rule that engines never receive secret values, only short-lived references they redeem.
Package secrets is the redemption half of the rule that engines never receive secret values, only short-lived references they redeem.
server
Package server assembles the Dhole control plane out of the parts the earlier tasks built, and is the first place they have to work together.
Package server assembles the Dhole control plane out of the parts the earlier tasks built, and is the first place they have to work together.
steps/agent
Package agent is the agent step type: a model given a bounded loop and a closed set of things it is allowed to do.
Package agent is the agent step type: a model given a bounded loop and a closed set of things it is allowed to do.
steps/approval
Package approval is the human gate in a run.
Package approval is the human gate in a run.
steps/gate
Package gate is the step type that WAITS.
Package gate is the step type that WAITS.
steps/llm
Package llm is the model-call step type: it asks a language model for a JSON object, holds the answer to a declared schema, and records what the call was and what it cost.
Package llm is the model-call step type: it asks a language model for a JSON object, holds the answer to a declared schema, and records what the call was and what it cost.
steps/loop
Package loop is ADR 0015's answer to the one shape a DAG cannot hold: an agent that thinks, acts, observes and goes round again.
Package loop is ADR 0015's answer to the one shape a DAG cannot hold: an agent that thinks, acts, observes and goes round again.
taint
Package taint implements ADR 0015's second half: data entering from an untrusted trigger is marked AT THE BOUNDARY, the mark propagates through typed ports, and tainted data reaching an effectful step or a privileged engine is refused unless an explicit sanitisation gate clears it.
Package taint implements ADR 0015's second half: data entering from an untrusted trigger is marked AT THE BOUNDARY, the mark propagates through typed ports, and tainted data reaching an effectful step or a privileged engine is refused unless an explicit sanitisation gate clears it.
tenancy
Package tenancy turns a tenant id into an account somebody can be held to: it provisions the tenant, holds the limits it runs under, and keeps the ledger it is billed from.
Package tenancy turns a tenant id into an account somebody can be held to: it provisions the tenant, holds the limits it runs under, and keeps the ledger it is billed from.
tenant
Package tenant is the tenancy boundary: the single place that says what a tenant id may be, how it travels through a call, and what it names on the bus.
Package tenant is the tenancy boundary: the single place that says what a tenant id may be, how it travels through a call, and what it names on the bus.
trigger
Package trigger is the pluggable way a run starts.
Package trigger is the pluggable way a run starts.
trigger/completion
Package completion is the trigger that fires when another pipeline's run finishes.
Package completion is the trigger that fires when another pipeline's run finishes.
trigger/git
Package git is the trigger that fires on a forge's webhook: GitHub, Gitea or Forgejo.
Package git is the trigger that fires on a forge's webhook: GitHub, Gitea or Forgejo.
trigger/http
Package http is the trigger that fires on an inbound POST.
Package http is the trigger that fires on an inbound POST.
trigger/schedule
Package schedule is the trigger that fires on a cron expression.
Package schedule is the trigger that fires on a cron expression.
version
Package version reports the identity of this build: the release version and the commit it was made from.
Package version reports the identity of this build: the release version and the commit it was made from.
wait
Package wait makes a run wait without anything staying awake.
Package wait makes a run wait without anything staying awake.
webui
Package webui serves the built web app from the control plane's own binary.
Package webui serves the built web app from the control plane's own binary.
wire
Package wire holds the engine protocol version and the rule for agreeing on one.
Package wire holds the engine protocol version and the rule for agreeing on one.
web
e2e/seed command
Command seed mints a credential for a tenant other than the bootstrapped one.
Command seed mints a credential for a tenant other than the bootstrapped one.
tools/typecheck-oracle command
Command typecheck-oracle answers, for a list of pipelines given as JSON on stdin, exactly what internal/dag.TypeCheck answers.
Command typecheck-oracle answers, for a list of pipelines given as JSON on stdin, exactly what internal/dag.TypeCheck answers.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL