@agentworkforce/factory-cloud
v0.1.4
Published
The Cloudflare baseline a Factory garden deploys: the Worker, the Container image, the Durable Object classes and their migrations, and the dispatch config template.
Readme
factory-cloud
Deployment home for the AgentWorkforce Factory control process. Factory source
remains in the public AgentWorkforce/factory repo; this repo
consumes its published @agent-relay/factory package.
This repository is PRIVATE. The published npm package is what a garden
consumes. It is the baseline that every Factory instance deploys, and a
garden installs it as @agentworkforce/factory-cloud at a pinned version —
no repository credential at all. See
consuming this baseline.
This repository was briefly public on 2026-09-03 to remove that credential, and was made private again the same day once the package became the distribution path. If you are reading a copy of this file that says "this repository is public, and that is load-bearing", it predates that change.
Be clear about what the package discloses. The tarball carries src/,
container/, config/, scripts/ and test/ — publishing it publicly
discloses the same source a public repository would. What stays private is the
git history, the issues and pull requests, and the branches. The package is a
deliberate, versioned artifact rather than a live repository; it is not a
smaller disclosure of the code itself.
It holds no secrets and no account-scoped identifiers, and
scripts/check-public-baseline.mjs fails CI if one appears — that check is
named for the era that introduced it and still earns its keep, because the
package is public even though the repository is not. Credentials live in
Actions secrets and Cloudflare, and reach the runtime as --var/secret
bindings.
Tracking issue: factory#268.
Runtime shape
One Cloudflare Worker always addresses one named factory-primary Container,
and wrangler.jsonc caps that Container class at max_instances: 1. The
Container runs the long-lived Factory event subscription and timers. Factory
uses --backend relay, so agents run on enrolled fleet nodes in a fresh
sandbox per agent. The persistent dispatcher and ephemeral agents are separate
lifecycles.
The deployed compatibility mode deliberately does not sleep: it keeps the
24-hour inactivity window, renews that window, and addresses the same singleton
from a minute recovery cron. Container disk is ephemeral. Separate named
Durable Objects retain the version-3 Factory state document and the rotating
RelayAuth path-token pair. Claims and leases must report the cloudflare-do
backend before live start is admitted; the entrypoint refuses to start against
the file store.
An additive event-driven path is implemented but hard-disabled in the deploy workflow. Relayfile's existing signed outbound webhook can wake the stable singleton, and the existing state Durable Object can retain a chunked, integrity-checked mirror generation so resume does not require an immediate cold rebuild. Receiver/snapshot shadowing and short sleep have separate flags; sleep fails closed unless the dispatcher is idle, reconciliation is complete, and a ready snapshot exists. The outbound subscription and reconciliation remain the fallback and correctness paths until live acceptance evidence is complete. See event-driven wake and durable resume.
Factory is embedded through the host-neutral @agent-relay/factory/cli
entrypoint. container/factory.mjs injects the Cloudflare adapter; the public
Factory package contains no Cloudflare selection policy.
Configuration
Factory resolves exactly one contract: the path passed to --config, otherwise
./factory.config.json in its working directory. It does not search target
repositories or layer contracts. The image renders
config/factory.config.template.json and passes the exact resolved path to
every command. See config resolution.
The workflow requires exactly four GitHub Actions secrets and one non-secret repository variable. Each command prompts for its value without putting it in shell history:
gh secret set CLOUDFLARE_API_TOKEN --repo AgentWorkforce/factory-cloud
gh secret set AGENT_RELAY_WORKSPACE_KEY --repo AgentWorkforce/factory-cloud
gh secret set FACTORY_RELAYAUTH_ACCESS_TOKEN --repo AgentWorkforce/factory-cloud
gh secret set FACTORY_RELAYAUTH_REFRESH_TOKEN --repo AgentWorkforce/factory-cloud
gh variable set FACTORY_SLACK_CHANNEL --repo AgentWorkforce/factory-cloudThere is deliberately no Factory GitHub token. github.identity is pinned to
app; private reads and every GitHub write must cross the server-side App
boundary. The Cloudflare token deploys infrastructure and is not passed into
the Container. The RelayAuth pair seeds the credential Durable Object and is
also never passed into the Container; only the Container-private
factory-auth.do access endpoint crosses that boundary. Never commit a secret,
account id, workspace key, or connection string here. This repository is
public; scripts/check-public-baseline.mjs refuses those shapes in CI, and it
is a backstop, not permission to try.
The disabled event receiver additionally expects
FACTORY_RELAYFILE_WEBHOOK_SECRET as a Worker-only secret when an operator
reaches the shadow-doorbell stage. It is intentionally absent from the current
deployment workflow: this repository does not mint, rotate, or enable it, and
it is never forwarded to the Container.
Consuming this baseline
A garden depends on a published version of this package, not on a checkout of this repository. That is the whole dependency:
// a garden's baseline.json
{ "package": "@agentworkforce/factory-cloud", "version": "0.1.0" }The garden's deploy workflow turns that into an exact npm dependency and
installs it; it does not commit a package.json of its own. See
AgentWorkforce/hoopsheet-garden's .github/actions/fetch-baseline.
No token:, no persist-credentials, no repository access. That is the point:
while the baseline was a private repository, every garden job needed a read
credential to resolve it, and the only shapes that satisfied it were a
fine-grained PAT or a GitHub App installation token — infrastructure a
config-only repository should not need. hoopsheet-garden failed CI six
consecutive times on exactly that step.
Why an exact version rather than a range: a running garden must not change
because the baseline moved. ^ or ~ moves by definition. Bumping a garden
onto a newer baseline is then an explicit, reviewable, one-line edit in that
garden's repository.
The package is a build context, not a library. A garden does not import
it; it runs wrangler deploy against it. That is why the tarball ships
wrangler.jsonc, Dockerfile, .dockerignore, src/, container/,
config/ and scripts/ rather than a compiled entry point, and why
wrangler is a real dependency rather than a devDependency — npm does not
install a dependency's devDependencies, and the pinned wrangler version is part
of the deployment contract scripts/check-cloudflare-contract.mjs asserts.
scripts/check-publishable-package.mjs refuses to publish a tarball missing
any path the deploy needs. It derives that list from the Dockerfile's COPY
lines and from wrangler.jsonc, so it cannot drift when a COPY is added, and
it asserts against the real npm pack output rather than the files array —
files is an input to packing, not its result.
Why a package rather than a container image, which is the obvious alternative:
- A garden needs the Worker script, which
wranglerbundles from local source (src/index.mjsplus its siblings). No registry image supplies that, so an image cannot replace this dependency however it is published. - Cloudflare Containers pull only from AWS ECR, Docker Hub, Google Artifact Registry, or Cloudflare's own managed registry. GHCR is rejected outright by wrangler's registry allowlist, so "publish the image to GitHub Packages" is not an available design.
What a garden repository therefore contains, in full: an instance.json
overlay, a baseline.json naming this package and an exact version, and a
deploy workflow. No Worker, no Dockerfile, no wrangler.jsonc. See
config/instances/README.md and
AgentWorkforce/hoopsheet-garden.
Publishing a new baseline version
Use the Publish baseline to npm Actions workflow (.github/workflows/publish.yml).
It is the supported path because a release is no longer just an npm publish: it
also builds the prebuilt Worker bundle, builds and pushes the Container
image to Cloudflare's managed registry, and writes the deploy manifest —
so a garden can provision over the Cloudflare REST API without running
wrangler or Docker itself. See
docs/prebuilt-deploy-artifacts.md. Leave
dry_run on for the first run against a new scope: it validates the bundle and
manifest without pushing an image or publishing.
Publishing by hand skips the image push and the artifact build. A bare
npm publish ships a tarball with no dist/ (no worker bundle, no manifest)
and no pushed image, so a garden's REST provisioner has nothing to deploy —
even though check-publishable-package.mjs passes, because dist/ is optional
in the pack. If you must release manually, reproduce what the workflow does:
npm ci
npm run check # the same gate ci.yml runs
npm version <patch|minor|major>
npm run deploy:build # prebuilt Worker bundle -> dist/worker/
# build + push the Container image, capture <name>@sha256:<digest>:
npx wrangler containers build . -t "agentworkforce-factory-cloud:<new version>" --push
node scripts/generate-deploy-manifest.mjs --release \
--worker-bundle-dir dist/worker \
--image-ref "<name>@sha256:<digest>" --image-digest "sha256:<digest>" \
--out dist/factory-cloud-deploy-manifest.json
npm publish # now the tarball carries dist/
npm view @agentworkforce/factory-cloud@<new version> versionThe image build+push needs CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID.
The last line is not ceremony. npm publish exiting 0 is not evidence the
registry serves the version — a release nobody can install is not a release,
and that assertion is the one step a human is most likely to skip.
prepublishOnly runs scripts/check-publishable-package.mjs on the way out, so
a tarball missing a path the deploy needs cannot be published even if someone
publishes without running npm run check first. That guard is the reason this
manual path is tolerable rather than reckless.
An NPM_TOKEN repository secret is already provisioned for the workflow this
section used to describe. Writing it is open work, and the decisions it has to
settle are real ones: whether a release is gated on the Factory drift check,
and whether main moves before or after the registry confirms the version.
Until it lands, the commands above are the whole procedure.
More than one Factory
This repository is the baseline. A second Factory — a "garden" — deploys
this same Worker, Container, Durable Object classes and migrations as its own
Cloudflare Worker service, and differs only by a config overlay in
config/instances/. It is selected at container boot by the
FACTORY_INSTANCE_SLUG Worker variable; absent, the baseline renders exactly as
before.
Isolation comes from the Worker service name (wrangler deploy --name),
which is what scopes a Durable Object namespace and the Container. It does not
come from renaming bindings, classes, migrations, or the stable factory-primary
object key — those stay identical across instances on purpose.
See config/instances/README.md for what is config
and what is inherited, and AgentWorkforce/hoopsheet-garden for a worked
example.
Observability
The resolved config explicitly enables Factory's bounded Cloud reporter under
the operator-facing instance name factory-khaliq-cloud. Events first enter
Factory's local outbox, then the authenticated Cloud Factory ledger. Cloud owns
the downstream fan-out: sanitized operational failures go to PostHog, while
the Factory health sweep turns stale heartbeats and explicit failures into the
shared NightCTO evidence contract.
PostHog and NightCTO credentials therefore remain in AgentWorkforce/cloud;
they are not duplicated into this deployment. Reporter or sink failure is
fail-open for orchestration: it may leave telemetry pending or lost across a
Container replacement, but it must never stop Factory dispatching. An explicit
config key proves deployment intent, not delivery; completion still requires a
real event observed in the Cloud ledger and both downstream systems.
The authenticated /evidence payload also carries dispatchContract, an
allowlisted projection of the rendered config the running Factory was
started with — the repo roster, the label/prefix gate, batchSize and
mergePolicy. A successful read carries state: "available"; a read or parse
failure remains visible as state: "unavailable" with a fixed reason code
rather than disappearing during JSON serialization. The template is baked
into the image at build time, so a checkout only describes the commit you are
reading; this is the read that answers what is deployed. It is authenticated
because the resolved contract names every repository Factory may write to. See
config resolution.
Version drift
factory-version.json is the image's single Factory package pin.
scripts/check-factory-drift.mjs fails closed if that pin is behind npm or the
registry cannot be checked. CI runs it on pushes, pull requests, and daily;
bump-factory.yml moves the pin automatically. This prevents the deployment
repo from becoming a new place for a package pin to rot.
Deploy
deploy.yml is manual. Its default is a start-disabled deployment that builds
the image and runs the real built CLI inside the Container. A live request
still fails closed until Factory reports the Durable Object SQLite backend.
The workflow returns the host exit codes for status and run-once --dry-run.
It explicitly deploys FACTORY_EVENT_DRIVEN_ENABLED=0 and
FACTORY_EVENT_DRIVEN_SLEEP_ENABLED=0, preserving the working bridge.
The SQLite namespace is intentionally not auto-initialized. Before the first live start, run the create-only import command inside the named Container and feed it the current v3 state document on stdin:
node /opt/factory-host/container/import-state.mjs - < current-state.jsonThe command uses the Container-only factory-state.do route, validates the
document with the installed Factory package, reads it back, and prints only
its revision, byte count, and SHA-256. A second import fails with HTTP 409.
Restart the Container after import so its preflight runs against initialized
state.
The credential Durable Object rotates the pre-minted fixed workspace-path pair
before first use, atomically stores each new pair, and returns only its access
token through the Container-private endpoint. Replacement Containers reload
the newest pair from the Durable Object, not ephemeral disk or host-local
login. The deploy remains gated on a published Factory package that consumes
FACTORY_CLOUD_ACCESS_TOKEN_URL, and private repository read/write must land
through the GitHub App. A successful infrastructure deploy is therefore a
rehearsal, not proof that #268 is done.
Completion requires a cloud-originated private-repo dispatch, remote agent spawn, App-authored PR, restart-without-redispatch proof, and a fresh exit-code check that the laptop Factory remains stopped. See the cutover design and acceptance bar.
