All notable changes to this repository should be recorded here. Sections
correspond to released git tags; Unreleased collects work merged after the
latest tag.
- Git sources can declare several remotes (
remotes:), with onepush_defaultand onecanonicalremote (phase 2 of.agents/plans/jj-native-operator.md, steps 1-4). A remote is a bare URL or a mapping withurl,canonicalandbranches;repo:still declares the one remoteorigin.- A new cache, or
clone-mode slot, is cloned from the canonical remote (elseorigin, else the only remote) under that remote's name, whatever git'sclone.defaultRemoteNamesays. It gets the other remotes,remote.pushDefault, and a push URL on the canonical remote that makes every push to it fail (angee-fetch-only://<name>). A cache is configured in a staging directory beside its path and moved into place only then, with a plain clone's permissions. A symlinkedcache_pathis cloned into its target, and an existing empty directory (a mount point, say) in place, configured right after. A path that holds anything else is refused. angee source fetchandsource pullreconcile an existing cache's declared remotes, push block and push default, warning for each change. Underremotes:they also makedefault_reftrack the clone remote, so a cache cloned underrepo:, whose originremotes:now names a fork, pulls from the canonical remote. Bring-up and workspace provisioning only warn about differences, as they already did fororigin. Undeclared remotes are never touched, and arepo:source never reads or writes push URLs or tracking.workspace statusandworkspace repairwarn when a clone slot's (mode: clone, or a git slot with no mode) remotes differ from the manifest's, such as a remote marked canonical after the slot was created. They change nothing.- The manifest refuses
repoandremotestogether, apush_defaultthat is undeclared or canonical, two canonical remotes,brancheson the canonical remote, invalid remote names or names differing only in case, relative URLs underremotes, URLs (repoincluded) with control characters or surrounding spaces, invalidbranchespatterns, and several remotes with no clone remote. A workspace template's inline source is checked the same way before anything is cloned. The mapping form accepts<<merge keys, as the rest of the manifest does. angee workspace source publishkeeps to the remotes (step 5): see Changed.
- A new cache, or
angee doctorchecks for what jj slots will need (phase 5 of.agents/plans/jj-native-operator.md). A newtool.jjcheck reportsjj --versionand whetherjj workspace add --colocateexists, andtool.gitwarns when git is older than 2.42. Both are warnings, never errors, since no stack can declare a jj slot yet. The jj driver behind them (internal/vcs/jjdriver) reads a slot's status and diff, is tested in CI against the jj commit in.github/jj-rev, and is not reachable from a manifest.
- Publish picks its remote from the source and keeps to its rules
(phase 2 step 5 of
.agents/plans/jj-native-operator.md).- Without
--remote(REST and GraphQL: noremote), publish goes where git itself would push the branch: itspushRemote, elseremote.pushDefault(whichpush_defaultsets in the cache and in clone slots), else the remote it tracks, elseorigin, else the only remote. It used to go tooriginwhatever the source declared. When git names no remote, as with several remotes and no push default, publish is refused with git's reason (INVALID_INPUT, fieldremote) instead of guessing. - A remote the slot doesn't have is refused (
INVALID_INPUT, fieldremote); when the slot's own push configuration named it, the error says so. - A publish to the canonical remote is refused (
INVALID_INPUT, fieldremote). It used to fail at the remote's push block, after creating a detached slot's branch, which a retry then tripped over. - A branch that matches none of the target remote's
branchespatterns is refused (INVALID_INPUT, fieldbranch). - All of these are checked before a detached slot's branch is created or anything is pushed.
- Without
- The base ref is read from the source's clone remote. A slot's base
(its
ref, elsedefault_ref) was read fromorigin/<ref>first and then from the other remotes in an unspecified order, which varied between calls. It now comes from the remote branch git has the local branch track, then the clone remote's, then the other remotes' by name. This decides a new worktree slot's start, status counts andsync-base. Arepo:source resolves as before unless its local branch tracks another remote. - The workspace slot verbs (fetch, pull, push,
workspace push, sync-base, publish, merge, rebase, and abort and continue) go through the slot driver (phase 4b of.agents/plans/jj-native-operator.md), with no change in behaviour, except that a failed fetch (in a slot fetch, pull or sync-base) names the source's clone remote rather thanoriginwhen the source declaresremotes:. The slot driver also decides what a push and a publish do (phase 4d), with no change in behaviour. It also creates and removes slots (phase 4c): a worktree's staging, install and registration repair, a clone, and removal withgit worktree prune. The git commands are the same, except that a workspace with several worktree slots of one source prunes once per slot rather than once per source. - A workspace no longer renders a stack (#110). A workspace and a stack
are separate concerns: a workspace is files (its rendered template and its
source slots), and a stack's services and jobs reach it through
workspace://mounts,workdirand env.- A workspace template that declares
chainorchain_rootis refused withINVALID_INPUT, naming #110, before anything is materialized: onworkspace create,workspace update, create preflight andtemplate.templatesleaves such a template out. A stack template's ownchain:is unchanged. - The operator no longer reads a stack inside a workspace: workspace status, the topology and the snapshot stop querying its Docker and process-compose. That stack's own operator reports it.
- A stack inside a workspace no longer reads its parent:
stack update --templatetakes its ports from its own answers file, like any stack, where it used to override them with the parent's workspace allocations. - A workspace created from a chaining template keeps its files, the stack
inside included, now an ordinary stack at that path. Its status reports
no inner stack and
workspace destroyworks as before, butworkspace updaterefuses it withINVALID_INPUTnaming #110: a render of the workspace template alone would overwrite or delete what the chain rendered, and an update cannot switch the recorded template. Destroy it and create it again. The record is left as it is. angee doctorreports a chaining workspace template as an error.workspace destroy --purgedeletes a workspace's directory without stopping a stack that lives inside it, which the operator does not know about; stop such a stack first. The guide now says so.
- A workspace template that declares
- Workspace inner-stack fields and workspace logs (#110), removed in a
later release once the consoles in use stop selecting them:
innerStack(RESTinner_stack) is always null, andinnerError,chainandchainRoot(RESTinner_error,chain,chain_root) are always empty, on workspace status and refs. REST and--jsonkeep sending"chain": []until the fields are removed; before, every workspace sent its own template there;workspaceLogs,onWorkspaceLogs,GET /workspaces/{name}/logsandangee workspace logsreturn nothing, since a workspace has no logs of its own; the CLI command prints a deprecation note on stderr.angee workspace statusno longer printschain_root,inner_stackorinner_errorlines.
-
stack update --templatefinds the answers file of a stack whoseANGEE_ROOThas more than one segment (#128). copier writes the answers file in the directoryANGEE_ROOTis relative to, two levels above the stack root fordeploy/.angee. The update looked only in the stack root and its parent, so it refused such a stack with "missing answers file".- It now walks up from the stack root, nearest first, and takes the first
answers file whose recorded
ANGEE_ROOTputs the stack at the stack root by the rulestack initplaces it with. NoANGEE_ROOTmeans the file's own directory, an absolute one names the stack root itself, and a relative one is joined to the file's directory,./deploy/.angeeanddeploy/.angee/included. A relative one that climbs out with.., whichstack initrefuses, never matches.stack update --template -ireads the answers the same way. - Any other answers file on the way is skipped, such as an unrelated
project's further up, or one in the stack root that names another root.
The update used to take a file in the stack root whatever it recorded.
Only a skipped file's
ANGEE_ROOTis read, so its other answers can hold anything, and files above the stack's own are never read. - A file on the way that can't be read as a YAML mapping stops the update
with
INVALID_INPUTontemplate, naming the file, since it might be the stack's own answers, damaged. So does an answer in the stack's own file that is neither a scalar nor a list, which used to be an internal error. - This covers templates that declare
ANGEE_ROOTas a copier question, and templates that leave it out of the questions with the default., as angee-django's stack templates do: copier records noANGEE_ROOTfor them, and the stack sits beside the answers file. A template that declaresANGEE_ROOTonly under_angee.inputswith another default, such as.angee, records none either, and its answers still can't be found, as before. - Paths are compared cleaned, without resolving symlinks, as the parent
check did. A
--rootthat runs through a symlink to a directory between the stack root and the answers file does not find them. A stack root or render directory that is itself a symlink is refused when the render opens it, as before.
- It now walks up from the stack root, nearest first, and takes the first
answers file whose recorded
-
stack update --templateno longer moves path inputs (#121). It read atype: pathanswer from the stack's answers file, where copier recorded it already relative toANGEE_ROOT, and resolved it again as if it were new input relative to the directory copier renders into. On a stack whoseANGEE_ROOTis not., every update moved the path up by the depth ofANGEE_ROOT: under.angee/,PROJECT_PATH: ..became../.., then../../... Stacks withANGEE_ROOT=., as the angee-django templates use, were not affected.- A recorded answer is now replayed as it is, and so is a value passed on
the update that equals the recorded string, as the
-iform and the immutability check already take it. Any other value passed on the update (--input,--answers, or a field changed in-i) is resolved once, asstack initresolves it: relative to the directory holding the answers file. So is the default of a path question the template gained since the last render. - The
-iform shows a recorded path relative toANGEE_ROOT, but reads an edited one relative to the directory holding the answers file. - A chained template that inherits one of the stack template's own path
inputs unchanged takes the recorded answer as it is too. Any other input
it inherits, such as one the stack declares
type: str, and any input its chain entry sets are resolved for that layer, as at init. - Known limits: two chain layouts still move a chained template's path
input once, on the first update, and keep it there (before, they moved on
every update). One is a chain entry that sets its own
ANGEE_ROOT: withANGEE_ROOT: ".",appbecomes../app. The other is a chain entry that forwards the stack's path input: withPROJECT_PATH: "${inputs.PROJECT_PATH}",../appbecomes../../app. No shipped chained template declares a path input. - A stack that has already been updated keeps the moved value in its
answers file. Pass the right one once with
angee stack update --template --input PROJECT_PATH=<path>, relative to the directory holding the answers file. Animmutablepath input refuses a new value: edit the answers file by hand. A question that changed fromtype: strtotype: pathis replayed as recorded, the raw value, and the same--inputrepair fixes it; spell the value differently from the recorded string (./appforapp), since an equal value is kept as recorded.
- A recorded answer is now replayed as it is, and so is a value passed on
the update that equals the recorded string, as the
-
A log read's byte limit keeps the newest whole lines of the whole read (#118).
stackLogs(services: [api, worker], limit: 60)returnedapi | api-1495 … api \n[truncated]: cut mid-line, with nothing fromworker. The process-compose backend capped each process before labelling it, the GraphQL field then kept the start of the joined text, and the container backend kept the oldest bytes of its output.- The limit now applies once, to the labelled read of both runtimes, in the
platform: the runtimes return every line the tail asks for. It keeps the
last whole lines, cut at a line boundary. A read it shortens starts with
a
[truncated]line, which counts toward the limit. The marker used to come last, after the limit. A limit of 12 bytes or less has room only for the marker, so a read it shortens is the marker alone, longer than a limit below 12. - "Newest" means last in read order, not in time. Container lines come
first, as docker compose orders them, then local lines grouped by service
in the order requested. So with
[worker, api]and a small limit,api's last lines are kept andworkercan be left out. angee job runkeeps a local job's output within 1 MiB by the same rule. The remote client's limited read, served by/stack/logs, does too.- The platform holds only the last
limit + 1bytes of a read while it collects it, and a failed docker command's error carries the last 4 KiB of its output, cut on a line boundary, instead of all of it. - The CLI and the REST log endpoints take no byte limit, as before.
- The limit now applies once, to the labelled read of both runtimes, in the
platform: the runtimes return every line the tail asks for. It keeps the
last whole lines, cut at a line boundary. A read it shortens starts with
a
-
The process-compose API token is also read from
PC_API_TOKEN_PATH(#118). OnlyPC_API_TOKENwas read, so a supervisor given its token through a file answered 401 to log reads, to process updates and toGET /live, and the stack could not be brought up. angee now reads the token as process-compose v1.120.0 does (GetApiTokeninsrc/config/config.go):PC_API_TOKENwhen it is set and not empty, as it is; else the contents of the filePC_API_TOKEN_PATHnames, with surrounding whitespace trimmed. A file that can't be read gives no token, as in process-compose, and the supervisor answers as it will. A relative path is read from the stack root, where angee runs the supervisor. When the env file sets a key twice, the last one counts, as it does for the supervisor. -
process-compose downandlistget the stack's env file. They ran with angee's inherited environment alone, so a token set only in the stack's env file never reached them.angee statusshowed local services as unknown, a localjob runfailed,angee downfailed, andangee devwaited for the supervisor forever. A missing env file behaves as before. -
Stacks and the CLI take turns on a shared source cache (decision D9 of
.agents/plans/jj-native-operator.md). The operator holds a lock file beside a git source's cache,.<name>.angee.lock(beside a symlinkedcache_path's target), while it changes the cache's worktree registrations or talks to its remotes: cloning or fetching the cache (bring-up,source fetch),source pull,source push, and adding or pruning workspace worktrees (workspace create,repair,destroy --purge, and a failed create's rollback). Two stacks sharing a cache, or a stack's operator and the CLI, could run these at the same time, so one could prune a worktree the other was still installing, or remove the other's clone staging directory.- One now waits for the other, with a warning that another angee process
holds the cache, and gives up after
ANGEE_LOCK_TIMEOUTwhen that is set, with aCONFLICTerror. A rollback that cannot get the lock still removes what it created and leaves the prune to the next change. - The verbs on a slot (fetch, pull, push, sync-base, merge, rebase, publish) take no lock: git keeps each ref update whole, and they never prune registrations.
- The directory holding the cache must be writable, for the lock file. A
cache_pathbind-mounted on its own, whose parent the operator cannot write, now fails with an error saying to mount the parent directory instead.
- One now waits for the other, with a warning that another angee process
holds the cache, and gives up after
-
Worktree slots work when the user's git writes relative worktree paths. With
worktree.useRelativePaths = truein git's config (git 2.48+), amode: worktreeslot got a.gitfile pointing relative to the temporary directory it was staged in, so once installed at its path every git command in it failed with "not a git repository". The add also setextensions.relativeWorktreesandcore.repositoryformatversion = 1on the cache, and git before 2.48 and go-git refuse to open a repository with that extension, the cache and every slot of it.-
Worktrees are now added with absolute links (
git -c worktree.useRelativePaths=false worktree add; older git ignores the key), so the slot works and the cache's repository format is left as it was. -
A cache hit before this fix keeps the extension, and its slots stay broken: this release changes neither. To return the cache and its slots to absolute links, run, in this order:
git -c worktree.useRelativePaths=false -C <cache> worktree repair <slot>... git -C <cache> config --unset extensions.relativeWorktrees
Name each slot by its absolute path: with
-C, git reads a relative path from the cache's directory. Whichever slots it is given, the repair checks every worktree registered in the cache and fixes each broken one.
-
-
Services restart on failure by default (#111). A service that exited stayed down until someone restarted it, because neither generated file carried a restart policy. Services now take
restart: on_failure | always | no, and the default ison_failure, so on upgrade every service that sets nothing restarts after a non-zero exit, a crash or an outside kill.- Each runtime applies its own policy, with its own backoff. process-compose
gets an
availabilityblock and waits its default second before each restart. Docker Compose getsrestart: on-failure, orunless-stoppedforalways, and doubles its own wait up to a minute. - Neither limits the number of restarts. process-compose counts every
restart since the process was last started, so a limit would eventually
leave a long-running service down for good. A crash loop's output stays
bounded by each local service's rotated
run/logs/<name>.log(#113). - A stop either runtime was asked for (
angee down,service stop,service restart) never triggers a restart. - Jobs take no
restart; it is refused on a job. - The edge container restarts on failure too.
- process-compose now restarts a local service whose readiness probe fails, where it used to leave it stopped.
- Set
restart: noto keep the old behaviour for a service. - On upgrade, the first
angee uprecreates container services, because Compose sees a changed definition. A running process-compose supervisor applies the policy when Angee next starts or restarts a process, or afterangee down.
- Each runtime applies its own policy, with its own backoff. process-compose
gets an
-
A crash loop is visible.
- A service waiting out its restart backoff has the outcome
restarting, counted undersummaryasrestarting(REST and GraphQL). A local service also reports the exit that ended its last run andrestarts, process-compose's restart count; Docker's status reports neither for a container. angee statuslists restarting services under the counts, andangee service listshowsrestarting (exit 1, 3 restarts)orrunning (3 restarts).- A pending job or service waiting on a service the runtime keeps
restarting has a
reasonnaming it. A running service counts only when it has a readiness probe, the only case in which process-compose holds its dependents back. angee devandangee dev -dprint that reason once while they wait for jobs, and a service still restarting when they finish may be in the summary line: it is, when the last status read finds it in its backoff.- To apply a fix to
angee.yamlto a crash loop, runangee service restart <name>.angee service up <name>now also passes a changed definition to a local service caught in its restart backoff, where it used to leave it alone; once the service has relaunched,upsees it running and leaves it. A job waiting on the service is skipped when process-compose replaces it.
- A service waiting out its restart backoff has the outcome
-
Workspace and source status agree with
workspace destroyon whether a source is pushed. They computed it separately. Status now uses destroy's rule from v0.16.0: a source on a detached HEAD counts only commits that no branch, remote branch or tag holds, and a source without an upstream counts only commits that neither its base ref nor its remote counterpart (<remote>/<ref>, origin's first) holds.- After
sync-base, a source readsaheadof its local base butpushed, where it used to read unpushed while push and destroy had nothing to do. - The unpushed reason follows the same rule. A diverged source without an upstream says "N commit(s) ahead of base ref main with no upstream", and a detached one "N commit(s) on a detached HEAD that no branch holds".
- The topology's unpushed count and
workspace repairgo bypushed, so acleandetached source with commits of its own is counted and named. Repair describes an ahead-but-pushed source as "ahead by N commit(s)". - The
ahead,behindanddivergedstates are unchanged.
- After
-
Slot reads go through a driver interface (
internal/vcs, with a git driver), the first step towards jj-backed slots (phase 4a of.agents/plans/jj-native-operator.md, now committed). Source caches are read through the same git driver. -
A failing source diff is reported as
GIT_FAILEDwith git's message, where it wasINTERNAL; the HTTP status is unchanged.
- A service that exits non-zero reads as failed (#112). process-compose
reports a process that exited as
Completedwhatever its exit status, and the service state passed that through, so a service that exited 1 looked finished while the summary counted it as failed.- Service states keep the runtime's
statusand addoutcome, the verdict the summary counts (running,completed,failed,stopped,skipped,pending,never-runorunknown), andexit_code. This is additive in REST and GraphQL (outcome,exitCode).outcomecan be filtered, ordered and grouped on likestatus. - A failed service whose command could not start has the reason
could not start. angee statuslists each failed service under the counts, andangee service listshowsfailed (exit 1)in place of the runtime's status.- A local service killed by a signal still reads as
stopped: process-compose reports exit code -1 for a crash by signal, aSIGKILL(the OOM killer's included) and a deliberate stop alike.
- Service states keep the runtime's
angee logsshows the newest lines (#113). With no--tail,process-compose process logsreplays the supervisor's whole 1000-line buffer over a websocket that drops lines once a client falls about 256 lines behind. The read returned the oldest ~256 lines and missed the newest.- A local service or job is read through the supervisor's log range
endpoint (
GET /process/logs/{name}/0/{n}), which returns the newest lines whole in milliseconds;process logstook about a second per process. Several local services are read at once. A follow still runsprocess logs --follow --tail N. - Log reads and follows, from the CLI and the operator, ask both runtimes for the last 200 lines by default. Container logs used to return the whole history. The log stream endpoint's default backlog is 200 lines too.
angee logs,service logsandjob logstake-n/--tail, the REST/stack/logsand/services/{name}/logstake?tail=, and the GraphQLstackLogs,serviceLogsandonServiceLogstaketail, up to 10000. A local service holds its last 1000 lines. Following a local service or job replays at most 256 lines; a larger tail is refused with an error naming its log file. The log socket refuses atailthat is not a number instead of reading it as the default.- A local service the running supervisor has not loaded (declared after it
started) is reported at once as not found (
NOT_FOUND, REST 404), whereprocess logswaited on it forever. A read while process-compose replaces a service with its updated definition is aCONFLICTthat asks for a retry. angee job runshows every line the supervisor holds of a local job's run (the last 1000), where it showed the oldest 256, and a failure names the job's log file.- Logs of several local services show every one of them. Only the first
was shown, because
process-compose process logsreads one process. When a read covers more than one service, every local line is labelled with its service as docker compose labels its own. - Each stack's process-compose supervisor logs to
run/process-compose.logunder the stack root, without color codes, unlessPC_LOG_FILEnames another file. Before, every stack logged to one shared file in$TMPDIR. - Each local service and job also writes its output to
run/logs/<name>.logthrough process-compose'slog_location: one plain text line per output line with its time and level, flushed as it arrives and rotated by process-compose at 10 MB with three rotated files kept. Output past the 1000-line buffer, or after the supervisor exits, is no longer lost.
- A local service or job is read through the supervisor's log range
endpoint (
workspace destroylet aclonesource go while commits only its own branches held, on a detached HEAD or without an upstream. A clone is a repository of its own, so its branches go with it. Destroy and status now count, for a clone, every commit that no remote branch or tag holds ("N commit(s) on a detached HEAD that no remote branch or tag holds", "N commit(s) that no remote branch or tag holds, with no upstream"). Tags count because git keeps fetched tags with local ones, so a clone pinned at a release tag still reads pushed. Source cache status uses the same rule. Worktree sources keep the rule above: their branches stay in the source cache. A clone without an upstream whose base branch was deleted can now be destroyed when every commit is on a remote branch; it used to be refused.workspace statusreported a source whose base exists only on a remote (origin/<ref>) in the cache as an error; it now counts against that remote ref.
- Values from requests reach git as arguments only. The workspace source
verbs passed a ref, branch or remote from the API to git as given, so a
value shaped like a git option ran as one. A rebase ref could run a command
as the operator's user, a push ref or publish branch could mirror the
remote's branches, and a diff ref could write a file on the host.
- Those values, and the refs, repositories and paths angee passes to the
same git commands from templates and the manifest, now follow
--end-of-options. Git refuses an option-shaped one as a bad revision, refspec or reference. - This covers merge, rebase, push, publish, diff, sync-base, worktree creation, ahead/behind and commit counts, and template clone.
- Template refresh resolves its ref to a commit before checking it out,
because checkout before git 2.44 does not honour
--end-of-options. - Publish pushes its branch as
refs/heads/<branch>:refs/heads/<branch>, so a branch carrying refspec syntax, such as:<branch>(which would delete it), is refused. A push--refis still passed as a refspec. - It needs git 2.24 or newer. Each form was checked on git 2.24.1 to 2.50.
- Those values, and the refs, repositories and paths angee passes to the
same git commands from templates and the manifest, now follow
- Templates that include files outside their directory render again.
copier-go v0.1.0 (in v0.21.0) confines
{% include %}to the template's own root, so angee-django's stacks, which include the collection's_sharedfiles, failed with "unable to resolve template". angee now passes the template's_angee.include_rootto copier-go v0.1.1 (WithIncludeRoot) for every render, after checking that it is a relative ancestor of the template and not the filesystem root. Include names resolve against the template root, as upstream Copier resolves them, so a stack includes../_shared/AGENTS.md.jinja, not../../_shared/…(angee-django updates its templates to match). For a workspace chain template, the pinned snapshot's copier.yml must still declare the include_root it was pinned for, so a declaration rewritten while pinning cannot widen the sandbox beyond the snapshot.
-
when:in the template input form. A question whose condition is false, given the answers before it, is no longer asked: the interactive form hides it, line prompts skip it, and it takes its default, as copier answers it. A value passed for it is not validated. copier-go evaluates the condition. -
~intype: pathinputs.~and~/…expand to the home of the user running the render before the value is made relative to the stack root. The expanded absolute path is what the answers record. -
Template validators are enforced. copier-go (now synced with upstream Copier 9.18.2) runs a question's
validator:on every answer the render uses: inputs passed with--inputor by--yes, and the defaults it falls back to. A failing validator, a value outside a question'schoices, or a required question with neither an input nor a default failsangee init,stack update --template, workspace create and service create with codeINVALID_INPUT, and copier's message names the question.angee init --yesused to accept, for example,serve_mode=productionwithruntime_mode=process.
- Template defaults are left to copier. angee passes copier the inputs
given, generated values and rewritten path inputs, but no longer a
template's plain defaults. A templated default (
"{{ name|lower }}") is rendered instead of being taken as its literal text. Onstack update --template, an answer recorded last time is kept rather than reset to the default. An inactive or secret question's default is not validated. - copier-go is bumped to v0.1.0, its sync with upstream Copier 9.18.2. It adds
the
ask:question key, and the trust list matches a URL with percent-encoding, backslashes or doubled slashes only exactly. A template whose_min_copier_versionis newer than 9.18.2 now fails with codePRECONDITION_FAILEDand causetemplate_too_new; before, the check was skipped. copier records only answers to questions, so angee keeps a service's workspace in its own render state. Services rendered earlier still read it from their answers file. - A read-only bind mount must exist. A
bind://…:romount compiles to compose's long syntax withcreate_host_path: false, so a missing host path fails the container's start (bind source path does not exist) instead of Docker creating an empty root-owned directory, for example a~/.sshthat would then hide a later SSH setup. Writable binds are unchanged.
- More host names per path-routed stack.
ingress.aliaseslists host names the caddy edge serves as the same site asingress.domain, each with its own automatic certificate undertls: auto. The domain stays the canonical host for route URLs. Each alias must be a host name, not the domain, and unique; it needsingress.type: caddyandrouting: path.
-
An error contract for every failed operation. GraphQL errors carry
extensions.code(NOT_FOUND,INVALID_INPUT,CONFLICT,PRECONDITION_FAILED,GIT_FAILED,TIMEOUT,JOB_FAILEDorINTERNAL). When they apply, they also carry:- a
causesuch asuncommitted_changes,slot_missingormerge_conflict; - the
operationand theworkspace,slot,source,joborserviceit concerns; - the masked
remote; - the
pathsinvolved; - a
hint; - git's redacted output as
detail(at most 4 KiB); - the
request_id.
REST error bodies carry the same keys. Causes come from angee's own state and the step that failed; git's and ssh's diagnostics are passed through, never interpreted. See Errors.
- a
-
Messages that say what failed and what to do. For example,
Sync base for workspace "src": source slot "django" has uncommitted changes in addons/.../operator.graphql. Commit or restore the changes, then retry.A git failure names the action and remote, then quotes git's own line, instead of leading with git's raw output. -
Checks before git runs.
workspace sync-baseandworkspace pushcheck every slot before touching any, and sync-base fetches every slot before merging any, so a refusal never leaves the workspace half done. The slot verbs andsource pushrefuse up front, each with its own message:- a checkout with uncommitted changes, listing its files;
- a missing slot, pointing at
workspace repair; - a slot off its branch;
- a push from a detached HEAD;
- a source with no cache.
-
Failed operations are logged. Each is logged with the request ID, operation, code and cause: at WARN when the client can act on it, at ERROR otherwise. A failed GraphQL operation used to leave only
status=200in the log. Responses carryX-Request-ID; a request that sends one keeps it. -
Job runs name what failed. A chained job run whose dependent fails reports which one and why, such as
service "frontend" failed: ..., instead of "one or more dependents failed". The receipt carrieserror_codeanderror_cause(GraphQLerrorCode,errorCause). -
Git over SSH from the operator image. The
angee-operatorandangee-cliimages now include the OpenSSH client; before, any git source with an SSH URL failed withssh: not found. SSH itself stays the platform's: mount the deploy user's~/.sshread-only in the operator's home. See Running in a container.
-
REST statuses now follow the error code:
- uncommitted changes and a branch mismatch answer
409instead of500; - a request made while another mutation is running answers
409instead of400.
git failures and timeouts still answer
500. - uncommitted changes and a branch mismatch answer
-
A dirty checkout is detected one way, by
git status; go-git's status, which could disagree about untracked files, is no longer used. -
URLs in errors and logs have their query values masked as well as their user information, since a token is sometimes passed as a query parameter.
-
The operator container's user has a real home. It is the
HOMEthe container is started with, oradduser's default/home/<name>; it used to be/tmp. A stack-root uid the image already has, such as Alpine'sguest(home/dev/null), gets one too. The docs also show the platform-native form (user:,group_add:, the host's/etc/passwdand/etc/groupmounted read-only), which needs no entrypoint.
- The
kind,name,fieldandreasonerror keys. They are gone from REST error bodies and GraphQL extensions; usecode,causeand the context keys. A not-found error names its object inworkspace,slot,source,joborservice. The "stack root exists" conflict ofstack initiscause: stack_root_exists, with the root inpaths.
- A chained restart keeps routed services on the edge.
angee job run <job> --chained-restart, from the CLI or through the operator (GraphQLjobRun(chainedRestart: true), the console's "Restart application"), recreated the routed services it reached without their caddy labels and off the edge network, so caddy-docker-proxy served an empty config and the site went down until each service was restarted on its own. The job run compiled a view of the stack without its ingress, to leave out the stack-wide edge service, and with it lost the wiring the edge gives routed services. That wiring is now added to the view from the whole stack (path routing numbers its label keys over every routed service), so a recreated service gets the same labels, networks and ports as a full compile; the edge service itself is still left out. Broken since v0.15.0.
- Built with Go 1.26.9 and
golang.org/x/netv0.60.0. Release binaries and images were built with Go 1.26.6, whosenet/http,crypto/tls,net/textprotoandoshave vulnerabilities fixed in 1.26.9 (GO-2026-6603, -6604, -6605, -6607, -6608, -6610, -6611, -6612, -6613, -6617). govulncheck reports none on this release.
- Job status in
angee job list. Each job shows the latest run the runtime holds:never-run,pending,running,completed (exit 0),failed (exit 1),skippedorunknown, so a failed job no longer looks like one that never ran. A skipped job, or one whose command could not start, gets a reason column.--json, RESTGET /jobsand GraphQLjobscarrystatus,exit_code(exitCode) andreason, and GraphQLjobsfilters and sorts onstatus(#86). - Outcome counts in
angee status. The service and job counts list how many are running, completed, failed and skipped, such asservices: 8 (5 running, 3 skipped). A service stopped byangee stopor Ctrl-C counts as stopped, not failed.--json, RESTGET /stack/statusand GraphQLstackStatuscarry the counts assummary(#86). angee job logs <name>shows the output of a job's last run for as long as the runtime keeps it, andangee logsandGET /stack/logs?service=accept job names.job logsused to fail with "job logs are returned by job run" (#86).
- Stopped containers show in
service listandstatus. The status query usesdocker compose ps --all, so a container job that finished reports its exit, and a container service that exited or was never started showsexitedorcreatedinstead ofdeclared(#86). - The process-compose supervisor outlives its processes. It is started
with
--keep-project, so it stays up after every local process has ended, untilangee down(or Ctrl-C for a foregroundangee dev). It used to exit on its own at that point and take every process state and log with it, so a failed job whose dependents were all skipped left nothing forjob list,job logsorangee devto report, andangee job runandangee restarthad no supervisor to talk to (#86).
angee devreports a failed job instead of exiting 0. When a job exits non-zero, or the runtime skips services and jobs because a dependency failed, dev prints one line once the jobs have finished, such asjob "deps" failed (exit 1); 3 skipped: codegen, frontend, storybook; see `angee job logs deps`, and exits non-zero. In the foreground the line appears while the rest of the stack keeps running, and again when dev ends.angee dev -dnow waits for the stack's jobs before returning, bounded byANGEE_JOB_TIMEOUT(default30m), and names any job not finished when the bound runs out. A healthy bring-up prints nothing new and exits 0 (#86).- Skipped services say why.
service listadds the failed job or service a skipped service's dependency chain stopped at, such asjob deps failed (exit 1), and--json, REST and GraphQL carry it asreason. process-compose skips a process only when a dependency fails; a process left out by configuration shows asdisabled(#86). angee service upstarts local services under a running supervisor. v0.17.0 detached the process-compose supervisor withup -D, which fixeduphanging when no supervisor was running but broke the usual case where one already serves the control port, such as duringangee dev: the secondup -Dforked a daemon that died on the taken port while the parent exited 0, soangee service up <local service>(and a secondangee dev -d) reported success and the services stayedDisabled. Before v0.17.0 the same call failed outright. A running supervisor now owns the project:upstarts the named services through it, each after any local dependency it has never run, starts a stopped named service again and leaves running ones alone. Anupthat names no service starts what that supervisor has never run, such as the services an earlierangee service upleft out. A service added to the manifest after the supervisor started is reported, with nothing started, as needing a supervisor restart, because adding it through the process-compose API restarts the processes the supervisor runs. A freshup -Dnow waits for the new supervisor to answer, and fails, naming the port and the process-compose log whenPC_LOG_FILEsets it, if the daemon never does. A control port held by something that is not a process-compose supervisor fails before any launch, and one served by another stack's supervisor (two stacks sharing aprocess_composeport) is refused instead of driven.
angee workspace repair [name]. Materializes the source slots declared for an existing workspace whose paths are missing, such as a slot added toworkspaces.<name>.sourcesafter the workspace was created (which neitherworkspace updatenor lifecycle commands create) or one deleted from disk. Each missing slot is cut the wayworkspace createcuts it (mode, branch,ref/default_ref, subpath, cache refresh). Anything else at a slot's path is never changed: a slot is reported asok, or asneeds-attentionwhen it is dirty, diverged or on the wrong branch, or its path holds something that is not the slot. A slot that cannot be materialized is reported asfailed, with nothing left at its path, while the others are still repaired, and the command then exits non-zero. Repairing a repaired workspace changes nothing. Also exposed as RESTPOST /workspaces/{name}/repair(200 with per-slot outcomes,ok: falsewhen a slot failed) and GraphQLworkspaceRepair(name:)(#95).
- Template lookup says why the template registry failed. When no local
template matched, a failure to fetch the template registry was reported as
template "stacks/dev" was not found locally or in the template registry, so a machine without git (a fresh Ubuntu install, for example) looked like a missing template. A missing git is now named outright; any other fetch failure (no network, an unwritable cache) shows git's error, with credentials in anANGEE_TEMPLATE_REGISTRYURL masked. "Not found" is kept for a registry that was fetched but has no such template, and now names the registry. - Unmaterialized workspace source slots fail with a clear message.
angee workspace sync-base,workspace pushand theworkspace sourceverbs (fetch,pull,push,diff,merge,rebase, the aborts andpublish) on a workspace with a declared slot that is missing on disk now fail before touching any slot, naming the slot and pointing atangee workspace repair <name>(HTTP 409), instead of with a raw gitchdir ... no such file or directoryerror. The same applies to a slot that is a dangling link or, for a git slot, a directory that is not its own checkout, where git would otherwise have acted on an enclosing repository.workspace statuskeeps reporting a missing slot asmissing(#95). angee doctorprobes process-compose correctly. It calledprocess-compose --version, a flag process-compose rejects, so a working install was always reported asversion check failed: exit status 1. The probe now runs theversionsubcommand and reports the first line carrying a version number, skipping the banner and structured debug records process-compose writes around it (#50). Carried over from #57 by @JesseWilsonUSC.angee doctorwaits long enough for Node tools. Each probe had 2s, which a cold Node start regularly exceeds:pnpmwas reported ascontext deadline exceededwhile answering fine by hand. The budget is now 10s (#51). Carried over from #57 by @JesseWilsonUSC.angee doctorchecks thedocker composeplugin. It checked thedockerbinary but neverdocker compose, the separate CLI plugin the compose backend shells out to, so doctor passed clean on installs whereangee upthen failed withunknown shorthand flag: 'f' in -f. The plugin is now its owntool.docker-composecheck, with a distinct hint whendockeritself is missing, and a failed probe reports its stderr instead of only the exit status (#55). Carried over from #57 by @JesseWilsonUSC.- A missing manifest names the next step. Commands that need a manifest
reported
open /path/angee.yaml: no such file or directorywhen run outside a stack. They now report that noangee.yamlwas found and suggestangee initor--root(#52). Carried over from #57 by @JesseWilsonUSC. angee updetaches the process-compose supervisor. The process-compose backend started the supervisor withup -d, but process-compose's-dis--hide-disabled; detached mode is-D. Soangee upandangee service upfor local services ran the supervisor in the foreground and blocked until every process exited. They now pass-D. Carried over from #44 by @litnimax.- A template ref of the wrong kind or shape says what was expected.
template "…" does not match kind "stack"named neither the accepted syntax nor the kind. The error now lists the forms a ref of that kind takes:<name>orstacks/<name>,<owner>/<repo>//<path>, each optionally pinned with@<ref>, an absolute path, or a GitHub URL. A guess such asgh:owner/repo//pathgets that message instead of a failed clone ofhttps://github.com/gh:owner/repo.git. The--templateflag help names the same forms, the templates guide documents each with an example, and theangee service createexamples no longer use a relative path, which is not a template ref (#54). angee source fetchandsource pullfollow therepoinangee.yaml. A git source'srepowas used only for the first clone; afterwards fetch and pull used the cache's ownorigin, so a cache cloned from another URL (for example the HTTPS URL a stack template rendered) kept using it afterrepowas changed to an SSH host alias with a deploy key, andangee source pullfailed withPermission denied (publickey). The two verbs now set the cache'sorigintorepofirst (addingoriginif it is missing) and log a warning naming the source when that changes it. Stack bring-up,workspace createandworkspace repaironly warn about a mismatch, since a cache can be shared by several stacks or be your own clone. Worktree slots share the cache's remotes; other remotes are left alone. Git errors returned to CLI and API clients now mask credentials in remote URLs.
- How
stack update --templatemergessources. A source declared by hand since the last render survives a re-render; a source the template also renders takes the template's definition, replacing local edits to it. The stack update docs say so, and a merge test pins both cases and the change summary (#60). angee init --helpnames the real template registry,ang-ee/angee-django(orANGEE_TEMPLATE_REGISTRY), and that fetching it needs git.
- Worktree sources without a branch. A workspace source with
mode: worktreeand an emptybranchis cut on a detached HEAD at its base instead of failing (or quietly creating a tracking branch) when the source cache has that ref checked out. It needs arefor the source'sdefault_refto start from; without one, create fails before anything is materialized.angee workspace source publish --branch <name>creates the branch at the current commit and pushes it with upstream tracking. - Substitutable source mode. A workspace template's source
modeaccepts${...}substitution likebranchandref, so a stack can pick it through an input.
- Unknown workspace source modes are rejected. A workspace source
modeother thanworktree,cloneor empty fails manifest loading and workspace create, naming the workspace and source. Previously any other value on a git source silently produced a full clone, so an existing workspace with such a mode is a clone: setmode: cloneor remove the line. - Push leaves untouched sources alone.
angee workspace pushandangee workspace source pushwithout--refskip a source that has no upstream and no commits of its own beyond its base, instead of creating an empty branch on the remote, and log a warning naming it. A source on a detached HEAD is refused (HTTP 409) with a pointer tosource publish --branch.source publishwithout--branchreportsnothing to publishfor such a source. - "Unpushed" ignores commits that came from the base. Push, publish and
the
workspace destroyguard count a source's own commits as those that neither its base ref nororigin/<ref>holds. A source thatsync-basemoved past the cache's local base no longer looks unpushed, so destroy no longer blocks on it. For a source on a detached HEAD, destroy refuses commits that no branch, remote branch or tag holds.
angee restartand chained restarts restart process-compose services again. process-compose answers success to a definition update without restarting anything when it judges the definition unchanged, and Angee took that answer for the restart:angee restart <name>and the service steps ofangee job run --chained-restartreported success with the old process still running, typically for every service with areadyprobe. Angee now compares the supervisor's process state before and after an update and restarts the process itself unless the update relaunched it. That also covers a service left out of anangee service up <name>selection, which a restart with a changed definition used to stop and leave down, and a local job re-run, which no longer waits outANGEE_JOB_TIMEOUTwithout running the job (#91).- Worktree sources on a base that exists only on the remote. A worktree
source whose
refexists in the cache only asorigin/<ref>now starts from that commit. Previously git created and checked out a local branch named after the base instead of the source's branch, which then showed as a branch mismatch. The new branch does not track the base. - Workspace publish over HTTPS with the
ghcredential helper. The stripped environment of workspace git operations (ws source publish, merge and rebase) now passesGH_TOKEN,GITHUB_TOKEN,GH_HOST,GH_CONFIG_DIR,XDG_CONFIG_HOMEandGIT_ASKPASS, so a push no longer fails with "could not read Username" when gh authenticates from the environment. Other variables are still not inherited.
- Managed application restarts.
angee job run --chained-restartreruns a root job and then, once it succeeds and its dependents are ready, reruns the downstream jobs and restarts the dependent services in dependency order; failed or blocked prerequisites are reported per node without touching their descendants, and upstream services keep running. The run is a daemon-owned operation with a durable receipt across REST and GraphQL:POST /jobs/{name}/runaccepts optionalinputs/chained_restartand returns202 Acceptedwith a receipt immediately,GET /job-runs/{id}polls its status, current step, per-node outcomes, and terminal output,GET /job-runs/latestreturns the most recently started receipt, andGET /jobs/{name}/run-preview(GraphQLjobRunPreview) lists the affected jobs and services before starting. Receipts are held in bounded daemon memory so a client can disconnect and reconnect while an operation runs, but they are not persisted — restarting or stopping the operator loses them. stop_grace_periodservice field. A service can bound graceful shutdown before the runtime force-stops it, in Go duration syntax (30s,2m); the default is10s. Compose receives the duration directly and process-compose rounds it up to whole seconds. Migration limitation: an active legacy process-compose process started without a shutdown timeout must first be stopped, or its supervisor relaunched, before its configuration can be updated; the operator reports that condition rather than force-stopping the worker.angee stack update --template --skip <pattern>. The repeatable--skipflag keeps matching existing files untouched, including files removed from the newer template; a matched file that does not yet exist is still added. Skip patterns cannot match stack documents, Copier answers, or reconciliation state, and any unskipped conflict still stops the update.
angee devno longer owns container lifetime. Container services start detached andangee devfollows their logs alongside the local-process supervisor; Ctrl-C stops the local processes and the log stream only, leaving containers running, andangee downis now the single owner of container shutdown. The container follower replays a bounded recent tail instead of the whole accumulated history and re-attaches after a container is recreated (for example byangee restart <name>from another shell).angee restart <name>applies the current service definition. Restarting a service now re-applies the freshly compiled configuration — including updated command and environment — on both the container and local runtimes, instead of restarting the previously running definition.- Lifecycle changes are serialized by a shared mutation lease. Stack and service lifecycle operations take a single-slot, reentrant lease, so competing mutations no longer interleave.
- Log reads no longer rewrite the stack. Streaming a service's logs (and
angee logs) no longer runs stack prepare first, so reading logs cannot regenerate runtime files or re-resolve secrets.
- process-compose stderr no longer contaminates job output. Job commands now capture stdout and stderr separately, so diagnostics written to stderr are kept out of the output returned to the caller.
- A stuck job can no longer hold the mutation lease forever. A detached job
run is bounded by
ANGEE_JOB_TIMEOUT(default30m;0disables the bound), so a container job whose process never exits times out and releases the lease instead of blocking every later lifecycle change until the daemon restarts. - process-compose job completion is detected only on genuine terminal
states. Completion is read only from
Completed/Error/Skipped; a poll landing in the launch window (Pending/Launching/Running/…) is no longer misread as a completed exit 0, so a re-run's success is not reported prematurely. A re-run of an unchanged job, which process-compose restarts in place while keeping the previous end time, is now recognised by its new process id instead of waiting outANGEE_JOB_TIMEOUT. - process-compose control-plane calls time out. Every process-compose control-plane HTTP request now uses a client with a 30-second timeout instead of the unbounded default, so a hung supervisor cannot stall a job or status call indefinitely.
- Container services see the current env-file values. The compose backend
now exports the stack env file into
docker compose's environment (as the process-compose backend already did), so a secret updated after the operator started (secretSet, agent reprovision) is rendered fresh instead of being shadowed by the stale copy the operator inherited from its own environment at start;uptherefore recreates the container with the new value. Status (docker compose ps) reads the same env file so it interpolates the compose file from the same fresh values.
- Interactive template input form.
angee initandangee stack initopen a single scrollable screen listing every question incopier.ymlorder, with its help text, choice lists, Yes/No toggles, masked secrets, and an origin marker (default,answers,flag,recorded,changed). Tab/Enter move forward, Shift+Tab moves back, ↑↓ pick a choice, ←→ toggle, and a finalRender the template?confirm gates the render. No or Ctrl+C printsaborted, nothing renderedand exits 130. After confirmation the answers are summarised to stderr. Piped stdin,ANGEE_ACCESSIBLE=1, andTERM=dumbfall back to line prompts that print help and choices and re-ask invalid answers;--yesaccepts defaults with no prompts and reports every missing required input with the exact--inputflag. - Answers files.
--answers <file>(repeatable) oninit,stack init,workspace create,service create, and the template update commands loads a YAML mapping such as a recorded.copier-answers*.yml:_keys are ignored, scalars keep their source text, lists become the multiselect JSON array encoding. Files layer over template defaults and stackworkspace_defaults, under--inputand form edits. - Form on workspace and service create.
angee workspace createandangee service creategain--yes/-yand--interactive/-i, open the form with-ior when required inputs are missing on a terminal, and send only explicit inputs to the platform; fully specified scripted calls keep running without prompts. - Interactive update with recorded answers.
angee stack update --template -i,angee workspace update <name> -i, andangee service update <name> --template -iopen the form pre-filled with the previous render's answers (recorded), list immutable inputs read-only, ask again for secrets that are never recorded, and re-render with the changes.stack update --templatealso accepts--inputand--answers. - Recorded inputs over the API.
GET /stack/template-inputs,GET /workspaces/{name}/template-inputs,GET /services/{name}/template-inputsand the GraphQL queriesstackTemplateInputs,workspaceTemplateInputs,serviceTemplateInputsreturn the template descriptor with the recorded, non-secret answers for one target. - Richer template descriptors.
api.TemplateInputDescriptor(CLItemplate get --json, RESTGET /templates/{ref...}, GraphQL) carriesorder,help,placeholder,secret,multiselect, orderedchoices(list,Label: valuemap, and Jinja string forms),when, andvalidator; inputs followcopier.ymlorder with metadata-only inputs last, andservices/<name>refs resolve. A name declared both as a question and under_angee.inputskeeps the question's presentation and the metadata'srequired/immutable/generatedflags.
--inputand--answersvalues are validated against declared types and choices in every mode, including--yes;runtime_mode=prodnow fails withtemplate input runtime_mode must be one of: process, dockerinstead of rendering.- Stack, workspace, and service template updates reject a change to an
immutableinput before rendering, naming the input without echoing a secret value. angee template getprints one readable block per input (type, default, choices, flags, wrapped help,when) instead ofinput.<name>lines; secret defaults show asdefault set.- The template-author guide documents how
help,choices,secret,multiselect,placeholder, and declaration order shape the form.
- Container images report their release version. The operator and CLI
images stamped only the CLI's version variable, so
angee-operator --versioninside the published image (and the operator's startup log) saiddev. Both Dockerfiles now setinternal/operator.Versionas well, matchingmake build.
-
Service readiness probes. Services can declare a
readyblock with an HTTP, TCP, command, or non-empty-file probe plus timing controls. Angee emits native Docker Compose healthchecks and process-compose readiness probes;after:anddepends_on:now wait for a probed service to become healthy while preserving started-only and completed-job semantics elsewhere. This replaces noisy per-container wait loops in stack templates. -
Stack-level workspace input defaults. A manifest may declare
workspace_defaults, keyed by workspace template ref (workspaces/src, or the baresrc), withinputsthe operator layers under everyangee workspace create --template <ref>for that template. The layering, lowest to highest: the template's_angee.inputsdefaults, the stack'sworkspace_defaults, a declared workspace'sinputs, then the request's--inputvalues (an explicit empty value still wins).angee workspace preflightfolds them intoeffective_inputsand reports the stack's share asstack_defaults. The framework-dev stack template renders the block from itswork_state_sourceanswer so every src-template workspace binds the private work-state slot (.work/) without repeating--input work_state_source=...on each create. -
Verbose command diagnostics. The global
--verbose/-vflag names each phase as it starts and heartbeats slow steps; repeating it as-vvtraces every subprocess and HTTP call with durations and redacted attributes. -
Operator request logging. The daemon now emits structured server logs at
INFOby default, including one completion line per request with an eight-character request ID, status, and duration.-v(orANGEE_VERBOSE=1) enables request starts, resolved authentication scopes, and Platform debug narration carrying the same request ID; health-check records stay atDEBUG. -
angee version. The new command prints the CLI version in plain text or JSON with--json(closes #53).
-vnow means verbose. It no longer aliases the version flag; use--versionorangee versionto print the CLI version.- Git network work is bounded and non-interactive when no terminal is
available. Operator and piped/CI git commands disable terminal credential
prompts and use SSH batch mode unless
GIT_SSH_COMMANDis already set. Clone, fetch, pull, and push operations now time out after two minutes by default (ANGEE_GIT_TIMEOUT;0disables the deadline). - Long waits now expose ownership and deadlines.
run/operator.lockrecords its holder and can be bounded withANGEE_LOCK_TIMEOUT(unbounded by default). Non-streaming remote-operator requests time out after 30 minutes by default (ANGEE_OPERATOR_TIMEOUT;0disables), while streams remain unbounded. Operator keep-alive connections have a 120-second idle timeout.
- Runtime status failures no longer masquerade as stopped services. An
absent process-compose supervisor remains the silent
declaredstate, but parse, HTTP, timeout, and Docker daemon failures now warn with their cause and report affected services asunknown. - OpenBao bootstrap now fails at the readiness deadline. If OpenBao does not accept requests within 30 seconds, bootstrap returns the readiness error (including the last probe failure when available) instead of continuing and failing later with an unrelated secret error.
angee devinteractive control menu. The keyboard menu layered over the foreground log stream (added in v0.10.0) is gone:angee devnow only streams logs, and keypresses are ignored. It was unreliable by construction. The menu was drawn inline on a terminal that docker compose and process-compose write to directly, so the service picker and its prompt were trampled by live logs. The byte-at-a-time key reader had no escape-sequence handling, so every arrow, Home/End, or function key opened and closed the menu on its own and swallowed the next real keypress. And "restart" depended on a synchronous status probe that silently degraded into "start" whenever process-compose's API did not answer (the bug class fixed in v0.10.1), which reported success for a container without restarting it. Ctrl-C still tears the whole stack down; restart or stop one service withangee restart <name>orangee stop <name>from another shell. Non-terminal stdin (pipes, CI, the operator) already bypassed the menu, so that path is unchanged. The cbreak/termios helpers underinternal/cligo with it, as does the SIGQUIT catch that only existed to restore the terminal: Ctrl-\ is back to Go's default (goroutine dump and exit) rather than a graceful stack shutdown.
- Workspace materialization no longer hard-fails when an existing source cache
cannot be refreshed. Before cutting worktrees,
materializeSourcerefreshes a source's clone cache withgit fetch --all --prune. For a private or SSH source the (containerized) operator cannot authenticate, that fetch failed (cannot run ssh,Host key verification failed) and abortedPOST /workspacesand stack bring-up with a 500 — even though the already-cloned cache held everything needed to cut the worktree. Refreshing an existing cache is now best-effort: on failure the operator logs a warning and materializes from the cache. Cloning a missing cache still requires the remote and surfaces errors, and the explicit update verbs (angee source pull,angee workspace source pull/fetch,angee workspace sync-base) still fetch and fail loudly — caches are refreshed through those paths.
- Bump
golang.org/x/cryptoto v0.56.0, clearing govulncheck advisories GO-2026-6354 and GO-2026-6355. This raises the module'sgodirective to 1.26 (the toolchain CI and releases already build with).
- Local (process-compose) service status was always reported as
declared. process-compose's ANSI-coloured "new version available" notice is folded into command output (CombinedOutput), and its escape codes contain a[, which defeated the "trim to the first[" JSON extraction in the status parser — so everyruntime: localservice showed asdeclaredinangee statusand to theangee devcontrol menu. The parser now locates the real JSON array robustly (tries each[and decodes the first valid value). This also fixesangee devrestart, which read the wrong status and tried to start an already-running process ("process is already running") instead of restarting it. - Bump
github.com/gorilla/websocketto v1.5.3, clearing govulncheck advisory GO-2026-6278 (cryptographically weak PRNG for the WebSocket mask key) reached through the operator's log-stream endpoint.
angee devcontrol menu:RandQare direct hotkeys. PressingRorQopens the restart/quit picker in a single keystroke; any other key still shows the menu hint. Previously any key opened the menu andR/Qneeded a second press.
angee devinteractive control menu. When attached to a terminal, pressing any key during the foreground log stream opens a control menu:Rrestarts a service,Qstops one. Each opens a numbered picker of every service (with its runtime and observed state) plusAfor the whole stack andEscto cancel. Restarting a stopped service starts it; quitting a single service stops just it while the rest keep streaming, and quitting the whole stack tears everything down and exits. The terminal runs in cbreak mode so the streamed logs keep rendering and Ctrl-C still shuts the stack down; a non-terminal stdin (pipes, CI) falls back to the plain streaming behaviour unchanged.
angee init --devis removed. Plainangee initrenders the defaultdevstack template — the framework-dev stack: a project host at the stack root whose manifest declares the framework repos as git sources and thesrcworkspace. The redundantangee init stacksubcommand is deleted (angee stack init <template>stays). The repo-local.angee/overlay story is gone with it; see angee-django'sdocs/howto/upgrade-to-split-repos.md.
- Template registry. Bare and kind-qualified template names (
dev,stacks/dev) resolve from the local search paths first and fall back to ang-ee/angee-templates;ANGEE_TEMPLATE_REGISTRYoverrides the registry (URL,owner/repo, or a local path). The ref grammar gainsname@refpins and explicitowner/repo//subpathreferences. angee devcuts declared workspaces. StackPrepare materializes every stack-declared workspace whose directory does not exist yet (WorkspaceCreateadopts declared-but-unmaterialized records), soangee init && angee devis the whole bring-up: sources, the src workspace, provision, boot.- Workspace slot source names are input-substitutable, and an
optionalslot whose source is undeclared is skipped — the opt-in work-state and bridge/example slots render only where their sources exist. scripts/install.shhonoursANGEE_VERSIONfor pinned installs.
- Template repo caches refresh on every hit (fetch + detach at
origin/<ref>, ororigin/HEADfor an unpinned name), so a moving ref never serves a stale template. - Path-input translation defaults to project-at-root: a template with path
inputs but no
ANGEE_ROOTinput no longer gains a spurious../hop. - Stack init and dev tolerate local sources inside the stack root that do
not exist yet (workspace slots the declared
srcworkspace cuts on the same bring-up). - Create and update of
_preserve_symlinkstemplates carrying relative in-template symlinks (CLAUDE.md -> AGENTS.md) are covered by regression tests.
- Homebrew install:
brew tap ang-ee/tap && brew trust ang-ee/tap && brew install angee. Installsangee,angee-operator, andprocess-compose. Docker is deliberately left out — it is only needed forruntime: containerservices and container jobs, and the formula's caveats point at Docker Desktop, OrbStack, and Colima instead. scripts/update-homebrew-formula.shregenerates the formula from a published release's SHA256SUMS. The release workflow runs it and pushes to ang-ee/homebrew-tap, skipping with a warning whenHOMEBREW_TAP_TOKENis unset rather than failing the release.scripts/update-process-compose-formula.shregenerates the vendoredprocess-composeformula from upstream's. It is vendored rather than depended on across taps because Homebrew 6 auto-trusts only the formula named on the command line and refuses to load dependencies from an untrusted tap, so a cross-tap dependency would stop every install at a trust prompt for a third-party tap the user never named.
- Templates declare how far outside their own directory they reach with
_angee.include_root. A chain template is snapshotted before rendering so the render is pinned against mid-flight edits;include_rootnames the ancestor to snapshot instead of the template directory alone, so a relative include like{% include "../../_shared/AGENTS.md.jinja" %}keeps resolving from the pinned copy. It must name an ancestor of the template and stay inside the workspace — pointing it at the workspace root or beyond is rejected. Unset (the default) pins exactly the template directory. The workspace is the only bound, so a template inside a materialized Source can pin that whole checkout;docs/guide/templates.mdsays so out loud.
- A chain template ref that normalizes to the workspace root (
.,a/..) no longer snapshots the entire workspace — sources, worktrees and all — and is rejected with a pointed error instead. Present since v0.8.5. - A malformed
copier.ymlin a chain template now fails resolution instead of deferring an unrelated error to copier-go, and its_angeeblock is parsed from the bytes the guarded probe already read rather than reopened by pathname, which a swapped-in symlink could have redirected.
- v0.8.5 decided the snapshot root by matching directory names against a
built-in list of collection segments (
stacks,workspaces,projects). That heuristic is replaced by theinclude_rootdeclaration above. The name list was wrong in both directions: a collection named anything else (sayhosts/) still lost its relative includes, while any*/stacks/*ref pinned its whole sibling collection whether or not it reached outside itself. How a template collection is laid out is the collection author's business, not a vocabulary this repo should carry —projectsin particular was never a primitive here and never reachable through the snapshot path.
angee ws createno longer fails to snapshot a chain template that contains symlinks (snapshot workspace chain template ...: template snapshot contains unsupported symlink "templates"). The guarded snapshot now copies symlinks verbatim — mirroring the v0.8.3 reconciliation policy — instead of rejecting the_preserve_symlinkslinks the dev/local stack templates ship. Verbatim copies never read the link target and never recurse through a directory link, and copier-go itself refuses to follow a symlink escaping the template root when_preserve_symlinksis off.- Workspace chain templates shaped like
<collection>/<kind-family>/<name>(e.g.templates/stacks/dev) are now snapshotted from the collection root instead of the template directory alone, so relative includes into sibling entries (the dev stack's{% include "../../_shared/AGENTS.md.jinja" %}) keep resolving from the pinned snapshot. Together these unblockangee ws create --template dev. - The template subpath re-attached to that collection snapshot is re-guarded component by component inside the snapshot rather than joined lexically. Because snapshots now retain symlinks, a plain join could have traversed a directory link and handed copier-go a template root outside the snapshot — copier-go's own containment check is lexical against that root and would not have caught it. Guarded-source containment is therefore preserved by the re-guard, not by symlink copying being inherently safe. Resolution also fails early and pointedly when the subpath is missing or is not a real directory.
- Staging a
localsource whose path is the render target root itself no longer fails withvalidate local source "framework": path "." escapes render target.preparedAbsolutePathOpenernow routes the target-root case (rel == ".") to the absolute opener, the same as a path outside the target, instead of asking the target capability to open ".". This unblocksangee init --devin the repo layout, where the dev stack'sframeworklocal source resolves to the repo root (the render target), one level above.angee.
- Stateful Copier reconciliation now preserves
_preserve_symlinks: truetemplates instead of refusing them, soangee init --dev(and every other reconciled render) can materialize the stack templates'CLAUDE.md -> AGENTS.mdand.angee/templates -> ../templateslinks. A rendered symlink is already a first-class entry kind end-to-end — fingerprinted by its link target, applied through a rooted symlink write, and round-tripped through render state — so the earlier blanket rejection was unnecessary.
- The standalone operator now streams manual job output (
angee job run) to its terminal live as it runs, while still returning the captured output to the caller. Per-job status markers ([job <name>] running|finished|failed) keep concurrent jobs readable on a shared terminal. CLI invocations keep the existing buffered, once-at-the-end behaviour.
- Added stateful, ordered Copier reconciliation shared by stacks, workspaces, chains, and template-created services. Rendered files now carry versioned fingerprints, conservative three-way conflict handling, dry-run support, transactional apply/rollback, and explicit overwrite behavior.
- Added
angee service update <name> --template [--input K=V] [--dry-run] [--overwrite], RESTPOST /services/{name}/template/update, and GraphQLserviceUpdateFromTemplate. Service manifests merge maps recursively while scalars and lists remain atomic; service identity, workspace binding, and allocations stay stable.
angee stack update --templatenow reconciles the complete stack and chain output, so newly added files such asAGENTS.mdare installed. Barestack updateremains derived-files-only.--overwritereplaces conflicts.angee workspace updatenow actually re-renders its workspace and chain templates. Locally edited rendered files are preserved by default and can be replaced with--overwrite; inner-stack runtime state and allocated ports are retained.- Template state and output writes are now rooted and symlink-aware. Reconcile
operations reject Copier
_preserve_symlinks, verify declared local Source targets before writes, retain fixed destination/source/state capabilities through commit and rollback, revalidate live file and document baselines at apply time, and retain current or previously declared persistent roots during template deletions.
- Stack template merges now refresh the
ingresssection while preserving operator-managed runtime state. - Stack template updates now remove obsolete
docker-compose.yaml,process-compose.yaml, andrun/secrets.envartifacts transactionally, and roll back newly staged persist paths and source clones if reconciliation or state publication fails. - Updated the CI, release, and CodeQL toolchain to Go 1.25.12 and
go-gitto 5.19.1, resolving the reachable standard-library and SSH transport security advisories reported bygovulncheck.
angee stack initnow renders a stack template's declared_angee.chainhosts into the target before the stack's own files, so akind: stackthat chains a project host (e.g.stacks/local→projects/web) produces the host's files overlaid by the stack instead of only the stack's own files. The chain was previously parsed but never rendered. Chain entries share the schema and${...}substitution grammar of workspace chains: each entry'stemplateref andinputsresolve against the stack's inputs; a../../projects/webref resolves relative to the stack template while astacks/<name>ref resolves through the template search path; and an optionalroot/chain_rootplaces a host in a sub-directory (empty root overlays in place). An unresolved input reference now fails loudly instead of being written through verbatim.
- A flat chain overlay (a chain entry with no
root) shares Copier's.copier-answers.ymlwith the stack render that overwrites it, so a laterangee stack updaterefreshes the stack layer but not the chained host layer. Give a chained host its ownrootto keep it independently updatable.
angee stack init(and itsinit --dev/init stackvariants) now fall back to running locally whenANGEE_OPERATOR_URL(or--operator) is set but the operator is not reachable, instead of failing with a raw connection error. Init is a bootstrap command that only renders template files into a new root — the operator for that root often does not exist yet — so a shortGET /healthzprobe decides reachability, and an operator that refuses or does not answer the probe prints a notice on stderr and proceeds in-process. A reachable operator still handles init remotely; an interrupted probe (Ctrl-C) surfaces as a cancellation rather than silently falling back; and runtime commands (up,down,logs, …) are unchanged — they route to the operator and surface its errors as before.
- The GraphQL
serviceEndpointquery now returns alogStreamdescriptor ({ url, target, protocol, token, expiresAt }), reaching parity with the RESTGET /services/{name}/endpointlog_streamfield — a GraphQL consumer can now open a service's live log socket without dropping to REST for the credential. - The operator takes a
--log-backendflag selecting the per-service log streaming backend (defaultephemeral, the dev live-proxy). This realizes theLogStreamerconfig seam: an unrecognized value now fails fast at startup with a clear error instead of silently defaulting. A durable production backend registers behind the same seam once it exists (prodStreamerremains a fail-closed stub).
- Documented the workspace file tools primitive (shipped v0.7.3) that was
missing from the published surfaces: the
angee file get/setCLI in the commands guide, theGET/PUT /filesREST routes andfile/fileWriteGraphQL in the operator-API reference, and a Files row (plus ingress/endpoints, route-token, andonStackSnapshotChangeentries) in the README control-plane table. - Reconciled
docs/proposals/with shipped reality: removed five proposals whose work fully landed in code, docs, and this changelog (compose-project-isolation,ingress-routing-modes,graphql-websocket-transport,stack-snapshot-subscription, and the Hasura-supersededgraphql-nestjs-query-shape); trimmed four partially-shipped proposals to their remaining scope with anImplementation statusheader; and addeddocs/proposals/ROADMAP.mdindexing all open work.
- Operator workspace file tools: a generic, scoped file read/write API for
editing files inside a stack source over the operator, modeled on the secrets
API.
GET /files?source=&path=returns{ path, source, content, etag }andPUT /fileswrites with an optionaletagprecondition (a stale etag is a409 Conflict). Exposed on REST, GraphQL (filequery /fileWritemutation), the typed client (FileRead/FileWrite), and the CLI (angee file get/angee file set). Paths are confined to the source root (traversal and symlink-escape are rejected) and content is treated as UTF-8 text within a 1 MiB cap.
- Introduced an
internal/storesubstrate: a minimal generic key→bytesStore(modeled on Vault'sphysical.Backend) with optional capability interfaces (Lister/Deleter/Versioned) discovered by type assertion, plus a backend registry. The newlocalfsbackend (atomic temp+rename writes and compare-and-set etags) backs the file tools; env-file and OpenBao are now registered as store backends. Existing secret behavior is unchanged.
- Dynamically-provisioned service build contexts are now installed at
<root>/services/<name>/instead of<root>/.angee/services/<name>/. The stack root is already the control directory (ANGEE_ROOT, normally.angee, and always.angeefor a workspace inner-stack), so the old path double-prepended.angeeand the build context landed at.angee/.angee/services/<name>/— an exception to the convention thatworkspaces/,run/,sources/, and the generated compose files all sit directly under the root. The build-context writer, the destroy cleanup, and thebuild.contextcontainment validator now useservices/<name>/.- Service template contract change: rendered services must now declare
build.context: ./services/<name>/docker(previously./.angee/services/<name>/docker). Update service templates in lockstep with this release; existing provisioned stacks keep building on the old doubled path until re-provisioned (the orphaned directory is gitignored and harmless).
- Service template contract change: rendered services must now declare
angee workspace create --syncreconciles a worktree left behind by an earlier create that failed after materializing it: the leftover worktree is removed and re-added — gated behind--syncbecause it discards the leftover's contents — instead of failing withalready exists and is not empty. A stale "missing but already registered" worktree (no working tree to lose) is reclaimed automatically, without--sync. Exposed on the CLI (--sync), REST, and GraphQL (workspaces_insert_input.sync/WorkspaceCreateInput.sync) surfaces.
angee workspace createis now transactional: a create that fails after materializing git worktrees (e.g. an invalid--ttlor a template render error) rolls those worktrees and the workspace directory back, instead of stranding "missing but already registered" worktrees that block the next create. A pre-existing leftover directory is left untouched on failure.
- The operator GraphQL surface was reshaped to the Hasura dialect (Angee now
standardizes its consoles on Hasura) so the off-the-shelf
@refinedev/hasuradata provider drives an Angee console with no custom mappers. Clean cut, no backward compatibility — GraphQL clients must update. (The intermediate nestjs-query shape from earlier in this changelog is superseded.)- Console config required:
namingConvention: "hasura-default"andidType: "String"(the operator's keys are names, surfaced asid). services,jobs,sources,workspaces,templates,secretsare Hasura tables:<t>(where: <t>_bool_exp, order_by: [<t>_order_by!], limit, offset): [Row!]!,<t>_by_pk(id: String!), and<t>_aggregate(where) { aggregate { count … } nodes }(sources_aggregateaddssum/avg/min/maxoverahead/behind).- Filtering uses Hasura
where(_and/_or+String_/Boolean_/Int_comparison_expwith_eq _neq _gt _gte _lt _lte _in _nin _like _ilike _nilike _is_null …); sorting uses theorder_byenum; paging uses top-levellimit/offset. - CRUD for
services/secrets/workspacesis Hasura single-row:insert_<t>_one(object: <t>_insert_input!),update_<t>_by_pk(pk_columns, _set),delete_<t>_by_pk(id). All prior verb/CRUD mutations (serviceCreate/secretSet/workspaceCreate/the nestjs*One) are removed. - Realtime: per-table live-query subscriptions
(
subscription { services(where, order_by, limit, offset) { … } }, etc.), backed by a per-subscription poller, so refine live mode works. - Lifecycle, git, token, log, and
workspaceSource*operations stay as custom GraphQL operations (consumed via refine custom hooks);GitOpsTopology.*,WorkspaceStatus.*, andStackSnapshotremain plain views/lists. - The generic in-memory
internal/queryengine (filter/sort/paging/aggregate) is reused; theservice.APIpush-down stays, so the REST surface still filters via?query=<json>→{nodes,total_count}(REST contract unchanged). - Grouped aggregation (NDC typed-key shape): alongside the ungrouped
Hasura
<t>_aggregate, theservices_groups/sources_groupscustom roots mirror thestrawberry-django-hasuragrouping contract exactly, so one shareduseGroupBy/useFacetshook (refineuseCustom) drives the operator and the Django<model>_groupsbackend through a single shape. Each group pairs a typedkey(one field per selected dimension) with the FREE<t>_aggregate_fieldsaggregate — the same type<t>_aggregateexposes, no reshape. The roots takegroup_by: [<t>_group_by_spec!]!(a{ field, granularity }spec mirroring the upstreamGranularityenum for structural parity; the operator has no granular dimensions, so a non-null granularity is rejected),where(pre-group filter),having(a predicate over the aggregate measures:count/sum_*/avg_*/min_*/max_*with the eightgt,lt,lte,gte,eq,neq,in,not_incomparisons),order_by([<t>_group_order!], by a dimension or measure alias), andlimit/offsetpaging — all applied in-memory over the materialized engine groups. This replaces the earlier interim{ dimensions: [KeyValue!]!, aggregates }shape; the shape now tracks the Hasura v3/DDN group-by direction (graphql-engine #10786). Preview/non-stock: the stock list/aggregate/CRUD SDL is unaffected (grouping is additive).
- Console config required:
- The operator GraphQL schema is exported as SDL to
docs/public/angee.graphql(make graphql-schema, also produced bymake generateand checked bymake check-generated) for frontend codegen; live introspection at/graphql. - A contract test (
internal/operator/hasura_contract_test.go) validates the exact documents@refinedev/hasuragenerates (getList/getOne/getMany/create/ update/deleteOne/aggregate/subscription + all filter operators,idType:String) against the executable schema — a regression-guarded consumability guarantee.
- Bulk
*ManyHasura mutations (insert_<t>/update_<t>/delete_<t>withaffected_rows/returning, i.e. refine's createMany/updateMany/deleteMany); the single-row variants cover refine's core create/update/delete.
-
The Docker Compose project name is now derived from the absolute stack root instead of the manifest
name:verbatim. The project name is a daemon-global namespace keying container, network, and volume names, butname:is a non-unique display label — every dev workspace defaults to the same example name. Two stacks sharing aname:were merged into one Compose project even across different roots and operator daemons, so the second to come up clobbered the first's containers (seen as a cross-wired edge closing the agent WebSocket with1006).stack.Namestays the friendly label for the API, gitops, and console; containers now read likenotes-angee-1a2b3c4d-edge-1(readable base, bounded to 30 chars, plus a stable 8-hex per-root suffix). No other compose path is affected — every subcommand resolves the project from the rendered file'sname:.Upgrade note: the rename re-namespaces a stack's containers, networks, and volumes; the old project's containers linger as orphans after the first
up. Runangee downwith the old binary before upgrading to clean them up. App data persisted via bindpersist:subpaths under the stack root is unaffected; any genuinely named Docker volume would be recreated empty.
- The per-service log socket (
GET /services/{name}/logs/stream) accepts?tail=<n>(alias?n=<n>) to replay the lastnstill-available lines before the live follow begins — mapped to the compose / process-compose--tailflag. The value is clamped to[0, 10000];0, a missing param, or a non-numeric value defers to the backend's default backlog.
- Live per-service log streaming. The operator serves a WebSocket per service at
GET /services/{name}/logs/streamthat streams structuredLogLineframes (service,runtime,message, best-effortlevel) as the runtime produces them. Because each socket is scoped to one service, attribution is exact — no log-prefix parsing.GET /services/{name}/endpointadvertises the socket in alog_streamdescriptor (resolved URL,targetofoperator/edge/production, and a short-lived minted route token) so a consumer connects without minting its own credential. Auth runs in the handshake (per-service route tokenaud=svc:<name>or the admin/operator tier) behind the sameOriginallowlist as the GraphQL WebSocket. In development the operator proxies the live follow stream ephemerally (no persistence); a pluggable production backend is stubbed for a future durable store.
- Follow-mode logs now actually stream. The compose and process-compose backends
buffered
logs --followviaCombinedOutput/cmd.Run, delivering nothing until the stream was torn down — which also meant theonServiceLogs/onWorkspaceLogsGraphQL subscriptions never emitted live. A line-by-line streaming runner (and concurrent cross-backend fan-in for whole-stack follows) fixes both. ingress.portis now rejected unlessingress.type: caddyandingress.tls: off. It only affects the plain-HTTP edge, but a port set undertls: auto(ortype: none) was silently accepted as a dead no-op; it now surfaces as a validation error, matching the existing mode-mismatch rejections forroute.host/route.path. Also documented the field in the manifest guide.
ingressgained an optionalportfield for the plain-HTTP dev edge. Withtls: off, the Caddy edge previously always published:80, so two stacks on one host (e.g. parallel dev workspaces) both tried to bind:80and the second failed. Settingingress.port: <n>maps the chosen host port to the edge's container:80, letting each stack bind a distinct edge port; the consumer route URL carries the same port (ws://<host>:<n>/…) whenever it is non-default. The prod (tls: auto) edge keeps the standard443/80and ignores the field. The field validates to1–65535in both the Go loader and the published JSON schema.
onStackSnapshotChangeGraphQL subscription. A single subscription emits the aggregate stack snapshot (stackStatus, services, jobs, sources, workspaces, templates, secrets,gitOpsTopology, health) whenever the daemon-polled aggregate's hash changes, so the web console can drop its client-side snapshot poll. One server-side poller (the existing 2 s hash-and-publishEventHub) serves every subscriber; secret values are never part of the payload.
- Template rendering no longer HTML-escapes manifest and config values. The
copier-goengine (pongo2) shipped with autoescape on by default, so a rendered value containing",<,>, or&came out as"/&— corrupting non-HTML output such as a quotedangee.yamlvalue, which then failed to parse. Bumpedgithub.com/fyltr/copier-goto the build that disables autoescape globally to match Jinja2 / Python Copier semantics (templates that genuinely emit HTML can still opt in with{% autoescape on %}). scripts/install.shno longer calls the GitHub REST API to resolve the latest release. The unauthenticated API is rate limited to 60 requests/hr/IP, so on shared or CI networks (or after a few retries) the lookup returned an empty version and the installer silently fell back to building from source — which fails outright when Go isn't installed. It now downloads straight from…/releases/latest/download/, which needs no API call and isn't rate limited; building from source is now only a fallback when the binary download itself fails. Also fixed the release workflow's version pin, which stampedANGEE_VERSION=v0.5.14and produced a doubledvv0.5.14download URL.
ingressgained two edge-routing controls.ingress.routingselects how the Caddy edge matches inbound requests:host(default, unchanged) gives one subdomain per service (wss://<service>.<domain>/), whilepathputs every routed service under one shared host with a stripped prefix (wss://<domain>/<service>/, override per service withroute.path).ingress.tls: offdrops the edge to plain HTTP so URLs becomews://…and the edge publishes only:80. Together (routing: path,tls: off,domain: localhost) they give a zero-setup local dev path —ws://localhost/<service>/with no wildcard DNS and no local-CA cert trust — while the defaults keep existinghost/autostacks byte-identical. The same per-serviceforward_auth → /edge/verifyauth model applies in both modes.
angee devnow streams logs from every service regardless of runtime. It previously ran container services detached (docker compose up -d) and let process-compose own the foreground, so a mixed stack's container/agent logs never appeared.devnow brings both runtimes up attached and concurrently —docker compose up(no-d) interleaving its native per-service coloured output with the process-compose stream — and a shared context ties them together so Ctrl-C (or either backend exiting) shuts the other down cleanly. Newangee dev -d/--detachstarts the whole stack in the background and returns.
service createnow declares the secrets its service references. A service template is (correctly) forbidden from declaringsecrets:itself, but a service legitimately references${secret.NAME}in its env/command — so the operator now declares those referenced secrets in the stack on the service's behalf, as plain external entries whose value comes from the secrets backend (e.g. a priorsecretSet). Previously such a reference failed the post-create compose re-render withsecret "…" is not resolved(only declared secrets are resolvable), which blocked agent provisioning whose per-agent token secret is set via the REST API rather than declared in a stack template. Referencing a secret grants no value: resolution still fails if none was ever set.
angee stack update --templatenow honours a workspace's allocated ports when re-rendering a workspace's inner stack. Previously the re-render sourced its inputs solely from the inner stack's frozen.copier-answers.yml; if that file drifted to template defaults (e.g. a stray direct re-render reset it), the update silently baked the default ports into the inner stack, colliding with the host stack and other workspaces. The update now detects a managed workspace inner stack (<root>/workspaces/<name>/<chain_root>) and overlays the authoritative${alloc.*}-derived port inputs from the parent stack'sworkspaces.<name>.resolved.allocationsrecord. Only allocation-bearing inputs are reconciled — project/source inputs still come from the answers file, so a stale workspace record cannot repoint them — and the answers' already-resolved path inputs are reused verbatim instead of being re-resolved.
- The repository is now
ang-ee/angee-operatorand the Go module path isgithub.com/ang-ee/angee-operator(wasgithub.com/fyltr/angee). Install withgo install github.com/ang-ee/angee-operator/cmd/angee@latest/.../cmd/angee-operator@latest. Theangee/angee-operatorbinary names are unchanged. GitHub redirects the old repo URL, but update any pinned imports or clone URLs.
- Extracted
service.API, one exported interface that is now the single control-plane contract. Both the in-process*service.Platformand a remote HTTP client implement it, and the CLI, REST operator, and GraphQL operator all dispatch through it — replacing the private, hand-maintainedcli.platformClientinterface (deleted) that had drifted from the concrete type. The remote client moved out ofinternal/cliinto a newinternal/platformclientpackage (platformclient.RemoteClient), paired with the contract it implements. Compile-timevar _ service.APIassertions on both implementations keep them in lockstep. - Closing remote gaps so the contract is uniform across transports:
- Interactive
angee init/stack initnow works against--operator: the prompt set is derived from theTemplate(ref)descriptor (served over local, REST, and GraphQL alike) instead of a local-only call. Template input descriptors gain aquestionfield distinguishing answerable Copier questions from generated/immutable metadata inputs. angee up/angee devagainst--operatornow stream the combined foreground output via newGET /stack/up/streamandGET /stack/dev/streamroutes, matching local behavior.- New REST routes
GET /services/{name}/endpointandGET /ingress/statusgive the previously GraphQL-only ingress lookups full REST parity.
- Interactive
angee stack update --templatere-rendersangee.yamlfrom the stack's Copier template before regenerating the derived runtime files, so template changes (a new service, job, port, or source) reach an already-initialized stack — parity withworkspace update. The merge refreshes template-origin sections (template wins for keys it emits) while preserving user-added keys, allocatedportsvalues, and operator-managed state (operator,workspaces,port_leases).--dry-runprints the additions/refreshes (+/~per section) without writing. Runs locally; requires the stack's.copier-answers.yml. Defaultstack update(derived-files-only) is unchanged. Full 3-way conflict detection for locally-edited template keys is a follow-up.
- New optional
ingressbackend, selected bytypeand defaulting tonone(today's host-published-ports behavior). Withingress.type: caddy,Compileinjects a singlecaddy-docker-proxyedge into the compose file: routed services (those with aroute:block) drop their host ports, join a private<name>_edgenetwork, and get stamped with Caddy router labels; only the edge publishes a host port. Aroute:on aruntime: localservice is rejected, and routed services take nooperator.port_poollease. GET /edge/verify— the operator's forward_auth target for the edge. Reads a route token from?token=/Authorization/Sec-WebSocket-Protocoland verifiesaud=svc:<service>; returns 200/401 (never 101). Not behind the admin-bearer gate.- New GraphQL queries
serviceEndpoint(name)({routed, url, internalHost, internalPort}) andingressStatus({type, domain, routes}), replacing host-side compose-port-scraping. - Manifest validation hardens the edge against Caddyfile injection: under
ingress.type: caddy, routed service names androute.hostare charset-checked and the ingress fields are rejected if they contain Caddy metacharacters, andedgeis a reserved service name. The forward_auth wiring (caddy.forward_auth/edge/verify?service=<name>) and theX-Forwarded-Uritoken read were validated end-to-end against a livecaddy-docker-proxyrun-spike.
- Bumped the CI/release/CodeQL Go toolchain from 1.25.10 to 1.25.11,
clearing the
net/textproto(GO-2026-5039) andcrypto/x509(GO-2026-5037) standard-library advisories govulncheck flagged against the pinned version.
- Fixed the release and CI workflows to use the
ghcr.io/ang-ee/*container image namespace (andscripts/install.shto use theang-ee/angee-operatorrepo) after the GitHub org rename fromfyltrtoang-ee. The v0.5.6 release published its binaries but the docker image push was denied against the oldfyltrnamespace; v0.5.7 re-publishes with images under the correct one. No source changes — the operator/CLI binaries are identical to v0.5.6.
- The operator now mints scoped, audience-bound JWTs and verifies them
centrally.
mintConnectionTokengained ascope: [String!]argument and stampsaud=operator; a newmintRouteToken(actor, service, ttl)issuesaud=svc:<service>route tokens. Both are exposed over GraphQL and REST (POST /tokens/mint,POST /tokens/route). - The operator-API auth middleware is now two-tier: it accepts the admin
bearer (full, server-to-server access) or a minted
aud=operatortoken, binding the token's actor and capability scope to the request context. Existing admin-bearer callers are unchanged. A single sharedVerifyenforces HS256, theangee-operatorissuer, a present expiry, and audience membership, and is reused by the WebSocket transport below. - Together these let a host backend hold the admin bearer server-side and hand the browser a short-lived, capability-scoped token instead of the admin bearer. (Scopes are carried but not yet enforced per-mutation — that follows.)
- Added a
graphql-transport-wsWebSocket transport onGET /graphql, alongside the existing SSE transport, so browser GraphQL clients can run subscriptions over their built-in transport. Authentication happens in theconnection_inithandshake (token inconnectionParams) via the same two-tier check as the HTTP API. A cross-site-WebSocket-hijacking origin allowlist guards the upgrade — loopback and no-Originrequests are allowed; extend it with the repeatable--allowed-originflag. SSE,POSTqueries, and the admin-bearer header gate are unchanged.
- Bumped
golang.org/x/cryptoto v0.52.0, clearing the reachablegolang.org/x/crypto/sshadvisories (GO-2026-5013/5015 and related) that govulncheck flagged on the copier git-over-SSH path. - Removed the VitePress docs build/deploy CI workflow — the docs site no
longer lives in this repo. The
docs/markdown remains as source.
StackStatusnow reports real runtime state for process-compose services too (previously they always came back as"declared").proccompose.Backend.Statusqueries the supervisor viaprocess-compose list -o jsonand returns the literal status string lowercased (running,completed,pending, …). If the supervisor is offline or the control port is wrong, services fall back to"declared"exactly as before. Readiness probes (is_ready: Ready) also surface as ahealthyhealth verdict on the response.runtime.Backend.Statusnow takes aruntime.StatusRequest(Root+ControlPort) instead of a bare root. This is an internal interface change; CLI, REST, GraphQL surfaces are unchanged.
- Simplified the IPv6
EAFNOSUPPORTcheck inhostPortUnavailableto satisfy staticcheck S1008. No behavioural change; the port allocator still treats hosts without IPv6 as having[::]ports available.
StackStatus(RESTGET /stack/status, GraphQLstackStatus, CLIangee stack status) now reports the real runtime state of each service by querying the runtime backends, instead of always returning"declared".ServiceList/angee service listinherit the fix. Services that haven't been brought up still report"declared"; brought-up services report docker's literal state (running,exited,created, …). Backend query errors are swallowed and fall back to"declared"so partial-stack scenarios stay useful.api.ServiceStateand the GraphQLServiceStatetype gained ahealthfield that mirrors docker's healthcheck verdict (healthy,unhealthy,starting). Empty when the container has no healthcheck declared or the service has not been brought up. Process-compose services don't surface a health verdict yet — the field stays empty for them until the proc-composeStatusimplementation lands.
- New
service upverb across all surfaces (CLIangee service up <name>..., RESTPOST /services/{name}/up, GraphQLserviceUp(name: String!)). Maps todocker compose up -d/process-compose up, so it is idempotent across never-created services.angee service create --startnow goes throughServiceUpinstead ofServiceStart, so fresh services boot correctly on first try whilestart/stop/restartkeep their literal docker compose semantics. - Port allocations skip ports the host is already listening on. The
allocator probes
0.0.0.0and[::]for each candidate; ports in use are skipped sodocker compose updoes not later fail withaddress already in use. The IPv6 probe treatsEAFNOSUPPORTas "available" so hosts without IPv6 are unaffected.
angee-operator --versionnow prints the build version, stamped at release time via the same ldflags pipeline that already droveangee --version.
-
Workspaces are now a pure file primitive. The operator no longer owns workspace service lifecycle; an
angee workspaceinstance only renders Copier output (including any chained inner-stack templates as files) and materialises sources. Removed:Platform.WorkspaceStart/WorkspaceStopangee workspace start|stop|restartCLI subcommands--startflag onangee workspace createPOST /workspaces/{name}/start|stop|restartREST endpointsworkspaceStart/workspaceStop/workspaceRestartGraphQL mutationsstartfield on the GraphQLWorkspaceCreateInputand onapi.WorkspaceCreateRequest_angee.chain_lifecycletemplate metadata field and the correspondingWorkspaceResolved.Lifecycle/api.WorkspaceRef.Lifecycle/api.WorkspaceStatusResponse.Lifecyclefields (and theWorkspaceStatus.lifecycleGraphQL field)
If a workspace renders an inner stack and you want to bring it up, drive it explicitly with
angee stack up --root workspaces/<name>/.angee(or point a second operator at that root with--root/--port)._angee.chainand_angee.chain_rootcontinue to work exactly as before — they only describe what to render and where.
- Closed the CLI parity gap for the operator surface. Every operation
that was REST + GraphQL-only now has a CLI subcommand:
angee gitops topology [--with-commits N],angee source diff <name>,angee workspace preflight,angee workspace source {fetch,pull,push,diff,merge,rebase,merge-abort,rebase-abort,rebase-continue,publish},angee template {list,get},angee token mint. The surface matrix indocs/reference/surfaces.mdnow readsYesin the CLI column for every Platform method. Subscriptions remain GraphQL-only by design (REST/CLI have no native pubsub). - Added template-based service creation.
angee service create --template <ref> --workspace <name>(RESTPOST /services/create, GraphQLserviceCreate(input)) renders a Copier template with_angee.kind: serviceinto the outer stack as onemanifest.Serviceentry. Templates declarename_patternandensureport pools; the operator resolves the service name from the workspace name + caller inputs, allocates ports under ownerservice/<name>/<pool>, installs other rendered files (typicallydocker/) at<root>/.angee/services/<service_name>/, and strictly rejects anything outsideservices:in the rendered output. On render failure the port leases are released and the build context is removed.service destroyis updated to release the service-prefixed leases and delete the build-context dir. - Added secret CRUD over REST + GraphQL + CLI. Five operations
(
secrets,secret(name),secretValue(name),secretSet,secretDelete) backed by the existingsecrets.Backendinterface so both env-file and OpenBao deployments work without changes. The value read (secretValue/GET /secrets/{name}/value/angee secret reveal) is a dedicated endpoint so the privileged path is obvious in audit logs and code review.secrets(list) only returns declared secrets (entries instack.secrets); set/delete accept any name matching^[A-Za-z0-9._-]{1,256}$. Mutating calls log to operator stderr (env-file's audit trail; OpenBao continues to own its own audit log). - Closed the REST/GraphQL parity gap. Every operation added in this
release now has a REST endpoint alongside the GraphQL surface, secured
by the same admin-bearer middleware as the rest of the operator API.
New endpoints:
GET /gitops/topology[?with_commits=N],GET /sources/{name}/diff[?ref=...],GET /workspaces/{name}/sources/{slot}/diff[?ref=...],POST /workspaces/{name}/sources/{slot}/{fetch,pull,push,merge,rebase,merge-abort,rebase-abort,rebase-continue,publish},POST /workspaces/preflight,GET /templates,GET /templates/{ref...},POST /tokens/mint. Subscriptions remain GraphQL-only by design (REST has no native pubsub). - Added a GraphQL
Subscriptionroot over Server-Sent Events. Four operations are live:onGitOpsTopologyChange,onWorkspaceStatusChange,onServiceLogs,onWorkspaceLogs. Snapshot subscriptions poll the underlying query on a 2 s tick and publish only when the result hash changes; log subscriptions ride the existing runtime-backend follow channel. The transport isPOST /graphqlwithAccept: text/event-streamper gqlgen 0.17. - Retired the unused
GET /eventsSSE stub (only ever emitted a staticreadyevent). Live event streaming is now exclusively via GraphQL subscriptions on/graphql. - Added
workspaceCreatePreflight(input)mutation that validates aWorkspaceCreateInputagainst the resolved template's input declarations without materialising a workspace. Returns the effective inputs (after defaults), amissingRequiredlist, and aninvalidInputslist of{field, reason}for type-mismatch failures. - Added
mintConnectionToken(actor, ttl)mutation that issues an HS256-signed JWT scoped to the supplied actor. Signing key resolves in this order: explicit--jwt-secret/ANGEE_OPERATOR_JWT_SECRET, then HKDF-derived from the admin--token, then a per-process random fallback for loopback dev. TTL defaults to 1 h, capped at 24 h. - Added template-descriptor introspection:
templates: [TemplateDescriptor]!walks<root>/.templates/<kind>/*and<root>/templates/<kind>/*and returns descriptors withkind,name,path, and per-input metadata.template(ref)returns the single descriptor for an explicit ref (relative path, absolute path, or supported remote URL). - Added commit-DAG fields on
GitOpsTopology:gitOpsTopology(withCommits: Int)populatessources[].commitswith{sha, parents, refs, time, summary, author}for each git source, capped atwithCommits. Default value is 0 so the polling subscription stays cheap; clients opt in for the DAG renderer view. - Added
sourceDiff(name, ref)andworkspaceSourceDiff(workspace, slot, ref)queries that return unified-diff[DiffFile{hunks: [DiffHunk]}]payloads parsed viabluekeyes/go-gitdiff.refempty means uncommitted (working-tree-vs-HEAD); otherwise it diffs against the named revision. - Added higher-level convergence mutations on workspace source slots:
workspaceSourceMerge(ref),workspaceSourceRebase(ref),workspaceSourceMergeAbort,workspaceSourceRebaseAbort,workspaceSourceRebaseContinue, andworkspaceSourcePublish(remote, branch). Each returns aGitOpResultcarrying{ok, conflicted, conflictFiles, message}. On conflict the worktree is left in the conflicted state for the caller to resolve; conflict files come fromgit ls-files -urather than parsing free-form stderr. - Added integration tests covering behind/diverged worktrees, dirty
worktrees blocking pull/push, missing workspace-source paths, and
undeclared source references in
gitOpsTopology.
- Added the bundled
templates/agent-runtime/workspace template with a documented env-var contract:AGENT_ID(required),MCP_URL/MCP_TOKEN(optional, caller-supplied),ACP_PORT(allocated from the host stack'sacpport pool),ACP_TOKEN(resolved via${secret:acp_token}). v1 ships a placeholder service that prints the contract and sleeps; downstream consumers (e.g. the angee-djangoagentsaddon) replace the command block with a real runtime invocation. Contract documented indocs/guide/templates.md.
github.com/golang-jwt/jwt/v5@v5.3.1(MIT) formintConnectionToken.github.com/bluekeyes/go-gitdiff@v0.8.1(MIT) for the diff queries.
- Moved the stack-teardown shutdown trigger from SIGHUP to SIGINT (Ctrl-C in a foreground operator, or what process-compose forwards when a dev stack is interrupted). SIGTERM still performs a graceful HTTP shutdown only, and SIGHUP is back to its default disposition. This supersedes the SIGHUP-based behavior shipped in v0.4.11.
- The operator now treats SIGHUP as "shut down and bring the local stack
down with you": after the HTTP server has shut down it runs
platform.StackDown, terminating docker-compose services and process-compose-managed local processes. SIGINT and SIGTERM keep their prior behavior of stopping only the HTTP server. (Superseded by v0.4.12.)
- Replaced the docs logo with the angee-django isometric cube SVG and rendered it as the homepage hero image.
- Reframed the homepage and
Conceptspage around the engine / Host boundary: theangeeCLI + operator is the stack manager; the Host runtime (todayangee-django) composes Blocks into a working app on top. - Added
docs/guide/concepts.mdcovering Stack, Source, Workspace, Service, Host, and how self-building works end-to-end.
- Stood up a VitePress site under
docs/published at docs.angee.ai via GitHub Pages (.github/workflows/docs.yml). Existing markdown moved todocs/guide/anddocs/reference/; internal design notes moved to.agents/notes/. - Added prebuild scripts that render
internal/operator/schema.graphqlintodocs/reference/graphql/anddocs/public/angee.schema.jsonintodocs/reference/manifest-schema.mdon every site build. - Moved the canonical manifest schema to
docs/public/angee.schema.jsonso it is served at https://docs.angee.ai/angee.schema.json;cmd/schemanow stamps$idaccordingly. - Reworked repository documentation to describe current implemented behavior.
- Moved changelog material to this root
CHANGELOG.md. - Documented the current CLI surface, manifest schema, operator API, template resolver, and development commands.
- Added
docs/reference/surfaces.mdenumerating everyservice.Platformmethod and its CLI / REST / GraphQL exposure (R5). - Generated
docs/public/angee.schema.jsonfrom the manifest types and documented editor LSP integration via# yaml-language-server: $schema=...(R8). - Added
make schematodocs/guide/development.md(R8).
- Replaced the hand-built
graphql-go/graphqlschema (~1100 LoC) with a schema-first99designs/gqlgenresolver layer rooted atinternal/operator/schema.graphql. Body-size, content-type, log-output limiting, and the route-level cross-origin protection are preserved (R2). - Added a
make check-generatedstep that runs gqlgen and fails on out-of-dateinternal/operator/gql/(R2). - Added typed domain errors (
*service.NotFoundError,*service.ConflictError,*service.InvalidInputError) with consistent REST (404 / 409 / 400), GraphQLextensions, and CLI status preservation. The remote CLI client now decodesapi.ErrorResponseand returnserrors.As-able typed errors instead of flattening every non-2xx to a single string (R6). - Added GraphQL surface coverage for
gitOpsTopology, workspace sourcefetch/pull/push, andWorkspaceLogsLimited/StackLogsLimited(gaps identified by R5).
angee workspace start|stop|restart|logsaccept an optional[name]when run from insideANGEE_ROOT/workspaces/<name>, matchingstatusandsync-base(R10).- CLI no longer string-matches error messages to classify failures; uses
errors.Asagainst typed domain errors (R6).
- Migrated read-only git operations (
RefExists,CurrentBranch,CurrentRef,Upstream,AheadBehind/AheadCount,Config,Remotes,Dirty) togo-git/v5. Network, worktree, and merge/rebase ops continue to shell out to thegitCLI; the boundary is documented in the package doc-comment (R1a). - Native
gitCLI fallback for read-only status calls when go-git cannot parseextensions.worktreeConfig, so workspacebranch-mismatchis based on actualcurrent_ref != branchrather than a parse failure (R10). - Process-compose runtime targets now carry a control port; the backend
passes
--address 127.0.0.1 --port <port>forup,down,start,stop,restart, andlogs. Root stacks default to8080; rendered stacks declareports.process_compose.value. Workspace lifecycle and log commands inherit the rendered inner-stack port (R10). - Workspace JSON, REST responses, GraphQL, and CLI text status expose
process_compose_port,playwright_mcp_name, andplaywright_mcp_url(R10). dev-pranddev-pr-multiworkspace templates render${workspace.path}/.angee/data/chromeas the Playwright--user-data-dir, isolating browser state per workspace; DjangoANGEE_DATAcontinues to live at{{ ANGEE_ROOT }}/data(R10).- Application asset loader adopts an existing target row when exactly one matching unique field is present and creates the missing ledger row, instead of attempting a duplicate insert (R10).
- Hoisted root discovery into
internal/stackroot.Resolve; CLI, doctor, and operator now share one walk-up implementation (R4). - Promoted
JobRunRequestandWorkspaceUpdateRequestto the sharedapi/package; deleted three sets of duplicated inline structs across CLI and operator (R3). - Split
Stack.Defaults()(mutating) fromStack.Validate()(pure) and introducedgo-playground/validatorfor struct-tag constraint checks (R8). - Replaced
sortedServiceStates,sortedJobStates, andsortedWorkspaceRefsininternal/operator/graphql.gowith a single genericsortedMapValueshelper (R9).
- Evaluated
compose-spec/compose-go v2against the local Compose model and decided to keep the local minimal model. The container runtime renders exactly the fields needed by current manifests and templates; revisit when a template needs an unsupported field (R7). Tracked in.agents/plans/LATEST.mdMigration 3. - Deferred the full go-git migration (network / worktree / merge) until
upstream parallel-checkout (go-git/go-git#1956) lands and credential-helper
parity is designed (R1b). Tracked in
.agents/plans/LATEST.md.
- Operator: added GitOps topology API (
#10).
- CLI: improved
ANGEE_ROOTdetection. - Workspaces: stopped creating absolute symlinks during materialization.
- CLI:
angee workspace statusnow infers the target workspace from the current working directory when invoked without a name. - Documentation refresh.
- CLI:
angee workspace statusreports full source state, including branch-mismatch detection (#8).
- CLI: added
angee doctorfor environment diagnostics (#7).
- CLI: added
angee workspace open(#6).