App deploy and OTA path¶
Two genuinely distinct mechanisms live under this one heading, and the diagram keeps them separate rather than forcing a false merge:
- Path A -- container deploy-on-land: rebuilding the hub/memory containers themselves after a PR lands, gating task verification on a successful health check.
- Path B -- iOS/mac OTA distribution: shipping the companion app's
signed
.ipato Aaron's phone over Tailscale via Apple'sitms-servicesad-hoc install protocol, with no App Store and no Komodo involved.
flowchart TD
subgraph A["Path A: container deploy-on-land"]
LAND["Repo lands\n(see landing queue)"] --> HOOK["DeployHooksForRepo:\nresolve deploy_hook"]
HOOK --> ENQA["Deploys.Enqueue\n(coalesced per hook)"]
ENQA --> HOLD["AttachDeployEvidence:\nunsatisfied deploy_log\non the landed task"]
HOLD --> DROW[("deploys row")]
DROW --> CLAIMA["Host runner:\nRunDeployLoop -> ProcessDeploy\nPOST /deploys/claim"]
CLAIMA --> RUNHOOK["runDeployHook:\n~/.argus/deploys/<hook>\n(deploy/bootstrap.sh, installed once)"]
RUNHOOK --> FETCH["bootstrap.sh: fetch main,\nreset dedicated detached worktree\nto origin/main"]
FETCH --> REEXEC["bootstrap.sh execs the worktree's\nOWN deploy/argus.sh (read fresh,\nnever a hand-copied snapshot)"]
REEXEC --> DRIFT{"argus.sh drift guard:\nself checksum ==\norigin/main deploy/argus.sh?"}
DRIFT -->|no| FAILDRIFT["deploy FAILED: drift detected\n(fails loud, never silent)"]
DRIFT -->|yes| BUILD["docker compose\nbuild/up -d hub memory"]
BUILD --> HEALTH{"/healthz recovers\nwithin 60s?"}
HEALTH -->|yes| COMPLETEA["POST /deploys/{id}/complete\nsuccess"]
HEALTH -->|no| COMPLETEB["POST /deploys/{id}/complete\nfailure"]
COMPLETEA --> SATISFY["SatisfyDeployEvidence:\nclears held evidence"]
SATISFY --> SWEEPA["items.VerifySweep:\ntask becomes verified"]
SATISFY --> RESOLVEA["auto-resolve\nopen deploy_fail alert"]
COMPLETEB --> PAGEA["push.PushAlert +\ndeploy_fail critical alert"]
DROW --> STALEA{"stuck running\npast stale threshold?"}
STALEA -->|yes| REAPA["ReapStale: fail the deploy"]
end
subgraph B["Path B: iOS/mac OTA distribution"]
MACSESS["Mac session exports\nsigned .ipa"] --> UPLOAD["POST /app/upload?version=\nhandleAppUpload"]
UPLOAD --> ROTATE["mint fresh capability token\n(?k=), old links die"]
ROTATE --> PUSHUP["push.PushUpdate:\nAPNs 'Argus vX available'"]
PUSHUP --> DEVICE["device receives push\nwith install_url"]
DEVICE -->|"push missed"| POLL["GET /app/latest.json\nhandleAppLatest (bearer-gated)"]
DEVICE -->|"tap-through"| BROWSE["GET /app?k=...\nhandleAppPage"]
POLL --> BROWSE
BROWSE --> GATE1{"AppDist.gate:\n404 before token check,\nthen constant-time compare"}
GATE1 -->|fail| DENY["404, no oracle"]
GATE1 -->|pass| PLIST["GET /app/manifest.plist?k=\nhandleAppManifest (itms-services)"]
PLIST --> IPA["GET /app/argus.ipa?k=\nhandleAppIPA"]
IPA --> INSTALL["Safari installs\nthe app"]
end
Node annotations: Path A (container deploy)¶
DeployHooksForRepo¶
internal/hub/deploys.go:DeployHooksForRepo (L415). Resolves which
project's deploy_hook a just-landed repo maps to; called from the land
path once a PR reaches landed (see landing queue).
Deploys.Enqueue¶
internal/hub/deploys.go:Enqueue (L74). Inserts a deploys row, coalesced
per-hook by a coalesceWindow so a burst of landings collapses into a
single deploy run rather than one per PR.
AttachDeployEvidence¶
internal/hub/deploys.go:AttachDeployEvidence (L248). Attaches an
unsatisfied deploy_log evidence row to the landed task -- the task is
held from verified until the deploy actually reports success, closing the
gap between "merged" and "actually running."
Host runner claim / runDeployHook¶
handleDeployClaim -> Deploys.Claim (deploys.go:114), exposed as
POST /deploys/claim (server.go:721). The host agent-runner daemon
(has docker/git access the hub container itself lacks, since a container
can't rebuild itself) polls this via RunDeployLoop/ProcessDeploy
(deploy_runner.go:97,125), a lane independent of the agent-run claim loop.
runDeployHook (deploy_runner.go:191) resolves a bare-named script
strictly under ~/.argus/deploys/.
ARGUS-722: which file actually executes. For argus, that resolved file
(~/.argus/deploys/argus.sh) is deploy/bootstrap.sh from the repo,
installed there once, by hand. It is a thin, generic shim: it fetches
origin/main and resets the dedicated argus-deploy detached worktree to
it, then execs that worktree's own deploy/argus.sh -- the real build
logic, read fresh out of the worktree on every single run. deploy/argus.sh
opens with a drift guard: it hashes itself and compares against
origin/main's copy of deploy/argus.sh, and fails loudly (never silently)
on a mismatch. Only past that guard does it run
docker compose build/up -d hub memory (scoped to whichever service
actually changed, see changed-services.sh) and poll /healthz up to 60
seconds.
Before ARGUS-722, ~/.argus/deploys/argus.sh was a hand-copied
snapshot of deploy/argus.sh -- a repo change to the deploy logic reached
git and the argus-deploy worktree but never that executed flat copy.
ARGUS-700 and ARGUS-720 both merged, went CI-green, and were marked
verified while the stale pre-700 script kept running, unchanged, for over
a day. The bootstrap/re-exec split removes the second copy that could go
stale; the checksum drift guard is the belt-and-suspenders check that
catches it anyway if it ever somehow does.
Complete / branch¶
completeDeployWithRetry -> POST /deploys/{id}/complete ->
handleDeployComplete (deploys.go:308). Success:
SatisfyDeployEvidence (deploys.go:261) clears the held evidence,
triggering an immediate items.VerifySweep so the task becomes verified,
and auto-resolves any open deploy_fail alert. Failure: pushes an APNs
alert (push.PushAlert) and ingests a deploy_fail critical alert
(SignalDeployFailure) -- evidence stays unsatisfied, visibly paging
Aaron. ReapStale (deploys.go:158) fails any deploy stuck running past
a stale threshold if the runner process dies mid-deploy.
Node annotations: Path B (OTA distribution)¶
Upload¶
A Mac session exports a signed .ipa and calls
POST /app/upload?version=... -> handleAppUpload
(internal/hub/app_dist.go:83). An atomic write (tmp file + rename) avoids
serving a half-written binary; a fresh 256-bit capability token is minted on
every upload, so old install links die immediately.
Push announce¶
On successful upload, push.PushUpdate
(internal/hub/push_notifier.go:144) fans an APNs push ("Argus vX
available -- tap to install") to registered devices, carrying the live
install_url -- see notification/push fan-out
for the shared delivery plumbing.
Foreground poll fallback¶
GET /app/latest.json -> handleAppLatest (app_dist.go:156),
bearer-gated, exists for the case where a background push never arrived or
was missed.
Tap-through / gate / manifest / ipa¶
GET /app?k=... -> handleAppPage (app_dist.go:205) serves a Safari page
with an itms-services://?action=download-manifest&url=... link. All three
Safari-facing routes (page, manifest, ipa) are gated by
AppDist.gate (app_dist.go:274): a 404 fires before the token check
even runs (no oracle for whether a version exists), then a constant-time
comparison validates the capability token -- token-gated rather than
bearer-gated, since Safari cannot carry an Authorization header. Safari
then fetches GET /app/manifest.plist?k=...
(handleAppManifest, app_dist.go:232, an XML plist describing the
software-package asset) which points at
GET /app/argus.ipa?k=... (handleAppIPA, app_dist.go:259), serving the
raw .ipa bytes. Routes wired at internal/hub/server.go:526-528,810,813.
Key distinction¶
"Deploy" (Path A) rebuilds the hub/memory services themselves and gates task verification on it. "OTA" (Path B) ships the iOS/mac companion app binary directly to Aaron's device with no App Store, no Komodo, and no code-signing automation in this repo -- signing happens outside the hub, on the Mac, before upload. There is no Komodo reference anywhere in this codebase; the term does not apply to either path here.
Related pages¶
- Landing queue -- the merge event that triggers Path A.
- Notification/push fan-out -- how both the deploy-failure page and the OTA "update available" push are delivered.