Compare commits
235
Commits
v1.801.490
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ca535bae5 | ||
|
|
5bd6be0221 | ||
|
|
e50ec9140a | ||
|
|
f3fa834af7 | ||
|
|
963cc69fd6 | ||
|
|
7f58c1a8a3 | ||
|
|
3d2224fa0d | ||
|
|
518dfb6abd | ||
|
|
1d8fa80903 | ||
|
|
6041b0ef2a | ||
|
|
4dfd6e3b35 | ||
|
|
95880de2ec | ||
|
|
bf52370a6b | ||
|
|
1f1a7c9463 | ||
|
|
53bc2fb48c | ||
|
|
8472a65b88 | ||
|
|
790612ac98 | ||
|
|
5ea1e3e34a | ||
|
|
9eeb1f5a18 | ||
|
|
a3de3352dd | ||
|
|
9a8b66c89a | ||
|
|
aa4aaef792 | ||
|
|
34d915f877 | ||
|
|
9fc843dff3 | ||
|
|
f8ac61dd67 | ||
|
|
af927d7711 | ||
|
|
e5d89f0d92 | ||
|
|
35afb39d95 | ||
|
|
b79ed8b784 | ||
|
|
90386e0296 | ||
|
|
e37be93cb8 | ||
|
|
2daf7ac6b6 | ||
|
|
7861ada4d3 | ||
|
|
d3d3680738 | ||
|
|
378c0a0054 | ||
|
|
a8968c90f5 | ||
|
|
38eba9579c | ||
|
|
fd3341dd87 | ||
|
|
f0f4bc41e3 | ||
|
|
f983a530a5 | ||
|
|
9471b9a1f1 | ||
|
|
29685cb7ec | ||
|
|
30ed726abc | ||
|
|
7870c6e44a | ||
|
|
4c0f7d9387 | ||
|
|
719c77fc2d | ||
|
|
33e075b595 | ||
|
|
69f5639162 | ||
|
|
126ed2590d | ||
|
|
e10f0576a9 | ||
|
|
1f38fcc6ff | ||
|
|
6f82f001c4 | ||
|
|
35543313ce | ||
|
|
a9575db39f | ||
|
|
49e3953982 | ||
|
|
b55f1048fd | ||
|
|
889ebc350b | ||
|
|
6561437af3 | ||
|
|
7d09341e70 | ||
|
|
17c31fd8ca | ||
|
|
7cf9eb986a | ||
|
|
1ebda0ab16 | ||
|
|
7cca2637ec | ||
|
|
c663f04e6e | ||
|
|
bacbe30e52 | ||
|
|
a5ec8df325 | ||
|
|
99216affcc | ||
|
|
7e7d41b029 | ||
|
|
7cd7306ea5 | ||
|
|
0a3ebc2167 | ||
|
|
91305ee65b | ||
|
|
8b704def20 | ||
|
|
32f2150208 | ||
|
|
35209c422c | ||
|
|
f494e9e5f8 | ||
|
|
1d692c7467 | ||
|
|
73c8225230 | ||
|
|
82aaf8230f | ||
|
|
b853fa16ab | ||
|
|
1d84cca4e5 | ||
|
|
3d0d19acea | ||
|
|
1df3fa1cac | ||
|
|
845dae123c | ||
|
|
33c369a422 | ||
|
|
1320ba8009 | ||
|
|
43e9c351fd | ||
|
|
d9ce990109 | ||
|
|
477689538a | ||
|
|
4bbfa596fb | ||
|
|
28a3fbdbba | ||
|
|
9d512d5485 | ||
|
|
c00e1a0fb7 | ||
|
|
2bb253b6af | ||
|
|
627b66714a | ||
|
|
b683451cfb | ||
|
|
8c56920061 | ||
|
|
f6aaf91d45 | ||
|
|
a611fd16f0 | ||
|
|
4aec686b86 | ||
|
|
949560e95e | ||
|
|
28f8873c89 | ||
|
|
c0a0e9136b | ||
|
|
14bdf40d53 | ||
|
|
3e4d98fb6f | ||
|
|
d721be09e4 | ||
|
|
90c9f2dd15 | ||
|
|
2b23d289b4 | ||
|
|
b2ce8891e0 | ||
|
|
33c6640bee | ||
|
|
9673793c2e | ||
|
|
1052d3d1bf | ||
|
|
0de31762e7 | ||
|
|
c33c3d63e9 | ||
|
|
9095395c56 | ||
|
|
0e1accb665 | ||
|
|
ded824b648 | ||
|
|
0c495e1a0c | ||
|
|
7fbafa54aa | ||
|
|
7ba18a2386 | ||
|
|
5131661f59 | ||
|
|
df6b78ffa1 | ||
|
|
91cd1367ae | ||
|
|
864cbca1b1 | ||
|
|
c4e79aa053 | ||
|
|
ab29588949 | ||
|
|
e6374961a7 | ||
|
|
9052430f9f | ||
|
|
8ae00313e0 | ||
|
|
4117fc1486 | ||
|
|
9957aa1a8b | ||
|
|
49a5ecca1a | ||
|
|
fca758d361 | ||
|
|
730724f688 | ||
|
|
ca31fdf159 | ||
|
|
13d88fda56 | ||
|
|
4938b9149e | ||
|
|
b028ab0f07 | ||
|
|
6b23f290b4 | ||
|
|
6dfb79e49a | ||
|
|
a30a6b16e4 | ||
|
|
4840b92d0b | ||
|
|
13ce86d43f | ||
|
|
3c24993000 | ||
|
|
9950d27d97 | ||
|
|
ddd81cdb9b | ||
|
|
f8e1941202 | ||
|
|
86de6b94e6 | ||
|
|
f908ac9213 | ||
|
|
181969ac0f | ||
|
|
155e9503ec | ||
|
|
dd6cd6de25 | ||
|
|
ec5e4876ec | ||
|
|
7d42283fdd | ||
|
|
dbcb9040a0 | ||
|
|
7881feacfc | ||
|
|
a8da2c68f7 | ||
|
|
76223d8df2 | ||
|
|
57b2e41fb7 | ||
|
|
ae49297880 | ||
|
|
f648c17f85 | ||
|
|
ce5714f685 | ||
|
|
26f4b356b7 | ||
|
|
ac2e1d7422 | ||
|
|
67c64cca52 | ||
|
|
398ea586b8 | ||
|
|
d46b85b268 | ||
|
|
c5f8660abb | ||
|
|
3f336bfeeb | ||
|
|
1782107b79 | ||
|
|
cd82ffe0db | ||
|
|
342388347a | ||
|
|
b62869ca97 | ||
|
|
eafbf24a13 | ||
|
|
32a2f9caf1 | ||
|
|
d35bc990f9 | ||
|
|
2c8ff45655 | ||
|
|
3846c7440b | ||
|
|
0d4093193e | ||
|
|
2bcf761f2d | ||
|
|
39c2dc6923 | ||
|
|
70572332b0 | ||
|
|
23a6c6edea | ||
|
|
7772f7fe80 | ||
|
|
6232358a8e | ||
|
|
86c51b1526 | ||
|
|
f4cc9249b8 | ||
|
|
5f7e632dde | ||
|
|
7e34af0a23 | ||
|
|
adf4be5067 | ||
|
|
46c2f1ce53 | ||
|
|
26cfa8e5db | ||
|
|
de97f6dfb7 | ||
|
|
3581728ceb | ||
|
|
182367f235 | ||
|
|
0b5a074a89 | ||
|
|
39a81d08e3 | ||
|
|
067ab86de3 | ||
|
|
597ece7dea | ||
|
|
b17f9e6a60 | ||
|
|
6dbc3b9d2e | ||
|
|
827f3e6692 | ||
|
|
7c50638eb1 | ||
|
|
a1e44d9d96 | ||
|
|
f2b5ee2d15 | ||
|
|
5ff5314d46 | ||
|
|
c4769c7179 | ||
|
|
8465354e6b | ||
|
|
9d9efed301 | ||
|
|
208bc6c1a1 | ||
|
|
9e7a93c65c | ||
|
|
3f4cb0f6c5 | ||
|
|
61667ffcca | ||
|
|
5176fd58df | ||
|
|
857c38747f | ||
|
|
1fa6f964cb | ||
|
|
0e57f39c4b | ||
|
|
fe6a33c65c | ||
|
|
f1ec14c198 | ||
|
|
0dfe88d35c | ||
|
|
19010dbbed | ||
|
|
fb35574098 | ||
|
|
9c4c861384 | ||
|
|
c8a44c7014 | ||
|
|
9d48b131de | ||
|
|
7c49095dca | ||
|
|
5435fe3a05 | ||
|
|
292697bdb7 | ||
|
|
aab3d069f5 | ||
|
|
6c48821537 | ||
|
|
7bee6de9ef | ||
|
|
3b49870bca | ||
|
|
890c96002b | ||
|
|
bf720faee1 | ||
|
|
77e040ef90 | ||
|
|
80b41c1ab8 |
@@ -39,3 +39,23 @@ Thumbs.db
|
||||
# The Dockerfile itself doesn't need to be in the context it builds
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
# Agent worktrees. .claude/ is gitignored — it is a whole SECOND CHECKOUT of this
|
||||
# repository, and COPY . . was shipping it into the image context: 92 MB of a
|
||||
# copy of ourselves, cache-keyed so every agent run invalidated the layer. The
|
||||
# same second-checkout problem the zipdoc gate hit when its walk read 203
|
||||
# packages where there are 104.
|
||||
.claude/
|
||||
|
||||
# Compiled binaries left at the ROOT by a local `make`. /bin/ was already
|
||||
# excluded, but these do not live there — `hanzo` alone is 586 MB, and none of
|
||||
# the three is tracked in git, so nothing in the build can want them. They are
|
||||
# named rather than globbed because a glob here would also catch source.
|
||||
# Measured: context 1087 MB -> 251 MB.
|
||||
#
|
||||
# NOT excluded: `sandboxes`, which IS tracked in git. COPY . . may legitimately
|
||||
# carry it, and a build output that someone committed is a question for that
|
||||
# commit, not something to silently drop from the image.
|
||||
/hanzo
|
||||
/o11y
|
||||
/host
|
||||
|
||||
+170
-125
@@ -63,43 +63,41 @@ on:
|
||||
concurrency:
|
||||
# ONE release at a time, queued not cancelled: the image is pushed early and
|
||||
# the tag and pin come last, so killing a run midway leaves an orphan — ten of
|
||||
# them between v1.801.335 and v1.801.350. Pull requests are a different ref, so
|
||||
# they neither queue behind a release nor hold one up, and a new push to a PR
|
||||
# cancels its own stale run.
|
||||
# them between v1.801.335 and v1.801.350. A pull request gets a slot of its
|
||||
# own, so it neither queues behind a release nor holds one up, and a new push
|
||||
# to it cancels its own stale run.
|
||||
#
|
||||
# cancel-in-progress is a LITERAL false, and the evidence for that is a run
|
||||
# whose gate had already PASSED. Run 895 (41a0e5a3, event push, branch main):
|
||||
# gate, cicd and containment all completed `success` at 09:46:45Z after 39
|
||||
# minutes, and image, rollout, reach, fanout and receipt were then `cancelled`
|
||||
# at 10:25:26Z having never started. The run's own conclusion is `cancelled`.
|
||||
# On a push this expression is supposed to be false, so nothing should have
|
||||
# cancelled anything — the forge does not evaluate it to a boolean, and a
|
||||
# non-empty string is truthy.
|
||||
# Two earlier readings of this are worth not repeating, because both blamed the
|
||||
# setting and the setting was never what acted. One argued cancel-in-progress
|
||||
# was "a literal false the forge cannot evaluate, and a non-empty string is
|
||||
# truthy"; the other argued the cancelled runs "were superseded while still
|
||||
# QUEUED, so cancel-in-progress was never the thing acting on them". The second
|
||||
# is right about the mechanism and neither found the cause, which is one line
|
||||
# lower and is the GROUP, not the flag.
|
||||
# ── THE GROUP MUST NOT BE KEYED ON A VALUE THIS FORGE DOES NOT SET ──────────
|
||||
#
|
||||
# This was landed once as 2f9ffc2d and reverted by 0d7011e2, which argued the
|
||||
# cancelled runs "were superseded while still QUEUED, not killed mid-gate, so
|
||||
# cancel-in-progress was never the thing acting on them". That is true of the
|
||||
# runs it measured (889-892, which never received a runner) and false of run
|
||||
# 895: a run that never started cannot contain a job that completed
|
||||
# successfully. Both failure modes are real; only this one is a line in this
|
||||
# file, and it is the one that throws away work the gate already did.
|
||||
# It was `cicd-${{ github.ref }}`, and act_runner leaves the ref EMPTY: the API
|
||||
# returns head_branch null and no ref field on ordinary pushes to main. So the
|
||||
# group rendered as the constant `cicd-` and EVERY run in the repository — main
|
||||
# pushes, pull requests, tag pushes — shared ONE slot.
|
||||
#
|
||||
# Measured over runs 891-909, every one of them a push to main: 16 cancelled,
|
||||
# 3 failure, 0 success. The one run that earned a release lost it 39 minutes
|
||||
# after the gate went green. That is the whole of "the gate is green and
|
||||
# nothing ships".
|
||||
# That is the mechanism behind the cancellations above, and it is not the
|
||||
# cancel-in-progress setting the paragraphs above blame. Gitea coalesces a
|
||||
# concurrency queue to depth one: an in-progress run is left alone (which is
|
||||
# what cancel-in-progress: false buys), but a QUEUED run is cancelled when a
|
||||
# newer one arrives for the same group. That is correct behaviour — nobody
|
||||
# wants an image per intermediate commit — and it only became destructive
|
||||
# because one constant group meant every pull request coalesced away the
|
||||
# release queued behind it, and vice versa. The tell is in the API: those runs
|
||||
# carry started_at 1970-01-01, so they were never given a runner at all.
|
||||
#
|
||||
# This does not queue without bound. One run holds the group and the rest
|
||||
# collapse into a single pending run, so pushes arriving during a release are
|
||||
# coalesced rather than accumulated — which is what the paragraph above always
|
||||
# meant by "queued, not cancelled".
|
||||
#
|
||||
# The cost, stated plainly: a stale PR run is no longer cancelled by its own
|
||||
# next push, because the setting can no longer ask what event it is. A PR that
|
||||
# is pushed to repeatedly holds more than one run. That is cheap next to a
|
||||
# release lane that cannot finish.
|
||||
group: cicd-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
# Keyed properly, the original intent holds without depending on the ref.
|
||||
# Releases share one slot and queue; each pull request gets its own slot and a
|
||||
# new push to it cancels only its own stale run. cancel-in-progress is now an
|
||||
# expression that evaluates to a real boolean rather than a literal whose
|
||||
# truthiness was being argued about.
|
||||
group: cicd-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'release' }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
# ══ CAR 0 ══ THE DOCUMENT EQUALS THE CODE. Everything below `needs:` this.
|
||||
@@ -143,6 +141,23 @@ jobs:
|
||||
# `inputs.tests != 'false'`, so the `tests:` expression below finally means
|
||||
# what it says. Verified before moving: v1 (8e43277) CONTAINS v1.0.17
|
||||
# (81b3c18), so the set -u default this pin was taken for comes with it.
|
||||
# BACK ON @v1, and not because the alias is right.
|
||||
#
|
||||
# Pinning @v1.0.38 — which the paragraphs above call for — stopped the forge
|
||||
# constructing a run AT ALL: no row, nothing to inspect, exactly the "dead CI
|
||||
# is not red, it is absent" failure described above. A failing `gate` is worse
|
||||
# than a passing one and better than no run, so this is reverted to the state
|
||||
# that at least reports.
|
||||
#
|
||||
# The lane is still broken and was before this: `gate` never gets a runner
|
||||
# (runner_id 0, zero steps, ~32 min, failure) while `containment` succeeds on
|
||||
# the same run against an idle 10/10 fleet. Runs 925, 926 and 928 all died
|
||||
# that way, so no release has minted since v1.801.490.
|
||||
#
|
||||
# Whoever picks this up: the question is why the forge declines to SCHEDULE a
|
||||
# called workflow's jobs while scheduling sibling jobs in the same run, and
|
||||
# why naming an immutable tag prevents run construction when that tag demonstrably
|
||||
# carries .hanzo/workflows/build.yml. Both answers live in the forge, not here.
|
||||
uses: hanzoai/ci/.hanzo/workflows/build.yml@v1
|
||||
with:
|
||||
# Not on a tag: the tag is minted below only after this gate passed on main
|
||||
@@ -331,12 +346,32 @@ jobs:
|
||||
# what makes a tag a receipt for an image that booted.
|
||||
image:
|
||||
needs: [gate, containment]
|
||||
# github.ref arrives NULL on an API rerun (act_runner drops it), which
|
||||
# skipped the image on the first fully green gate pass this pipeline ever
|
||||
# had. A rerun keeps event_name and the workflow only triggers from main
|
||||
# pushes/tags/dispatch, so refuse PRs and accept ref==main OR the rerun's
|
||||
# null-ref shape.
|
||||
if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == '' || github.ref == null)
|
||||
# NAME WHAT IS REFUSED, NOT WHAT IS ALLOWED — because this job's ref is not
|
||||
# reliably knowable and its absence must not read as "no".
|
||||
#
|
||||
# act_runner does not populate the ref on these runs: the API returns
|
||||
# head_branch NULL and no ref field at all, on ordinary pushes to main and
|
||||
# not merely on the API reruns the previous note blamed. The old condition
|
||||
# allowed ref=='refs/heads/main' OR '' OR null, which looks like it covers
|
||||
# exactly that — and does not, because an absent value satisfies none of the
|
||||
# three, so the whole predicate is false and the job is SKIPPED.
|
||||
#
|
||||
# Skipped, not failed, is the entire reason this was invisible. Run 38052
|
||||
# (7fbafa54a) is the proof: gate success, containment success, and then
|
||||
# image/rollout/reach/fanout/receipt all `skipped`. A green tick on a run
|
||||
# that shipped nothing, with no error anywhere to read. That is "the gate is
|
||||
# green and nothing ships", and it had outlasted several fixes aimed at the
|
||||
# gate, which was never the thing failing.
|
||||
#
|
||||
# Inverting it removes the dependence. `on:` already decides what reaches
|
||||
# this workflow — pushes to main, v* tags, PRs, dispatch — so the job does
|
||||
# not need to re-derive that, and re-deriving it is what broke. Two things
|
||||
# must be refused: pull requests, and TAG pushes (the tag is minted BY the
|
||||
# claim below, so building on a tag would take the next number and publish a
|
||||
# second image for a commit that already has one). Everything else — main,
|
||||
# dispatch, and the ref-less shape — is a release. startsWith() on an absent
|
||||
# ref is false, so the missing value now means "not a tag", which is true.
|
||||
if: github.event_name != 'pull_request' && !startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: [hanzo-build-linux-amd64]
|
||||
# cloud builds ~28 subsystems and has run 15m, 17m and 20m42s.
|
||||
timeout-minutes: 60
|
||||
@@ -357,53 +392,26 @@ jobs:
|
||||
username: ${{ secrets.GHCR_USER }}
|
||||
password: ${{ secrets.GHCR_TOKEN }}
|
||||
|
||||
- name: The commit must exist on github before a tag can name it
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
SHA: ${{ github.sha }}
|
||||
# The claim below reserves a version by creating refs/tags/v<N> AT THIS
|
||||
# COMMIT on github.com. A ref can only point at an object that is there,
|
||||
# so the claim answers 404 — "Object does not exist" — for a commit github
|
||||
# has never seen, and refuses to build.
|
||||
#
|
||||
# It routinely has not seen it. CI runs on git.hanzo.ai, which is canonical
|
||||
# and where the push lands; github is fed by a PUSH MIRROR on an 8-HOUR
|
||||
# interval, and the claim runs seconds later. So the object the tag must
|
||||
# name is normally hours away, and every release in that window fails on a
|
||||
# 404 that reads like a permissions problem and is really a race. It cost
|
||||
# the fleet four days of releases stacked behind one.
|
||||
#
|
||||
# Publishing the commit here closes the race at its cause: after this step
|
||||
# github HAS the object, whatever the mirror's schedule. It goes to a ref
|
||||
# of its own rather than to main, because main is the mirror's to move and
|
||||
# the two lineages do diverge — this step's job is to make the object
|
||||
# exist, not to decide what main is.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
api="https://api.github.com/repos/hanzoai/cloud"
|
||||
if curl -fsS -o /dev/null -H "Authorization: Bearer ${GH_PAT}" "${api}/commits/${SHA}" 2>/dev/null; then
|
||||
echo "commit ${SHA} is already on github — nothing to publish"
|
||||
exit 0
|
||||
fi
|
||||
echo "commit ${SHA} is not on github yet (push mirror runs every 8h); publishing it now"
|
||||
git push --force "https://x-access-token:${GH_PAT}@github.com/hanzoai/cloud" \
|
||||
"${SHA}:refs/heads/forge-head"
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if curl -fsS -o /dev/null -H "Authorization: Bearer ${GH_PAT}" "${api}/commits/${SHA}" 2>/dev/null; then
|
||||
echo "github now resolves ${SHA}"
|
||||
exit 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
echo "::error::pushed ${SHA} to github but it still does not resolve — the claim below would 404 on an object that is not there"
|
||||
exit 1
|
||||
|
||||
# THE CLAIM IS MADE WHERE THE CODE IS. There used to be a step here that
|
||||
# published the commit to github first, because the claim was made against
|
||||
# github and a ref can only point at an object the server has. That step is
|
||||
# gone, and so is the race it existed to close: CI runs on git.hanzo.ai, so
|
||||
# the commit is already there — being there is what started this run.
|
||||
#
|
||||
# It was also the last thing forcing the private tree onto a github repo on
|
||||
# every release, and the thing that broke the lane twice: once when the repo
|
||||
# it pushed to was public, and once when the credential could not see the
|
||||
# private one it was moved to ("Repository not found", run 38319). A step
|
||||
# that cannot fail is better than a step with two known ways to fail.
|
||||
- name: Claim a version — atomically, before anything is built
|
||||
id: ver
|
||||
env:
|
||||
GHCR_USER: ${{ secrets.GHCR_USER }}
|
||||
GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }}
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
# The claim is written to git.hanzo.ai, so it is the forge credential
|
||||
# that has to be able to write it. GH_PAT is gone from this step: it
|
||||
# authenticated a register that no longer lives on github.
|
||||
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
||||
# The commit being released, named explicitly rather than read from
|
||||
# GITHUB_SHA: that variable is the runner's to set, this claim is the
|
||||
# workflow's to make, and a claim that silently reads an empty string
|
||||
@@ -455,10 +463,20 @@ jobs:
|
||||
done
|
||||
|
||||
# What has been TAGGED. ls-remote rather than /repos/../tags because the
|
||||
# REST list paginates at 100 and this repo carries ~1700 refs. github.com,
|
||||
# not the forge: that is where cloud's tags actually are; the forge copy
|
||||
# trails and reading it would collide with fifty published images.
|
||||
GIT=$(git ls-remote --tags "https://x-access-token:${GH_PAT}@github.com/hanzoai/cloud" \
|
||||
# REST list paginates at 100 and this repo carries ~1700 refs. The
|
||||
# private github repo, not the forge: the claim is a compare-and-swap
|
||||
# and only one server can arbitrate it, so the register of names has to
|
||||
# be the one the claim writes to. Reading a different git than we claim
|
||||
# against is how two lanes come to believe they both own a number.
|
||||
#
|
||||
# The register moved here from the public repo, so it starts from
|
||||
# v1.801.491 — the reconciliation release — rather than from the
|
||||
# public repo's v1.801.490. 487 through 490 are holes there: versions
|
||||
# claimed by runs that then died in the build, which is the correct
|
||||
# direction to fail (a hole is inert; a reused number serves wrong
|
||||
# bytes). They are not carried over, because a name nothing published
|
||||
# is not a release.
|
||||
GIT=$(git ls-remote --tags "https://x:${FORGE_TOKEN}@git.hanzo.ai/hanzoai/cloud" \
|
||||
| awk '{print $2}' | sed 's|refs/tags/||' | grep -v '\^{}' \
|
||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sed 's/^v//' || true)
|
||||
|
||||
@@ -508,11 +526,11 @@ jobs:
|
||||
|
||||
# THE COMPARE-AND-SWAP.
|
||||
CODE=$(curl -s -o /tmp/claim.json -w '%{http_code}' -X POST \
|
||||
-H "Authorization: Bearer $GH_PAT" \
|
||||
-H 'Accept: application/vnd.github+json' \
|
||||
"https://api.github.com/repos/hanzoai/cloud/git/refs" \
|
||||
-d "{\"ref\":\"refs/tags/v${CAND}\",\"sha\":\"${SHA}\"}")
|
||||
if [ "$CODE" = "422" ]; then
|
||||
-H "Authorization: token $FORGE_TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
"https://git.hanzo.ai/v1/repos/hanzoai/cloud/tags" \
|
||||
-d "{\"tag_name\":\"v${CAND}\",\"target\":\"${SHA}\"}")
|
||||
if [ "$CODE" = "409" ]; then
|
||||
# TWO DIFFERENT FAILURES SHARE THIS STATUS, and treating them alike
|
||||
# would turn one of them into a ten-attempt loop that ends in the
|
||||
# wrong diagnosis. "Reference already exists" is the collision this
|
||||
@@ -523,14 +541,14 @@ jobs:
|
||||
# a name to skip past; it is a repo that has not been published, and
|
||||
# the next number would fail identically.
|
||||
WHY=$(jq -r '.message // empty' /tmp/claim.json)
|
||||
if [ "$WHY" != "Reference already exists" ]; then
|
||||
echo "::error::claiming v${CAND} was refused with: ${WHY:-unknown}. If this is 'Object does not exist', commit ${SHA} is on the forge but not on github.com — the release lane claims versions against GitHub, so the mirror must carry the commit first."
|
||||
exit 1
|
||||
fi
|
||||
case "$WHY" in *"already exists"*) : ;; *)
|
||||
echo "::error::claiming v${CAND} was refused with: ${WHY:-unknown}"
|
||||
exit 1 ;;
|
||||
esac
|
||||
# Taken. By us, or by somebody else? The distinction is the whole
|
||||
# difference between a resume and a collision, and it is one GET.
|
||||
HAVE=$(curl -fsS -H "Authorization: Bearer $GH_PAT" \
|
||||
"https://api.github.com/repos/hanzoai/cloud/git/ref/tags/v${CAND}" | jq -r '.object.sha // empty')
|
||||
HAVE=$(curl -fsS -H "Authorization: token $FORGE_TOKEN" \
|
||||
"https://git.hanzo.ai/v1/repos/hanzoai/cloud/tags/v${CAND}" | jq -r '.commit.sha // empty')
|
||||
if [ "$HAVE" = "${SHA}" ]; then
|
||||
echo "v${CAND} is already claimed at our sha — this release, resumed"
|
||||
CLAIMED="$CAND"; break
|
||||
@@ -538,8 +556,17 @@ jobs:
|
||||
echo "v${CAND} is held by ${HAVE:-another ref} — trying the next number"
|
||||
continue
|
||||
fi
|
||||
if [ "$CODE" = "401" ] || [ "$CODE" = "403" ]; then
|
||||
# Say which credential and which scope, because the symptom
|
||||
# otherwise reads as "the claim is broken" and the cause is one
|
||||
# secret. FORGE_TOKEN has only ever been READ with in this
|
||||
# workflow — go module fetches and ls-remote — so the first
|
||||
# release to write with it is the first test of its scope.
|
||||
echo "::error::the forge refused the claim for v${CAND} with $CODE. FORGE_TOKEN must carry write:repository on hanzoai/cloud — it creates refs/tags/v<N> and the refs/smoked/<digest> receipt. Re-issue it with that scope and set it as the hanzoai org actions secret; nothing else here needs changing."
|
||||
cat /tmp/claim.json; exit 1
|
||||
fi
|
||||
if [ "$CODE" != "201" ]; then
|
||||
echo "::error::claiming v${CAND} returned $CODE (expected 201 or 422) — refusing to build a version this run cannot prove it owns"
|
||||
echo "::error::claiming v${CAND} returned $CODE (expected 201 created or 409 taken) — refusing to build a version this run cannot prove it owns"
|
||||
cat /tmp/claim.json; exit 1
|
||||
fi
|
||||
echo "claimed v${CAND} at ${SHA}"
|
||||
@@ -598,9 +625,24 @@ jobs:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
provenance: false
|
||||
tags: ghcr.io/hanzoai/cloud:v${{ steps.ver.outputs.version }}
|
||||
# `outputs:` in place of `push: true` — the only way to reach buildkit's
|
||||
# exporter attributes through this action, and `push: true` cannot be set
|
||||
# alongside it because both ask for an image exporter.
|
||||
#
|
||||
# zstd because this image is ONE 1.63GB layer (the per-app plugin
|
||||
# binaries, 98.8% of it) and gzip writes a layer as a single stream on a
|
||||
# single core: 175.0s to export, seven of eight CPUs idle. Measured on the
|
||||
# same bytes, both orderings, 2026-08-06: gzip 245.5s/256.1s against zstd
|
||||
# 58.1s/53.3s, and the zstd image is 1.2% smaller.
|
||||
#
|
||||
# This lane matches apps/platform/k8s.go deliberately. The two build the
|
||||
# SAME Dockerfile into the SAME repository, and a compression setting that
|
||||
# differs between them is not a preference — it decides the manifest media
|
||||
# type, which decides whether imagePullable's Accept negotiation gets a
|
||||
# 200 or a 404 (see the note in apps/platform/pin.go).
|
||||
outputs: type=image,push=true,compression=zstd,force-compression=true,oci-mediatypes=true
|
||||
# VERSION is what the binary reports as X-Api-Version. Without it the
|
||||
# ldflag falls back to the `dev` default and a released image cannot
|
||||
# say which release it is — and every car below keys off that header.
|
||||
@@ -647,7 +689,7 @@ jobs:
|
||||
env:
|
||||
GHCR_USER: ${{ secrets.GHCR_USER }}
|
||||
GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }}
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
img="ghcr.io/hanzoai/cloud:v${{ steps.ver.outputs.version }}"
|
||||
@@ -703,8 +745,7 @@ jobs:
|
||||
# so the receipt says "these bytes, from this source, booted" and stays
|
||||
# false the moment the bytes change under the name.
|
||||
SMOKED=0
|
||||
if curl -fsS -o /dev/null -H "Authorization: Bearer ${GH_PAT}" \
|
||||
"https://api.github.com/repos/hanzoai/cloud/git/ref/${RECEIPT}" 2>/dev/null; then
|
||||
if git ls-remote --exit-code "https://x:${FORGE_TOKEN}@git.hanzo.ai/hanzoai/cloud" "refs/${RECEIPT}" >/dev/null 2>&1; then
|
||||
echo "$DIGEST already has a smoke receipt — these exact bytes have booted"
|
||||
SMOKED=1
|
||||
fi
|
||||
@@ -779,19 +820,23 @@ jobs:
|
||||
- name: Record that these bytes booted
|
||||
if: steps.img.outputs.smoked == '0'
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CODE=$(curl -s -o /tmp/receipt.json -w '%{http_code}' -X POST \
|
||||
-H "Authorization: Bearer ${GH_PAT}" -H 'Accept: application/vnd.github+json' \
|
||||
"https://api.github.com/repos/hanzoai/cloud/git/refs" \
|
||||
-d "$(jq -nc --arg r "refs/${{ steps.img.outputs.receipt }}" --arg s "${{ github.sha }}" '{ref:$r, sha:$s}')")
|
||||
case "$CODE" in
|
||||
201) echo "recorded: ${{ steps.img.outputs.digest }} booted, built from ${{ github.sha }}" ;;
|
||||
422) echo "already recorded by another run — same digest, same statement" ;;
|
||||
*) echo "::error::could not record the smoke receipt for ${{ steps.img.outputs.digest }} (HTTP $CODE). These bytes booted and nothing can prove it, so a resume would smoke them again at best and skip them at worst — refusing to leave that state."
|
||||
cat /tmp/receipt.json; exit 1 ;;
|
||||
esac
|
||||
# A push IS the compare-and-swap: the server refuses to move a ref that
|
||||
# already names something else, and re-pushing the identical value is a
|
||||
# no-op. Gitea has no POST for a ref outside refs/tags, and it does not
|
||||
# need one — this is the operation that endpoint would have wrapped.
|
||||
if git push "https://x:${FORGE_TOKEN}@git.hanzo.ai/hanzoai/cloud" \
|
||||
"${{ github.sha }}:refs/${{ steps.img.outputs.receipt }}" 2>/tmp/receipt.err; then
|
||||
echo "recorded: ${{ steps.img.outputs.digest }} booted, built from ${{ github.sha }}"
|
||||
elif git ls-remote --exit-code "https://x:${FORGE_TOKEN}@git.hanzo.ai/hanzoai/cloud" \
|
||||
"refs/${{ steps.img.outputs.receipt }}" >/dev/null 2>&1; then
|
||||
echo "already recorded by another run — same digest, same statement"
|
||||
else
|
||||
echo "::error::could not record the smoke receipt for ${{ steps.img.outputs.digest }}. These bytes booted and nothing can prove it, so a resume would smoke them again at best and skip them at worst — refusing to leave that state."
|
||||
cat /tmp/receipt.err; exit 1
|
||||
fi
|
||||
|
||||
# NOTHING LEAVES THIS JOB UNSMOKED, re-asked of the authority rather than
|
||||
# remembered from a step output. Cheap, and it is the assertion `rollout`
|
||||
@@ -800,11 +845,11 @@ jobs:
|
||||
# unproven image becomes a pin.
|
||||
- name: These bytes have a smoke receipt
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsS -o /dev/null -H "Authorization: Bearer ${GH_PAT}" \
|
||||
"https://api.github.com/repos/hanzoai/cloud/git/ref/${{ steps.img.outputs.receipt }}" \
|
||||
git ls-remote --exit-code "https://x:${FORGE_TOKEN}@git.hanzo.ai/hanzoai/cloud" \
|
||||
"refs/${{ steps.img.outputs.receipt }}" >/dev/null 2>&1 \
|
||||
|| { echo "::error::no smoke receipt for ${{ steps.img.outputs.digest }} — these bytes have never been proven to boot. Nothing downstream may pin them."; exit 1; }
|
||||
echo "${{ steps.img.outputs.digest }} is smoked"
|
||||
|
||||
@@ -834,12 +879,12 @@ jobs:
|
||||
# because the one thing worse than a moved tag is a moved tag that shipped.
|
||||
- name: The claimed tag still names this commit
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="v${{ needs.image.outputs.version }}"
|
||||
HAVE=$(curl -fsS -H "Authorization: Bearer ${GH_PAT}" \
|
||||
"https://api.github.com/repos/hanzoai/cloud/git/ref/tags/${TAG}" | jq -r '.object.sha // empty')
|
||||
HAVE=$(curl -fsS -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"https://git.hanzo.ai/v1/repos/hanzoai/cloud/tags/${TAG}" | jq -r '.commit.sha // empty')
|
||||
if [ -z "$HAVE" ]; then
|
||||
echo "::error::${TAG} was claimed by this run but no longer exists — refusing to ship a release whose receipt was deleted"; exit 1
|
||||
fi
|
||||
@@ -1199,7 +1244,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Write release.json onto the tag
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="v${{ needs.image.outputs.version }}"
|
||||
@@ -1224,15 +1269,15 @@ jobs:
|
||||
(.|tostring) + "\n```"' /tmp/release.json)
|
||||
|
||||
# Idempotent: create, or update the one that a previous attempt left.
|
||||
ID=$(curl -fsS -H "Authorization: Bearer ${GH_PAT}" \
|
||||
"https://api.github.com/repos/hanzoai/cloud/releases/tags/${TAG}" 2>/dev/null | jq -r '.id // empty')
|
||||
ID=$(curl -fsS -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"https://git.hanzo.ai/v1/repos/hanzoai/cloud/releases/tags/${TAG}" 2>/dev/null | jq -r '.id // empty')
|
||||
if [ -n "$ID" ]; then
|
||||
curl -fsS -X PATCH -H "Authorization: Bearer ${GH_PAT}" \
|
||||
"https://api.github.com/repos/hanzoai/cloud/releases/${ID}" \
|
||||
curl -fsS -X PATCH -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"https://git.hanzo.ai/v1/repos/hanzoai/cloud/releases/${ID}" \
|
||||
-d "$(jq -nc --arg b "$BODY" '{body:$b}')" >/dev/null
|
||||
else
|
||||
curl -fsS -X POST -H "Authorization: Bearer ${GH_PAT}" \
|
||||
"https://api.github.com/repos/hanzoai/cloud/releases" \
|
||||
curl -fsS -X POST -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"https://git.hanzo.ai/v1/repos/hanzoai/cloud/releases" \
|
||||
-d "$(jq -nc --arg t "$TAG" --arg b "$BODY" '{tag_name:$t, name:$t, body:$b}')" >/dev/null
|
||||
fi
|
||||
echo "receipt written to the ${TAG} release"
|
||||
|
||||
+44
-15
@@ -213,19 +213,31 @@ RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
|
||||
SQLITE_REQUIRE_CODEC=1 CGO_ENABLED=1 go test -count=1 -tags "libsqlite3 sqlite_fts5 sqlite_math_functions" \
|
||||
-run 'TestEncryptionProof|TestUnwrapGoldenFixture|TestWrapUnwrapRoundTripPinsLayout' \
|
||||
github.com/hanzoai/sqlite
|
||||
# Go drops comments at compile time, so this pass is the ONLY way a typed handler's
|
||||
# prose reaches the document: zipdoc lifts it into zipdoc_gen.go, which registers it
|
||||
# with zip.Describe at init. It must run BEFORE every build below, because the
|
||||
# generated file is compiled INTO each binary — running it after would be too late.
|
||||
# NO `go generate -run zipdoc` HERE, DELIBERATELY — and the reason is not that the
|
||||
# lifted prose stopped mattering. It still is the only way a typed handler's words
|
||||
# reach /v1/openapi.json: Go drops comments at compile time, zipdoc lifts them into
|
||||
# zipdoc_gen.go, and that file is compiled INTO each binary below. An image whose
|
||||
# binaries lack it serves the 1441 description-less operations this step was added
|
||||
# to fix, and the SDK repos and the CLI read that document.
|
||||
#
|
||||
# mk/plugin.mk makes this a prerequisite of the per-app `build`, so the per-app path
|
||||
# has always had it. This path did not, and the omission is measurable in production:
|
||||
# api.hanzo.ai/v1/openapi.json serves 1441 operations with ZERO descriptions, which
|
||||
# is exactly the binary mk/plugin.mk warns about. The SDK repos and the CLI read that
|
||||
# document, so the prose never reached any of them either.
|
||||
RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
|
||||
--mount=type=cache,id=cloud-gobuild-v4,target=/root/.cache/go-build,sharing=locked \
|
||||
go generate -run zipdoc ./...
|
||||
# The fix for that was never "regenerate during the build". All 99 zipdoc_gen.go
|
||||
# files are COMMITTED — they are source, the way generated Go is source everywhere
|
||||
# else — so the tree `COPY . .` just brought in already contains them, and every
|
||||
# `go build` below compiles the real prose in whether or not anything regenerates.
|
||||
# Running the generator here re-derived those 99 files from the same inputs to
|
||||
# produce the same bytes, for 355.9s of a 17-minute build: 35% of the wall clock
|
||||
# spent proving a file equals itself.
|
||||
#
|
||||
# Freshness is the real requirement, and it is a property of the COMMIT, not of the
|
||||
# image. So it is enforced where commits are: `make zipdoc-check` regenerates from
|
||||
# source and fails on any diff (hanzo.yml, step `zipdoc-current`), which runs in
|
||||
# the test lane every later job already declares `needs:` on. A stale lift now
|
||||
# cannot be merged — which is strictly stronger than this step, because this step
|
||||
# would happily build a correct image from a stale commit and leave main wrong.
|
||||
# That is not hypothetical: main carried a stale apps/agents lift while this ran.
|
||||
#
|
||||
# It must stay out. Re-adding it buys nothing a green `zipdoc-current` has not
|
||||
# already proven, and costs the 355.9s back.
|
||||
# The commit this image is built FROM, handed in by the SAME builder that already
|
||||
# feeds it to the OCI label in the final stage (apps/platform buildFrontendCmdRev,
|
||||
# `--opt build-arg:REVISION=<sha>`; the other lane passes github.sha).
|
||||
@@ -301,6 +313,20 @@ RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
|
||||
# one-line manifest edit and this Dockerfile does not change. An app with no
|
||||
# plugin/<app> fails HERE (the generator's bijection would have caught it first).
|
||||
#
|
||||
# EXCEPT the CORESIDENT ones, which get no binary. Coresident means the app is not
|
||||
# prefix-routed: it mounts as middleware on a sibling's router, and cmd/cloud's
|
||||
# mount() returns before it can ever resolve a path or spawn a child. So its binary
|
||||
# is linked, copied and pulled on every deploy to be executed never. zen is the one:
|
||||
# 164.7 MB, 3.9% of this image, for a process that cannot start. Its behaviour ships
|
||||
# in /ai, which links apps/zen and mounts the Claim ahead of ai's catch-all.
|
||||
#
|
||||
# TWO lists, because they answer two questions. `names` is every manifest app and
|
||||
# still guards the bijection above — a coresident app must STILL have a plugin/<app>
|
||||
# (gen-app-cmds requires it, and it is what runs standalone in dev). `spawned` is
|
||||
# what the host can actually load, and that is what earns a binary. Flip
|
||||
# Coresident:false in the manifest and the binary comes back on the next build,
|
||||
# because both lists read the same source the host does.
|
||||
#
|
||||
# Each link is the ONE app's own graph (~600–2200 packages), NEVER the ~3040-pkg
|
||||
# fleet union the fused binary was. 112 lean links, sequential, none of them mega —
|
||||
# which is the whole point of this change.
|
||||
@@ -332,9 +358,12 @@ RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
|
||||
[ -n "$names" ] || { echo "FATAL: no apps parsed from manifest/apps.go — the derivation broke, not the app list"; exit 1; }; \
|
||||
for p in $names; do \
|
||||
[ -d "./plugin/$p" ] || { echo "FATAL: manifest app '$p' has no plugin/$p — run 'make generate' and commit"; exit 1; }; \
|
||||
echo "building plugin $p"; \
|
||||
CGO_ENABLED=1 go build -tags "libsqlite3 sqlite_fts5 sqlite_math_functions" -ldflags="$GO_LDFLAGS" -o "/plugins/$p" "./plugin/$p"; \
|
||||
done
|
||||
done; \
|
||||
coresident="$(sed -n '/Coresident: *true/{s/.*{Name: "\([^"]*\)".*/\1/p;}' manifest/apps.go)"; \
|
||||
spawned="$(sed -n '/Coresident: *true/d; s/.*{Name: "\([^"]*\)".*/\1/p' manifest/apps.go)"; \
|
||||
echo "building $(echo "$spawned" | wc -w) of $(echo "$names" | wc -w) plugins, $(nproc) at a time (coresident, never spawned: ${coresident:-none})"; \
|
||||
printf '%s\n' $spawned | xargs -P "$(nproc)" -I{} sh -c \
|
||||
'CGO_ENABLED=1 go build -tags "libsqlite3 sqlite_fts5 sqlite_math_functions" -ldflags="$GO_LDFLAGS" -o "/plugins/$1" "./plugin/$1" || { echo "FATAL: plugin $1 failed to build" >&2; exit 255; }' _ {}
|
||||
# THE STAMP LANDED — asked of the ARTIFACT, not of the flag string.
|
||||
#
|
||||
# `-X` naming a path or symbol the linker cannot resolve is not an error: it is
|
||||
|
||||
@@ -330,19 +330,52 @@ this one map.
|
||||
|
||||
### The release index: `CLOUD_PLUGINS` → `binaries.json` → verified fetch
|
||||
|
||||
**The image IS the distribution, and there is no `binaries:` lane.** The
|
||||
Dockerfile builds the light host plus one binary per manifest app into `/plugins`
|
||||
beside it, and the host resolves each plugin as a file on disk
|
||||
(`manifest.App.Plugin`). `hanzo.yml` says so where a lane would otherwise go, and
|
||||
gives the reason: 112 per-app entries would blow the artifact lane's 16-binary
|
||||
bound, and its `out:`-glob names every file with one recipe name, which the
|
||||
per-app/per-arch index in `manifest/release.go` cannot read.
|
||||
**The image carries a copy; the index is how a host without one gets the same
|
||||
bytes.** The Dockerfile builds the light host plus one binary per manifest app
|
||||
into `/plugins` beside it, and the host resolves each plugin as a file on disk
|
||||
(`manifest.App.Plugin`). That stays the default, because a pod that pulled one
|
||||
image must be able to serve without reaching the network.
|
||||
|
||||
`CLOUD_PLUGINS` (manifest/release.go:21) is the OPT-IN runtime path for a
|
||||
plugin-less host: point it at a `binaries.json` index and each app resolves to a
|
||||
published artifact instead of a sibling file. **Nothing publishes such an index
|
||||
today** — it is a supported input with no producer, kept for a host that ships
|
||||
without `/plugins`, not a second way the default deployment gets its bits.
|
||||
**The build half of a `binaries:` lane now exists; the publish half waits on two
|
||||
things outside this repo.** `make -f mk/fleet.mk dist` builds all 121 plugins for
|
||||
every platform in `PLATFORMS` (linux/amd64 + linux/arm64) into
|
||||
`dist/<app>-<os>-<arch>` — the exact filename a release index keys on, and the
|
||||
same `mk/plugin.mk build` recipe an app's own Makefile runs, so a published
|
||||
plugin is byte-for-byte what `make -C apps/<app> build` produces. `CGO_ENABLED=0`
|
||||
there is load-bearing rather than inherited: a plugin fetched over the network
|
||||
runs on a box we did not build, and a cgo binary would demand a matching
|
||||
libsqlite3 on it. The image's `/plugins` are built the other way (cgo +
|
||||
libsqlite3) because there the host owns the filesystem they land on.
|
||||
|
||||
`hanzo.yml` carries the declaration commented, with both blockers named:
|
||||
|
||||
1. **hanzoai/ci's `run:`/`out:` lane indexes per RECIPE, not per FILE.** It writes
|
||||
`{name: <the recipe's>, os: any, arch: any}` for every file it collects —
|
||||
right for a wheel, unreadable to `manifest/release.go`, which resolves by
|
||||
name+os+arch. The fix is to derive the triple from the filename when it carries
|
||||
the `<name>-<os>-<arch>` shape ci's OWN Go lane already writes: one naming
|
||||
convention, either lane. (`out: dist/*` also aborts that lane today — it copies
|
||||
each match into `dist/` by basename and `cp x x` is an error, not a no-op.)
|
||||
121 per-app entries is not the alternative: it would blow the platform front
|
||||
door's 16-binary bound (`apps/platform/artifact.go:86`) and restate
|
||||
`manifest/apps.go` in YAML.
|
||||
2. **`bucket:` needs `S3_ADMIN_ACCESS_KEY`/`S3_ADMIN_SECRET_KEY` from KMS, and
|
||||
those names are in KMS for no org.** Both lanes fail closed on it, so declaring
|
||||
`bucket:` publishes nothing and reds every tag build — which is what happened
|
||||
to ci's `site:` lane, which refused every caller that ever declared one. The
|
||||
answer being built next door is to stop needing the credential: publish through
|
||||
an authenticated cloud endpoint on the IAM bearer the KMS step already mints,
|
||||
so CI names no bucket and holds no bucket key. If that is the direction, this
|
||||
lane wants the same door.
|
||||
|
||||
When both hold, the layout is `https://s3.hanzo.ai/plugins/hanzoai/cloud/<tag>/`
|
||||
— artifacts first, `binaries.json` LAST, so the index never names an object that
|
||||
is not there — and `plugins` is the bucket `apps/platform/artifact.go:84` already
|
||||
defaults to, so both publishers write one layout.
|
||||
|
||||
`CLOUD_PLUGINS` (manifest/release.go:21) is the runtime path that reads it: point
|
||||
it at that `binaries.json` and each app resolves to a published artifact instead
|
||||
of a sibling file.
|
||||
|
||||
- **No digest, no trust.** `fetch` drops any index entry missing `url` or
|
||||
`sha256` (manifest/release.go:82), and `remote` returns a `zip.Plugin` with
|
||||
@@ -501,7 +534,69 @@ are not named after their app (`zt`→zero-trust, `eval`→evals, `auditlog`→a
|
||||
licensing, metrics) are external modules with a `plugin/<app>` and no source
|
||||
directory here; `mk/fleet.mk` runs them through the same recipe by name.
|
||||
`mk/go.mk` is the toolchain contract every includer shares (GOWORK=off, TMPDIR on
|
||||
disk, `-p=2`, the dev KMS key, the FTS5 tag).
|
||||
disk, the dev KMS key, the FTS5 tag, and `NPROC`).
|
||||
|
||||
### The fleet is a set of TARGETS, and the numbers that made it one
|
||||
|
||||
`mk/fleet.mk` applies that per-app contract to all 121 apps. Every sweep in it was
|
||||
a shell `for` loop, which can only do one thing at a time; they are now named
|
||||
targets make schedules. Measured on this repo, 20 cores, from an EMPTY build
|
||||
cache:
|
||||
|
||||
| what | how | cold |
|
||||
|---|---|---|
|
||||
| one plugin, alone | `make -C apps/auto build` | 57.6s at `-p=2`, 41.4s at `-p=$(nproc)` |
|
||||
| all 121, the old shell loop | `for d in apps/*/Makefile; …` | **586s** |
|
||||
| all 121, scheduled | `make -f mk/fleet.mk binaries` | **300s** (25s warm) |
|
||||
|
||||
The FLOOR is why the fleet is so much cheaper than 121 × one: the root package
|
||||
`github.com/hanzoai/cloud` is 587 packages (214 stdlib, 373 external) and EVERY
|
||||
app inherits it — `apps/auto` is 588 packages, `o11y` the largest at 2054. So the
|
||||
57.6s cold floor is paid ONCE into a shared cache and the marginal app costs
|
||||
~1.4s. **That makes cache SHARING, not floor size, the thing that matters**: 121
|
||||
apps built in 121 isolated caches would pay that floor 121 times, ~118 minutes of
|
||||
identical work. No single import dominates the floor either — the largest
|
||||
exclusive contributors are `hanzo-ds/go` (30 packages, for `datastore.Open` in
|
||||
audit_mirror.go) and `iam/pkg/model` (23, for the two-string `model.OrgRef` in
|
||||
token_validator.go); everything else shares a deep common core (`circl`'s 23 PQ
|
||||
packages arrive via `luxfi/zap`'s handshake, protobuf via prometheus).
|
||||
|
||||
- **J apps at once, P compilers each, and `J*P ≈ NPROC`.** The link is what costs
|
||||
memory: measured peak RSS is 1.67 GB for the heaviest plugin (o11y), 1.4 GB
|
||||
median, 273 MB for `cmd/cloud`. J is therefore bounded by MEMORY (3 GiB per
|
||||
concurrent app) read from the **cgroup** before `/proc/meminfo`, because the
|
||||
git-runner pod is 26Gi on 6 CPU while `nproc` inside it reports the node's
|
||||
cores. Oversubscribing CPU makes a build slower; oversubscribing memory makes it
|
||||
killed. `J*P` is capped at NPROC because it measurably matters —
|
||||
`J=20,P=2` (40 actions on 20 cores) took **431s**, the same work at `J=10,P=2`
|
||||
took **300s**.
|
||||
- **P stays 2 because the runner asks for 2**, not because 2 is fastest. `fan`
|
||||
passes `GOFLAGS` on the make command line, which beats the pod's injected
|
||||
`GOFLAGS=-p=2`; any other P here overrides the operator who sized the cgroup.
|
||||
`J=5,P=4` measured **268s** against 300s — real, but inside the spread the same
|
||||
`J=10,P=2` config showed on this machine (300s and 354s), so nothing here
|
||||
outweighs agreeing with the pod. Re-measure on a quiet box before moving it, and
|
||||
move the pod's setting with it.
|
||||
- **Prebuilding the shared floor does NOT help, and was measured twice.** J cold
|
||||
builds each compile the 587-package root, so warming it first is the obvious
|
||||
fix; at J=10,P=2 it went 300s → 339s (`go build <root>`) → 327s
|
||||
(`go build <root>/apps/...`, all 4149 packages). One process on a
|
||||
dependency-shaped graph leaves the box idle longer than the duplication costs.
|
||||
Recorded in `mk/fleet.mk` so it is not re-derived.
|
||||
- **A solo build is J=1, so it gets the whole box**: `mk/go.mk` sets
|
||||
`-p=$(NPROC)`. The runner injects `GOFLAGS=-p=2` into every job and `?=`
|
||||
deliberately does not override it — the operator sizing the pod knows what it
|
||||
holds.
|
||||
- **`make -k`, not `set -e`.** The old loop stopped at the first failure and the
|
||||
apps behind it never ran, which reports nothing, and nothing is
|
||||
indistinguishable from passing. make continues and names each failed target.
|
||||
- **The exemptions are exemptions from DESCRIBING, never from BUILDING.** kafka
|
||||
(needs a live broker) and zen (coresident, no standalone mount) were skipped by
|
||||
the only sweep that touched an app, so nothing ever compiled them — they could
|
||||
stop linking on main with every gate green. `binaries` carries no exemptions;
|
||||
`describe` builds those two and skips only the projection.
|
||||
- `binaries` → `bin/<app>` (121). `dist` → `dist/<app>-<os>-<arch>` for every
|
||||
platform, the publishable layout (4.1 GiB per platform).
|
||||
|
||||
## Framework doctrine
|
||||
|
||||
@@ -1069,8 +1164,8 @@ one before it.
|
||||
judged only inside the products that app publishes, because every app binary
|
||||
links cloud's core and therefore carries other subsystems' declarations.
|
||||
**1491 of 1491 operations carry prose; 0 orphans.**
|
||||
- **`openapi.yaml` is a GOLDEN, woven from the per-app subsets, and the ONE
|
||||
artifact cloud publishes.** `make openapi` writes it (through the weave,
|
||||
- **`openapi.yaml` is a GOLDEN, woven from the per-app subsets.** `make openapi`
|
||||
writes it (through the weave,
|
||||
`-weave`); `make test`, and therefore CI, verifies it with the same weave and no
|
||||
flag (`TestFleetIsTheWeaveOfItsApps`, openapi/weave_test.go). Same code path both ways — there is no second
|
||||
generator to disagree with, and no way to change a route without either
|
||||
@@ -1078,6 +1173,51 @@ one before it.
|
||||
because JSON is what the document IS (the same value served at
|
||||
`/v1/openapi.json`); YAML is a rendering, and `encoding/json` orders object
|
||||
keys so the bytes are stable run to run.
|
||||
- **TWO PROJECTIONS OF THAT ONE DOCUMENT, AND THE SPLIT IS DECLARED**
|
||||
(openapi/public.go). `openapi.yaml` is the INTERNAL document — everything the
|
||||
fleet serves, admin included, and what our own clients are cut from.
|
||||
`public.yaml` beside it is the PUBLIC contract: the operations that DECLARED
|
||||
themselves part of it, and nothing else. Both are written by ONE run of the
|
||||
weave (`-weave` writes the second beside whatever path it names), so they can
|
||||
never describe two different commits.
|
||||
- **`openapi.Public(path, method)` is the seam, and it is DEFAULT-DENY.**
|
||||
`Register` declares an operation's bodies, `Describe` declares its prose,
|
||||
`Public` declares its AUDIENCE — same law, same init, same inability to
|
||||
invent an address. Silence means INTERNAL, so a product cannot reach a
|
||||
published SDK by anyone forgetting; a whole product ships publicly only when
|
||||
somebody writes the line. There is **no prefix list in the emitter**, and
|
||||
that is the point: a prefix list is a second copy of the routing table, which
|
||||
is how a case-sensitive path list let `/V1/EXEC` walk past a credential guard
|
||||
and how a manifest prefix row disagreed with what an app served until
|
||||
`/v1/tags` 404'd in production. `Publish` reads ONE per-operation fact and
|
||||
knows nothing about paths, products, prefixes or case.
|
||||
- **Keyed by the DOCUMENT's address, not the fiber pattern**, because the
|
||||
largest public product has no fiber pattern here: hanzoai/ai reaches its whole
|
||||
surface through one `All("/v1/*")`, so `/v1/models` exists only as an address
|
||||
in the document its door hands over. Declarations are normalised through the
|
||||
same `translate` the document is built with, so `:id` and `{id}` are one key.
|
||||
- **Stamped once, at the END of `Spec`** — after `Fold` (which replaces a
|
||||
structural operation with the typed one) and after `Project` (which replaces a
|
||||
door with the registry behind it), both of which would discard a mark written
|
||||
earlier. It rides as `x-public`, an extension rather than a tag, because the
|
||||
tag axis already means PRODUCT and `compat` had to be filtered back out of it.
|
||||
- **v1 is INFERENCE: 18 operations, 10 products** (apps/ai/public.go) — the
|
||||
model catalog (`/v1/models`, `/v1/models/providers`) plus every model call:
|
||||
chat/completions, completions, responses, messages (+count_tokens),
|
||||
embeddings, rerank, images, videos (async create + poll + fetch) and the five
|
||||
audio verbs. Against 1782 internal paths. The junk a route table carries and a
|
||||
product surface does not — `/v1/openapi.json`, `/v1/event.js`, `/health`,
|
||||
`/v1/commands` — is gone for FREE: nothing excludes it, it was never included.
|
||||
- **The ratchet, split correctly.** `openapi/floor.json` keeps guarding the
|
||||
INTERNAL document and only it; measuring the public projection would either
|
||||
wedge CI on a shrink that is not a shrink, or re-base the floor to eighteen
|
||||
paths and let every internal product vanish unnoticed.
|
||||
`TestTheFloorGuardsTheInternalDocument` asserts both halves — the floor
|
||||
accepts the internal document and REFUSES the public one. The public surface
|
||||
needs no counting scheme of its own: it is small enough to compare WHOLE, so
|
||||
the committed `public.yaml` is the ratchet, and the drift gate
|
||||
(`mk/fleet.mk check`, porcelain-scoped to it) catches a shrink and a LEAK
|
||||
alike.
|
||||
- **SDK repos PULL; cloud does not push.** A stale spec does not stop at cloud —
|
||||
it ships wrong clients to four package registries. The repos read
|
||||
`openapi.yaml`, regenerate, and release on their own cadence.
|
||||
@@ -1087,6 +1227,100 @@ one before it.
|
||||
by the same run — one value in two places, not two sources of truth. It is
|
||||
named `hanzo`, not `cloud`, because the binary serves the WHOLE /v1 surface.
|
||||
|
||||
### What the projections MEASURE, asked of the running deployment
|
||||
|
||||
Every number below was taken by making the request, not by reading the code. Two
|
||||
of them refuted a claim that had been repeated confidently for weeks.
|
||||
|
||||
- **The published document IS the committed one.** `GET api.hanzo.ai/v1/openapi.json`
|
||||
serves 1735 paths / 2474 operations, and the `openapi.yaml` committed at the
|
||||
revision the deployment reports (`x-api-version: sha-8465354e`) has the SAME
|
||||
1735 / 2474 — identical operation sets, not merely similar counts. The
|
||||
lazy-host fallthrough that once made this address answer with 8 paths is gone.
|
||||
Take the version off the header and diff against THAT commit's golden; diffing
|
||||
against your branch's golden measures the deploy lag, not the defect.
|
||||
- **`GET /v1/commands` is live**: 2448 commands over 194 services, under a strong
|
||||
ETag that answers 304 to a matching `If-None-Match`.
|
||||
- **`POST /v1/mcp` works end to end**: `tools/list` returns 88 tools —
|
||||
`describe` plus ONE tool per subsystem, each carrying its operations in an
|
||||
`op` enum — and `tools/call` on `describe` returns the prose zipdoc lifted
|
||||
off the Go handler. 0 of the 88 have an empty description or a missing input
|
||||
schema.
|
||||
- **HALF the surface is not reachable as a tool, and only a tenth of that gap is
|
||||
declared.** Those 88 tools address **1189 of the deployment's 2422 operations
|
||||
(49%)**. The `_meta` names 134 as refused by the projection rule (a name that
|
||||
discloses a bearer secret; a mutating verb on an identity or authority object)
|
||||
and exactly ONE subsystem as unavailable (`x402`) — so roughly 1,100 operations
|
||||
are absent with no stated reason. `ai` is the extreme: **1 op in its enum
|
||||
against ~300 in the document.** An untyped route earns no tool by construction,
|
||||
so most of this is the typed migration's remaining tail showing up in the one
|
||||
projection where it is countable — but it is NOT all of it, and nothing today
|
||||
tells the two apart. Count it per app before believing any "MCP is complete".
|
||||
|
||||
### zipdoc needs a router it can RESOLVE, and the gate accepts the gap
|
||||
|
||||
`zipdoc` resolves a typed op's path STATICALLY. Register on the `cloud.Router`
|
||||
parameter and it cannot follow the interface to a prefix, so it refuses to lift —
|
||||
and the refusal is silent in every gate downstream, because `openapi.Complete`
|
||||
passes an operation carrying EITHER a summary or a description. `zip.WithSummary`
|
||||
alone is therefore enough to be green and empty.
|
||||
|
||||
`POST /v1/exec` shipped exactly that way: a real doc comment on the handler, no
|
||||
`//go:generate` directive in the package, no `zipdoc_gen.go`, and an operation
|
||||
that reached the document, the SDKs and the tool list with a summary and no
|
||||
prose. The fix is two lines — the directive, and `reg := cloud.ZipApp(app)` so the
|
||||
registration is spelled where the generator can read it (the pattern `apps/meet`
|
||||
and `apps/blueprint` already use). ROUTES move to the `*zip.App`; middleware stays
|
||||
on the scoped router, where the prefix guard applies to it.
|
||||
|
||||
**The chain is countable end to end**: 51 operations carry no description →
|
||||
51 of the 2448 commands at `/v1/commands` have an empty `Description` (49 `ai`,
|
||||
2 `router`, all owned by `apps/ai`). One hole, three surfaces.
|
||||
|
||||
### The field surface is a different fact, counted in a different place
|
||||
|
||||
An app can be 100% typed and publish a wholly undescribed shape, because op prose
|
||||
and FIELD prose are lifted from different comments. Measured on this commit:
|
||||
**5110 of 11043 published properties (46%) carry no description, and 643 schemas
|
||||
are 100% undescribed, across 51 of 121 apps.** Only 20 apps carry
|
||||
`TestEveryPublishedFieldIsDescribed`, which is precisely why the number is that
|
||||
large — the gate exists and does not run in 101 places.
|
||||
|
||||
One tranche closed here (343 properties): `authors` 39→0, `label` 37→0,
|
||||
`channels` 34→0, `prompts` 29→0, `leaderboard` 40→0, `campaign` 42→6,
|
||||
`affiliates` 123→10, `exec` 15→0.
|
||||
|
||||
- **A field reached through an EMBEDDED struct cannot be described today, and it is
|
||||
a zip defect, not an app one.** zipdoc files a field's prose under the type that
|
||||
DECLARES it; zip's schema builder inlines the embedding and looks the prose up
|
||||
under the type that PROMOTES it (`zip@v1.27.0/openapi.go:701` keys
|
||||
`fields[t.Name()+"."+name]`). The two never meet. Found twice, independently:
|
||||
`envelope.msg` vs `directoryOut.msg` — every enveloped `*Out` in the fleet
|
||||
publishes its `msg` and `status` bare, **167 properties, 3% of the backlog** —
|
||||
and `campaignWrite.audience` vs `campaignUpdate.audience`, where
|
||||
`internal/zipdoc/extract.go:634` additionally skips the embedded field outright
|
||||
because `campaignWrite` is unexported. Write the comment on the embedded struct
|
||||
anyway; do NOT unroll it into hand-copied field pairs, which would duplicate the
|
||||
shape the type exists to share AND break the embedding that keeps create and
|
||||
update in step. The fix is one change in zip: `structFields` must recurse into an
|
||||
embedded struct and re-emit under the OUTER type's name.
|
||||
- **A defined type over another struct publishes NOTHING.** `type campaignRecord
|
||||
Campaign` has no struct literal of its own, so zipdoc emitted nothing for it and
|
||||
all 30 of its properties published bare. Declare the struct under its published
|
||||
name and make the domain name an ALIAS (`type Campaign = campaignRecord`) — same
|
||||
type, one shape, and the prose lands.
|
||||
|
||||
### There is no hand-authored copy of the surface left
|
||||
|
||||
`docs/automations-openapi.yaml` was a 25 KB hand-written OpenAPI document
|
||||
describing 17 operations of `/v1/automations`. Nothing referenced it and no gate
|
||||
compared it, so it had drifted exactly as a second copy always does: it claimed
|
||||
two operations the fleet does not serve (`GET /v1/automations/health`,
|
||||
`POST /v1/automations/mcp`) and omitted three it does (`POST
|
||||
/v1/automations/connectors/{id}/run`, `/flows/{id}/versions`,
|
||||
`/hooks/{source}/{event}`). Deleted. The reference pages at docs.hanzo.ai are
|
||||
generated per product from this document; nothing else here describes an endpoint.
|
||||
|
||||
## The typed migration: THE PLAYBOOK (start here before typing anything)
|
||||
|
||||
Worked end to end on `apps/agents/targets.go` (5 ops). Follow it and a partition
|
||||
@@ -2887,6 +3121,39 @@ semantic is identical — fail closed once armed, allow before.
|
||||
because a silently-short list and a stale file are the same defect. A `tools/call`
|
||||
goes to the app that listed the name, verbatim; a name nobody has listed costs one
|
||||
discovery, then `-32602`.
|
||||
**The door publishes ONE TOOL PER SUBSYSTEM, not one per operation** (`fleet/grouped.go`).
|
||||
Measured on the deployed door: the flat projection was **1,189 tools in 977,636
|
||||
bytes** — ~244k tokens to merely enumerate what can be called — and MCP clients
|
||||
truncate (Slack keeps 128), so 1,061 operations were unreachable no matter how
|
||||
they were ordered. Ordering (`rank`) fixes which tools a truncating client keeps;
|
||||
it cannot fix a hard cap. So the surface is ONE TOOL PER APP, NAMED FOR IT, carrying
|
||||
`{"op":"<operation>","input":{…}}`, whose `op` enum holds NAMES ONLY, plus
|
||||
`describe` — which returns one operation's own descriptor, so a model
|
||||
searches the enum and fetches the schema for the one it picked. The whole
|
||||
corpus is **118 tools in 106,282 bytes** (`fleet.TestTheWholeFleetFitsInAModelsHead`,
|
||||
which builds it from `plugin/*/openapi.json`) — 17× less per operation, for 1.9×
|
||||
MORE operations than the baseline carried. `describe` is FIRST because it is
|
||||
what makes every other tool usable, so truncation must never take it. The envelope
|
||||
is a DECODING and not a second route: it yields the (name, message) a direct call
|
||||
carries, and `refuse()` in `gather` remains the only gate, so a refused name is in
|
||||
no enum, dispatchable through no envelope, and describable by nothing.
|
||||
**Headroom: 10 subsystems.** 118 of the 128 a client keeps. The manifest is 121
|
||||
apps and growing, so the next ten subsystems put the door back over the cap; the
|
||||
move then is to group by product surface (`productStems`, 17 buckets), not to add
|
||||
a second projection.
|
||||
**The tools carry no prefix.** They shipped as `hanzo_<app>` + `hanzo_describe`
|
||||
and were renamed hours later to the bare app names + `describe`, in one change
|
||||
with no aliases. The MCP server is the namespace — a client reaches these names
|
||||
through it and through nothing else — so `hanzo_` disambiguated nothing and cost
|
||||
a token per tool per turn. Two consequences worth knowing before touching this:
|
||||
the prefix was also how `composed()` told one of the door's own tools from a
|
||||
child's operation, which is now an exact membership test against the app set
|
||||
(`Door.composed`); and the door's tools and the app names now share one
|
||||
namespace, so **no subsystem may be named `describe`** —
|
||||
`fleet.TestNoSubsystemIsCalledDescribe` reads the manifest and fails the build if
|
||||
one ever is. A door still on an older image answers the prefixed names; the
|
||||
refused set is untouched, because `refuse()` reads CHILD operation ids and never
|
||||
saw the door's own names.
|
||||
It used to read a BUILD-TIME catalogue — `plugin/<app>/mcp.json`, embedded by
|
||||
`plugin/embed.go` and handed to zip as `Plugin.Tools` — and `tools/list` was a
|
||||
memcpy. **Those 116 files are deleted (49,865 lines).** They were a second source
|
||||
@@ -4549,7 +4816,7 @@ interchangeable and are not:
|
||||
| relative position | 187 ahead of inc HEAD | 16 ahead of forge/main |
|
||||
|
||||
Production runs `ghcr.io/hanzoai/cloud:sha-8465354e6bf3`, and its live behavior —
|
||||
88 grouped tools, `hanzo_describe` first, 134 refused — exists **only on the inc
|
||||
88 grouped tools, `describe` first, 134 refused — exists **only on the inc
|
||||
line**. An earlier draft of § 6 and § 9 was written against a forge-line checkout
|
||||
and concluded the agent door withholds nothing and that nothing in cloud calls
|
||||
it. Both were false in production, and false in the dangerous direction:
|
||||
@@ -4885,7 +5152,7 @@ irreversible.
|
||||
|
||||
**The tool surface already refuses, and it refuses our own agents too.**
|
||||
Measured on the live door, `POST https://api.hanzo.ai/v1/mcp` `tools/list`:
|
||||
88 tools in 63,468 bytes, the first of them `hanzo_describe`, and
|
||||
88 tools in 63,468 bytes, the first of them `describe`, and
|
||||
|
||||
```
|
||||
_meta["hanzo.ai/refused"] = {count: 134, rule: "a tool is not projected when its
|
||||
@@ -5023,9 +5290,9 @@ or forgotten. Two properties come free and both matter here:
|
||||
forwarded, because a scheduled run has no inbound request and a nested one may
|
||||
be running for a different principal.
|
||||
|
||||
Every rung of § 4 is already on that door: `hanzo_analytics` publishes
|
||||
`get_v1_errors`, `hanzo_o11y` publishes 318 ops including the review queues,
|
||||
and `hanzo_tracker` and `hanzo_help` are both projected. So the ladder needs no
|
||||
Every rung of § 4 is already on that door: `analytics` publishes
|
||||
`get_v1_errors`, `o11y` publishes 318 ops including the review queues,
|
||||
and `tracker` and `help` are both projected. So the ladder needs no
|
||||
new tool surface — only the policy about which rung may be pulled without a
|
||||
human, which is § 4's job and not the door's.
|
||||
|
||||
@@ -5096,3 +5363,83 @@ from under its owner costs their work.
|
||||
ours names this pod", so a second cloud pointed at the same `SANDBOX_NAMESPACE`
|
||||
would read the first one's live sandboxes as orphans. Same invariant the stores
|
||||
already have; stated where breaking it deletes something.
|
||||
|
||||
## Metasearch: an engine that says nothing is either empty or BLIND
|
||||
|
||||
The browser service is REAL and the code said it was not. `apps/crawl` opened
|
||||
with "that service was named in config but did not exist —
|
||||
crawl.hanzo.svc.cluster.local was NXDOMAIN", and browser.go promised escalation
|
||||
was best-effort "while the browser is not deployed, which is the state this ships
|
||||
in". Measured: `svc/crawl` 10.124.54.223:11235, pod Running, `/health` 200,
|
||||
image `ghcr.io/hanzoai/crawl:sha-7b8dc59`. It has been up for days. It needs the
|
||||
bearer token that reaches both pods from KMS as `crawl-secrets/CRAWL_API_TOKEN` —
|
||||
unauthenticated it answers `{"detail":"Authentication required"}`, so a missing
|
||||
token is not a degraded render, it is no render.
|
||||
|
||||
**The defect that hid everything else: zero results was not a value.**
|
||||
`fetchEngine` returned `([]webResult, error)` and a bot-challenge page came back
|
||||
`(nil, nil)` — the same value as a query the web has no answer for. An engine
|
||||
could stop working entirely and the only symptom was a slightly shorter page.
|
||||
That is how Brave was dropped rather than fixed and how DDG sat in the default
|
||||
set contributing nothing. `outcome.go` gives an engine's turn three states:
|
||||
`answered`, `blind` (the fetch succeeded and the parser read NOTHING), `failed`
|
||||
(never reached, so it says nothing about the parser).
|
||||
|
||||
**Zero is blind per engine, always, because no engine can be trusted to report
|
||||
its own emptiness.** Bing has no zero state at all: asked three distinct nonsense
|
||||
strings it returned ten results each time — Edmonton property tax, Bastille Day,
|
||||
Microsoft support — and for a fourth, pornography. DDG and Mojeek do return zero,
|
||||
but also return zero when serving a captcha, which is the case worth catching.
|
||||
The genuine-empty case is recovered where its evidence actually lives, ACROSS
|
||||
engines: blind while a sibling answered the same query is proof the query has
|
||||
results and that engine cannot see them.
|
||||
|
||||
Two instruments, deliberately different widths. The counter
|
||||
`hanzo_websearch_engine_total{engine,outcome}` records every turn (nine series,
|
||||
bounded by construction — the query is never an attribute); an operator reads the
|
||||
RATIO. The Warn line fires only on the confirmed fault, so it stays worth
|
||||
reading. `browsed` rides along because it decides who is woken: false is
|
||||
configuration, true means a real browser drew the page and our parser still read
|
||||
nothing — selector rot, the loudest signal this package has.
|
||||
|
||||
**DDG's challenge is an HTTP 202, and that one fact cost us the engine.** The
|
||||
fetch accepted only 200, so the challenge became a transport error, the transport
|
||||
error short-circuited past the escalation, and the engine the browser was
|
||||
deployed to rescue was the one that could never reach it — `ddg=failed` on every
|
||||
live query. Any 2xx is now parsed, and there is ONE remedy for "nothing readable
|
||||
came back" whichever way it happened: render it. Live, from cluster egress, that
|
||||
turned `[bing=answered(10) ddg=failed(0) mojeek=answered(20)]` into all three
|
||||
answering, and pushed `post.ca.gov/Training` off the top of "post quantum
|
||||
cryptography lattice" (now redhat's lattice-based-cryptography, then Wikipedia).
|
||||
|
||||
**GOOGLE IS NOT VIABLE FROM THIS NETWORK. Do not add it.** Every path returns the
|
||||
`/sorry/` interstitial — ~6KB, 19 captcha markers, "unusual traffic", zero
|
||||
results: headless Crawl with stealth on, `&udm=14`, `&gbv=1`, and a HEADFUL
|
||||
Chrome 150 on a real X display (`bot-browser`) from a second egress IP. The
|
||||
control rules out the technique — that same headful browser reads DDG's ten
|
||||
results and loads google.com's homepage normally (268KB, title "Google", no
|
||||
captcha). Only `/search` is refused, from two different node IPs, headless and
|
||||
headful alike. That is reputation attached to datacenter addresses, so no browser
|
||||
flag reaches it; the fix would be residential egress, which is a different
|
||||
decision than a parser.
|
||||
|
||||
**crawl, not bot-browser, is the instrument.** They measured IDENTICALLY on both
|
||||
engines, so bot-browser buys nothing on capability and costs concurrency (one
|
||||
shared headful Chrome behind a single CDP endpoint), tenancy (one cookie jar for
|
||||
every caller) and a websocket client cloud does not have. bot-browser is an
|
||||
interactive instrument — VNC 5900 / noVNC 6080 exist so a person can watch a bot
|
||||
session — and driving search through it would serialize every query through one
|
||||
browser.
|
||||
|
||||
Costs, measured from cluster egress: a browser render of a DDG result page is
|
||||
**~1.13s** (1119/1137/1160ms, 10 results each), against 400ms–1.4s for a whole
|
||||
static three-engine blend. Escalation is worth paying on zero and not before,
|
||||
which is what render.go already did. A REFUSAL is still worth a render because
|
||||
the browser leaves the cluster from a different node than cloud does — but it
|
||||
will not rescue a refusal aimed at the browser's own address (asked to render a
|
||||
URL that had just answered it 403, Crawl returned 0 bytes).
|
||||
|
||||
Watch for `WEBSEARCH_ENGINES` in universe: it is set to `bing,ddg`, which
|
||||
excludes Mojeek — an independent index and, with DDG, one of the two engines that
|
||||
honours `site:` (`site:x.com openai` → 20 real x.com URLs on Mojeek, 10 on DDG,
|
||||
`blind` on Bing). The in-code default is now all three.
|
||||
|
||||
@@ -90,7 +90,7 @@ APP_BINS := $(addprefix bin/,$(APPS))
|
||||
# gate (check) sat behind a door with no handle.
|
||||
include mk/fleet.mk
|
||||
|
||||
.PHONY: help deploy-ui skills build cloud hanzo ship apps $(APP_BINS) plugin generate describe run dev smoke test test-fast test-cgo test-codec vet lint tidy docker docker-push compose clean e2e
|
||||
.PHONY: help deploy-ui skills build cloud hanzo ship apps $(APP_BINS) plugin generate describe run dev smoke zipdoc-check test test-fast test-cgo test-codec vet lint tidy docker docker-push compose clean e2e
|
||||
|
||||
help: ## Show this help.
|
||||
@awk 'BEGIN{FS=":.*##";printf "\nUsage: make <target>\n\nTargets:\n"} /^[a-zA-Z0-9_-]+:.*##/{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
@@ -262,10 +262,25 @@ TEST_ENV = CLOUD_KMS_MASTER_KEY_REF="$${CLOUD_KMS_MASTER_KEY_REF:-$(DEV_KMS_KEY)
|
||||
# the shipped build carries, so the suite exercises the same schema surface.
|
||||
TEST_TAGS := sqlite_fts5
|
||||
|
||||
test: ## Run unit + integration tests (pure-Go, with the FTS5 tag the image ships).
|
||||
# The lifted prose is COMMITTED (zipdoc_gen.go) because bare `go build` cannot
|
||||
# regenerate it; -check writes nothing and goes red when a lift no longer
|
||||
# matches its source, which is the drift being committed makes possible.
|
||||
# Go drops comments at compile time, so cmd/zipdoc is the ONLY path from a typed
|
||||
# handler's prose to /v1/openapi.json — the document the SDK repos and the CLI
|
||||
# read. Its output is COMMITTED, and that is what lets a bare `go build` (and the
|
||||
# release image) produce a binary that still describes itself without anyone
|
||||
# paying to lift the prose again. The image used to pay: `go generate -run zipdoc
|
||||
# ./...` ran on the build's critical path for 355.9s of a 17-minute build, to
|
||||
# reproduce 99 files that were already in the tree.
|
||||
#
|
||||
# Committed means it can go STALE, so exactly one thing has to stay true:
|
||||
# regenerating from source changes nothing. This asserts it by running THE
|
||||
# GENERATOR and diffing, rather than asking a -check mode for a second opinion —
|
||||
# a gate must never be able to disagree with the tool it polices. It is also the
|
||||
# only form that catches the case below.
|
||||
#
|
||||
# `git status --porcelain`, not `git diff`: a NEW package's zipdoc_gen.go is
|
||||
# untracked and therefore invisible to a diff, which is the failure that matters
|
||||
# most. The pathspec scopes it to the generator's own files, so an unrelated
|
||||
# dirty tree neither hides a stale lift nor invents one.
|
||||
zipdoc-check: ## Regenerate the lifted prose FROM SOURCE and fail on any diff.
|
||||
# Per PACKAGE, not ./...: the checker must load exactly the way `go generate`
|
||||
# does, one package at a time — whole-module loading extracts differently
|
||||
# (zap-proto/zip zipdoc: single-vs-module load divergence) and a gate must
|
||||
@@ -273,9 +288,13 @@ test: ## Run unit + integration tests (pure-Go, with the FTS5 tag the image ship
|
||||
# A dot-directory is not this module's source. An agent worktree at
|
||||
# .claude/worktrees/<id>/ is a whole second checkout of this repository, and
|
||||
# the walk read it: 203 packages where there are 104, and it went red on a
|
||||
# copy's o11y while nothing here had changed. Same rule the source-walking
|
||||
# gates in Go state (typed_request_gate_test.go, orgns_test.go).
|
||||
@set -e; for d in $$(grep -rl '^//go:generate go run github.com/zap-proto/zip/cmd/zipdoc' --include='*.go' --exclude-dir='.?*' clients cmd . 2>/dev/null | xargs -n1 dirname | sort -u); do (cd $$d && $(GO) run github.com/zap-proto/zip/cmd/zipdoc -check) || { echo "$$d/zipdoc_gen.go is stale — run: go generate -run zipdoc ./$$d/..."; exit 1; }; done
|
||||
# copy's o11y while nothing here had changed.
|
||||
@set -e; for d in $$(grep -rl '^//go:generate go run github.com/zap-proto/zip/cmd/zipdoc' --include='*.go' --exclude-dir='.?*' clients cmd . 2>/dev/null | xargs -n1 dirname | sort -u); do \
|
||||
(cd $$d && $(GO) run github.com/zap-proto/zip/cmd/zipdoc -check) || { echo "$$d/zipdoc_gen.go is stale — run: go generate -run zipdoc ./$$d/..."; exit 1; }; \
|
||||
done
|
||||
|
||||
test: ## Run unit + integration tests (pure-Go, with the FTS5 tag the image ships).
|
||||
$(MAKE) zipdoc-check
|
||||
$(TEST_ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) test -tags "$(TEST_TAGS)" ./...
|
||||
# The drift gate: regenerate the document FROM SOURCE and fail on any diff.
|
||||
# The weave above proves the subsets compose; this proves they are still the
|
||||
@@ -293,9 +312,7 @@ test-fast: ## Everything `test` runs except the spec drift gate. Inner loop only
|
||||
@echo ">> test-fast: NOT checking spec drift (openapi.yaml + plugin/*/openapi.json)."
|
||||
@echo ">> a route added without regenerating will pass here and fail CI."
|
||||
@echo ">> the real gate: make -f mk/fleet.mk check"
|
||||
@set -e; for d in $$(grep -rl '^//go:generate go run github.com/zap-proto/zip/cmd/zipdoc' --include='*.go' --exclude-dir='.?*' clients cmd . 2>/dev/null | xargs -n1 dirname | sort -u); do \
|
||||
(cd $$d && $(GO) run github.com/zap-proto/zip/cmd/zipdoc -check) || { echo "$$d/zipdoc_gen.go is stale — run: go generate -run zipdoc ./$$d/..."; exit 1; }; \
|
||||
done
|
||||
$(MAKE) zipdoc-check
|
||||
$(TEST_ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) test -tags "$(TEST_TAGS)" ./...
|
||||
|
||||
# THE spec, in three steps, in the only order they work in:
|
||||
@@ -312,6 +329,12 @@ test-fast: ## Everything `test` runs except the spec drift gate. Inner loop only
|
||||
# 3. the weave composes those subsets into openapi.yaml (openapi/weave.go),
|
||||
# refusing when two apps claim one path or one schema name. There is no
|
||||
# monolith left to read: the woven document IS the published spec.
|
||||
# 4. the same run projects that document twice and writes public.yaml beside it
|
||||
# (openapi/public.go): the PUBLIC contract, which is the operations that
|
||||
# DECLARED themselves part of it and nothing else. Default-deny — an
|
||||
# operation that says nothing is internal, so a product cannot reach a
|
||||
# published SDK by anyone forgetting. openapi.yaml stays the internal
|
||||
# document, admin included, and is what our own clients are cut from.
|
||||
#
|
||||
# openapi.yaml is a golden file: written here, and verified two different ways —
|
||||
# and the difference between them is the whole lesson.
|
||||
|
||||
+77
-53
@@ -1,38 +1,73 @@
|
||||
package cloud
|
||||
|
||||
// Inference reached over the peer's own socket.
|
||||
// Where a sibling process reaches the model API.
|
||||
//
|
||||
// `ai` is a plugin of this same binary running as its own process. Its routes
|
||||
// ride its unix socket exactly as they ride a public listener — zip's plane is
|
||||
// "an ordinary route on the app … ZAP over a unix socket is simply the address
|
||||
// the caller dialed" — so a sibling speaks the ordinary OpenAI-compatible wire
|
||||
// to it WITHOUT leaving the host.
|
||||
// `ai` is a plugin of this same binary running as its own process, and the
|
||||
// question this file answers is the narrow one: from ANOTHER process of the same
|
||||
// fleet, what address serves /v1/chat/completions?
|
||||
//
|
||||
// What that deletes is the whole reason the old path existed:
|
||||
// # What the plane socket is, and what it is not
|
||||
//
|
||||
// base_url https://api.hanzo.ai/v1 the pod's OWN public address
|
||||
// token_url http://iam.hanzo.svc/… a token minted to authenticate to itself
|
||||
// This used to dial the peer's canonical socket — zip.SocketPath("ai"),
|
||||
// /var/lib/cloud/run/ai.sock — and speak ordinary HTTP to it, on the belief that
|
||||
// "an app's routes ride its unix socket exactly as they ride a public listener".
|
||||
// That belief is wrong twice, and each half is independently fatal.
|
||||
//
|
||||
// Both were consequences of addressing a peer by URL. There is no address to
|
||||
// configure here: the socket is derived from the app NAME, the same mapping the
|
||||
// meter and the ledger already use.
|
||||
// THE WIRE IS NOT HTTP. That socket is served by zaphttp.Server (zip
|
||||
// transport.go: the "zap" scheme, the default for a bare address) — a framed
|
||||
// binary protocol with its own codec. A cleartext HTTP request is not slower
|
||||
// there, it is unintelligible: the peer reads a malformed frame and closes, so
|
||||
// the caller gets `Post "http://ai/v1/chat/completions": EOF` on every request,
|
||||
// any method, any path, first connection, peer perfectly healthy.
|
||||
//
|
||||
// THE SURFACE IS NOT THE APP'S. What binds there is the app's PLANE — the
|
||||
// typed-op door at /.well-known/zip/op/<name>, which is what plane.Ask uses
|
||||
// (plane/ask.go: "ServePlane binds before the app's own listener"). The app's
|
||||
// own HTTP routes are on a listener the plane socket knows nothing about, so
|
||||
// /v1/chat/completions is a 404 there even when the wire is spoken correctly.
|
||||
// Measured on a healthy pod: over ZAP, ai.sock answers 404 for /v1/models and
|
||||
// /v1/chat/completions alike, while ai's own listener answers 200 and 401.
|
||||
//
|
||||
// Together they are why @hanzo in Slack answered "the agent hit an error handling
|
||||
// that": the model call EOF'd, agents recorded an honest error-status run, and the
|
||||
// bridge turned that into its generic reply. `ai` never logged the request because
|
||||
// the request never arrived.
|
||||
//
|
||||
// # The address that does serve it
|
||||
//
|
||||
// The fleet ROUTER's own HTTP listener — the one CLOUD_LISTEN names and
|
||||
// api.hanzo.ai is merely the public face of. It owns the route table that sends
|
||||
// /v1/* to `ai`, and it owns starting a cold app, so reaching the model API
|
||||
// through it is not a special case: it is the same door every external caller
|
||||
// uses, entered from inside.
|
||||
//
|
||||
// On LOOPBACK, which is the whole point. The router runs in this pod, so
|
||||
// 127.0.0.1 never leaves the network namespace: no DNS, no Service hop, and
|
||||
// above all no trip out through Cloudflare and back to the pod's own public
|
||||
// address, which is what the configured base URL (https://api.hanzo.ai/v1) does
|
||||
// and what made a completion depend on the edge being willing to loop. The
|
||||
// credential is unchanged — same static key or same M2M identity, chosen the
|
||||
// same way by the pickers in build.go — because who may ask is a different
|
||||
// question from where the peer is.
|
||||
//
|
||||
// There is deliberately NO second mechanism here. A raw route reached
|
||||
// process-to-process is not something this fleet offers; ops are (plane.Ask), and
|
||||
// inventing a parallel path for the one surface that is not an op is what broke
|
||||
// it. One door, entered from inside.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// aiApp is the app name the socket is derived from. One spelling.
|
||||
// aiApp is the app name this decision is about. One spelling.
|
||||
const aiApp = "ai"
|
||||
|
||||
// aiPeerURL is the base a socket-dialed call carries. The HOST is inert — the
|
||||
// transport dials a named peer, not this address — so it names the peer for logs
|
||||
// and error text and nothing more. The /v1 prefix is real: it is the peer's own
|
||||
// route prefix.
|
||||
const aiPeerURL = "http://ai/v1"
|
||||
// aiLoopbackPort is the port assumed when the listener address names none. It
|
||||
// matches config.go's own default for CLOUD_LISTEN, so the two cannot drift into
|
||||
// disagreeing about where this binary listens.
|
||||
const aiLoopbackPort = "8080"
|
||||
|
||||
// aiRoute answers the two questions a caller has about reaching `ai`: over what
|
||||
// transport, and under what address. It is ONE decision, shared by the
|
||||
@@ -40,44 +75,33 @@ const aiPeerURL = "http://ai/v1"
|
||||
// about where the peer is.
|
||||
//
|
||||
// !Enabled(ai) means this process does not carry the app, which is exactly when
|
||||
// `ai` is a SIBLING and its socket is the honest address. The process that IS
|
||||
// `ai` keeps the configured one — routing inference back through the picker
|
||||
// there would be the process calling itself.
|
||||
// `ai` is a SIBLING and the router's loopback listener is the honest address. The
|
||||
// process that IS `ai` keeps the configured one — routing inference back through
|
||||
// the picker there would be the process calling itself.
|
||||
//
|
||||
// The transport is nil in both branches: an ordinary HTTP address is reached with
|
||||
// the ordinary transport, and the pickers' "socket" log field reads false because
|
||||
// no socket is involved. Neither branch needs a custom RoundTripper — waking a
|
||||
// cold app is the router's job, and doing it again here would be a second
|
||||
// mechanism for something that already has one.
|
||||
func aiRoute(cfg *Config) (http.RoundTripper, string) {
|
||||
if cfg.Enabled(aiApp) {
|
||||
return nil, cfg.AIBaseURL
|
||||
}
|
||||
return newSocketTransport(aiApp), aiPeerURL
|
||||
return nil, aiLoopbackURL(cfg)
|
||||
}
|
||||
|
||||
// socketRoundTripper speaks HTTP to one app over its canonical unix socket.
|
||||
// aiLoopbackURL is the router's HTTP listener as seen from inside its own pod.
|
||||
//
|
||||
// It WAKES the peer before dialing, through the same reach() every plane call
|
||||
// uses: an app is lazy by default, so a sibling that dialed a cold socket would
|
||||
// read "not deployed here" from what is really "not started yet". reach asks the
|
||||
// router, which owns the manifest, so absence and outage stay distinguishable.
|
||||
type socketRoundTripper struct {
|
||||
app string
|
||||
next http.RoundTripper
|
||||
}
|
||||
|
||||
func newSocketTransport(app string) http.RoundTripper {
|
||||
srt := &socketRoundTripper{app: app}
|
||||
srt.next = &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
// network and address are DISCARDED: the peer is named, not addressed.
|
||||
// Whatever host the base URL carries is inert here, which is why the
|
||||
// deployment no longer states one.
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", zip.SocketPath(srt.app))
|
||||
},
|
||||
// Only the PORT is taken from the configured listener: the host half is whatever
|
||||
// the process binds (":8000", "0.0.0.0:8000"), and neither is an address a client
|
||||
// may dial. 127.0.0.1 is, and it is the one that cannot leave the pod.
|
||||
func aiLoopbackURL(cfg *Config) string {
|
||||
port := aiLoopbackPort
|
||||
if addr := strings.TrimSpace(cfg.ListenAddr); addr != "" {
|
||||
if _, p, err := net.SplitHostPort(addr); err == nil && p != "" {
|
||||
port = p
|
||||
}
|
||||
}
|
||||
return srt
|
||||
}
|
||||
|
||||
func (s *socketRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
bindRuntimeDir()
|
||||
if err := reach(r.Context(), s.app); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.next.RoundTrip(r)
|
||||
return "http://127.0.0.1:" + port + "/v1"
|
||||
}
|
||||
|
||||
+51
-23
@@ -1,42 +1,69 @@
|
||||
package cloud
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A SIBLING REACHES `ai` OVER ITS SOCKET, NOT THROUGH THE INTERNET.
|
||||
// A SIBLING REACHES `ai` THROUGH THE ROUTER ON LOOPBACK.
|
||||
//
|
||||
// `ai` is a plugin of this same binary running as its own process. Addressing it
|
||||
// by its public URL sent a completion out through Cloudflare and back, and made
|
||||
// the pod mint an OAuth token to authenticate to its own deployment. Which
|
||||
// transport a process gets is decided by WHAT IT IS, never by configuration.
|
||||
func TestSiblingReachesAIOverItsSocket(t *testing.T) {
|
||||
sibling := &Config{Enable: []string{"agents"}, AIBaseURL: "https://api.hanzo.ai/v1"}
|
||||
// Two addresses are wrong here and this pins both.
|
||||
//
|
||||
// The pod's own PUBLIC url sends a completion out through Cloudflare and back,
|
||||
// and makes the pod mint an OAuth token to authenticate to its own deployment.
|
||||
//
|
||||
// The peer's PLANE SOCKET (zip.SocketPath("ai")) cannot serve it at all: that
|
||||
// socket speaks ZAP, not HTTP, and carries the typed-op door rather than the
|
||||
// app's routes — so a raw /v1 request there is first unintelligible and then, if
|
||||
// framed correctly, a 404. Reaching it that way is what made @hanzo answer "the
|
||||
// agent hit an error handling that" for every Slack turn.
|
||||
//
|
||||
// What is left is the router's own listener, entered on 127.0.0.1 so it never
|
||||
// leaves the pod. Which address a process gets is decided by WHAT IT IS, never
|
||||
// by configuration.
|
||||
func TestSiblingReachesAIThroughTheRouterOnLoopback(t *testing.T) {
|
||||
sibling := &Config{Enable: []string{"agents"}, AIBaseURL: "https://api.hanzo.ai/v1", ListenAddr: ":8000"}
|
||||
|
||||
via, base := aiRoute(sibling)
|
||||
if via == nil {
|
||||
t.Error("a sibling took the default transport — it would leave the host to reach a peer")
|
||||
if via != nil {
|
||||
t.Errorf("a sibling got a custom transport (%T) — an ordinary address is reached with the ordinary transport", via)
|
||||
}
|
||||
if base == "https://api.hanzo.ai/v1" {
|
||||
t.Error("a sibling addressed `ai` by the pod's OWN public URL")
|
||||
t.Error("a sibling addressed `ai` by the pod's OWN public URL — that leaves the host and comes back through the edge")
|
||||
}
|
||||
if base != aiPeerURL {
|
||||
t.Errorf("sibling base = %q, want the named peer %q", base, aiPeerURL)
|
||||
if want := "http://127.0.0.1:8000/v1"; base != want {
|
||||
t.Errorf("sibling base = %q, want the router on loopback %q", base, want)
|
||||
}
|
||||
srt, ok := via.(*socketRoundTripper)
|
||||
if !ok {
|
||||
t.Fatalf("transport is %T, want the socket one", via)
|
||||
}
|
||||
|
||||
// The port is READ from the configured listener rather than assumed, or a
|
||||
// deployment that moves its listener would send every completion to a closed port.
|
||||
func TestSiblingFollowsTheConfiguredListenerPort(t *testing.T) {
|
||||
for _, listen := range []string{":9100", "0.0.0.0:9100", "127.0.0.1:9100"} {
|
||||
_, base := aiRoute(&Config{Enable: []string{"agents"}, ListenAddr: listen})
|
||||
if want := "http://127.0.0.1:9100/v1"; base != want {
|
||||
t.Errorf("ListenAddr %q → %q, want %q", listen, base, want)
|
||||
}
|
||||
}
|
||||
if srt.app != aiApp {
|
||||
t.Errorf("socket targets %q, want %q — the peer is NAMED, never addressed", srt.app, aiApp)
|
||||
}
|
||||
|
||||
// A sibling never dials the peer's plane socket. That door is zip's typed-op
|
||||
// plane (plane.Ask), it does not speak HTTP, and the app's own routes are not on
|
||||
// it — so naming it here can only ever produce an EOF or a 404.
|
||||
func TestSiblingNeverDialsThePlaneSocket(t *testing.T) {
|
||||
_, base := aiRoute(&Config{Enable: []string{"agents"}, ListenAddr: ":8000"})
|
||||
if strings.Contains(base, ".sock") || strings.HasPrefix(base, "http://ai") {
|
||||
t.Errorf("sibling base = %q — that is the plane socket, which serves ops and not /v1", base)
|
||||
}
|
||||
}
|
||||
|
||||
// The process that IS `ai` keeps the configured address: routing inference back
|
||||
// through the picker there would be the process calling itself.
|
||||
func TestTheAIProcessDoesNotDialItself(t *testing.T) {
|
||||
self := &Config{Enable: []string{"ai"}, AIBaseURL: "https://api.hanzo.ai/v1"}
|
||||
self := &Config{Enable: []string{"ai"}, AIBaseURL: "https://api.hanzo.ai/v1", ListenAddr: ":8000"}
|
||||
via, base := aiRoute(self)
|
||||
if via != nil {
|
||||
t.Error("the ai process resolved itself to its own socket — it would call itself")
|
||||
t.Error("the ai process got a custom transport — it would call itself")
|
||||
}
|
||||
if base != "https://api.hanzo.ai/v1" {
|
||||
t.Errorf("ai process base = %q, want its configured address", base)
|
||||
@@ -45,8 +72,9 @@ func TestTheAIProcessDoesNotDialItself(t *testing.T) {
|
||||
|
||||
// The host carries every app, so it is not a sibling either.
|
||||
func TestTheHostIsNotASibling(t *testing.T) {
|
||||
host := &Config{AIBaseURL: "https://api.hanzo.ai/v1"} // empty Enable = carries all
|
||||
if via, _ := aiRoute(host); via != nil {
|
||||
t.Error("the host took the sibling path while carrying `ai` itself")
|
||||
host := &Config{AIBaseURL: "https://api.hanzo.ai/v1", ListenAddr: ":8000"} // empty Enable = carries all
|
||||
_, base := aiRoute(host)
|
||||
if base != "https://api.hanzo.ai/v1" {
|
||||
t.Errorf("host base = %q, want its configured address", base)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/hanzoai/cloud/apps/sites"
|
||||
"github.com/zap-proto/zip"
|
||||
"github.com/zap-proto/zip/middleware"
|
||||
@@ -29,16 +33,18 @@ import (
|
||||
// the kernel answers which process is calling, and the boundary's findings travel
|
||||
// with the request.
|
||||
//
|
||||
// name is what the program calls itself in a diagnostic. tools is the MCP surface,
|
||||
// which only a program holding a subsystem list can project — everyone else passes
|
||||
// nil and serves none.
|
||||
// name is what the program calls itself in a diagnostic. tools is the per-caller
|
||||
// half of this program's agent door — the tools that exist because of WHO is
|
||||
// asking, which only a program holding a subsystem list can declare; everyone
|
||||
// else passes nil and offers none. The door itself is not optional either way:
|
||||
// see [callerTools].
|
||||
func App(name string, cfg *Config, deps Deps, tools zip.Source) *zip.App {
|
||||
app := zip.New(zip.Config{
|
||||
AppName: name,
|
||||
Logger: deps.Logger,
|
||||
ReadBufferSize: cfg.ReadBufferSize,
|
||||
BodyLimit: cfg.BodyLimit,
|
||||
MCP: zip.MCPConfig{Source: tools},
|
||||
MCP: zip.MCPConfig{Source: callerTools(tools)},
|
||||
// Cloud's refusal renderer, in place of zip's default — which reads only a
|
||||
// *zip.HTTPError and answers 500 for everything else, so a propagated 402
|
||||
// or 403 reached the console as a dead card. See errmap.go.
|
||||
@@ -150,6 +156,56 @@ func App(name string, cfg *Config, deps Deps, tools zip.Source) *zip.App {
|
||||
return app
|
||||
}
|
||||
|
||||
// callerTools is this program's per-caller tool half — and stating it, rather
|
||||
// than leaving it nil, is what makes the agent door UNCONDITIONAL.
|
||||
//
|
||||
// zip mounts the door only for an app that has something to project: a typed op,
|
||||
// a composed plugin's catalogue, or a per-caller Source. With all three absent it
|
||||
// returns before registering the route at all (zip@v1.25.1 mcp.go:99). That is
|
||||
// the right default for a program nobody interrogates, and the wrong one for
|
||||
// every program built here, because the fleet's door ASKS EVERY COMPOSED
|
||||
// SUBSYSTEM on each tools/list (fleet.Ask). A subsystem whose routes are all raw
|
||||
// — a reverse proxy, or a surface owned by another module — projects no typed op,
|
||||
// so nothing claimed POST /mcp in its process, so the ask fell through to the
|
||||
// console's terminal handler and was answered with the signpost that is correct
|
||||
// only on the front door: 308 → /v1/mcp, an address a child does not serve
|
||||
// (webui/mcp.go:44). Thirty of the fleet's subsystems — the whole of exec, tasks,
|
||||
// agent, ask, websearch, crawl, index, kms, billing, platform and twenty more —
|
||||
// were reported UNREACHABLE that way while every one of them was up, healthy and
|
||||
// serving its REST surface.
|
||||
//
|
||||
// A door whose registry is empty answers {"tools":[]}, and that is a REAL answer:
|
||||
// "asked, and serves nothing" is a different fact from "could not be asked", and
|
||||
// keeping those two apart is the whole of package fleet. Its hanzo.ai/unavailable
|
||||
// list means nothing while a healthy subsystem has no way to say the first one.
|
||||
//
|
||||
// nil in, empty out. A program that declares no Plugin.Door still HAS a
|
||||
// per-caller half; it simply holds no tools. It is consulted once per tools/list
|
||||
// that names an org, answers nothing, and zip then returns the same pre-rendered
|
||||
// bytes it always did — the memcpy that makes tools/list free is untouched (zip
|
||||
// listTools: len(mine) == 0 ⇒ the build-time array, verbatim).
|
||||
func callerTools(declared zip.Source) zip.Source {
|
||||
if declared != nil {
|
||||
return declared
|
||||
}
|
||||
return noCallerTools{}
|
||||
}
|
||||
|
||||
// noCallerTools is the per-caller half of a program that declares none: no tools
|
||||
// exist because of who is asking, and a name nobody projected is nobody's.
|
||||
//
|
||||
// Its Call is reached only for a name the build-time catalogue did not claim, and
|
||||
// it answers with the same sentence zip's own miss does — the fleet's door never
|
||||
// routes one here (it refuses an unlisted name itself, fleet/mcp.go), so this is
|
||||
// the reply to a client that guessed.
|
||||
type noCallerTools struct{}
|
||||
|
||||
func (noCallerTools) Tools(context.Context) []map[string]any { return nil }
|
||||
|
||||
func (noCallerTools) Call(_ context.Context, name string, _ json.RawMessage) (any, error) {
|
||||
return nil, fmt.Errorf("unknown tool: %s", name)
|
||||
}
|
||||
|
||||
// Identify gives an app a trustworthy answer to who is calling, and makes that
|
||||
// answer reachable from every route beneath it. App does this for every program,
|
||||
// which is the only reason it can no longer be skipped.
|
||||
|
||||
+266
-77
@@ -370,9 +370,14 @@ type affiliateStanding struct {
|
||||
// to a caller that has not applied.
|
||||
DefaultRateBps int64 `json:"defaultRateBps,omitempty"`
|
||||
// Handle is the opt-in public leaderboard name; empty means opted out.
|
||||
Handle *string `json:"handle,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
IsAffiliate bool `json:"isAffiliate"`
|
||||
Handle *string `json:"handle,omitempty"`
|
||||
// ID is the affiliate's server-minted handle, "aff_"-prefixed — what staff
|
||||
// approve, suspend, re-rate and pay against. Absent until the org applies.
|
||||
ID string `json:"id,omitempty"`
|
||||
// IsAffiliate says whether the caller org has an affiliate record at all. It is
|
||||
// the ONE field an org that never applied gets besides defaultRateBps: on false,
|
||||
// read nothing else here — every other field is absent, not zero.
|
||||
IsAffiliate bool `json:"isAffiliate"`
|
||||
// Link is the shareable ?aff URL; empty until a code is minted.
|
||||
Link *string `json:"link,omitempty"`
|
||||
// MarginBps is the platform gross-margin fraction commission is a rate OF.
|
||||
@@ -386,9 +391,15 @@ type affiliateStanding struct {
|
||||
// RateBps is the affiliate's own direct commission rate, in basis points.
|
||||
RateBps *int64 `json:"rateBps,omitempty"`
|
||||
// ReferredCount is how many orgs this affiliate has referred.
|
||||
ReferredCount *int `json:"referredCount,omitempty"`
|
||||
ReferredCount *int `json:"referredCount,omitempty"`
|
||||
// RequestedCode is the vanity code asked for at apply time — a request, not an
|
||||
// allocation. Approval mints `code`, which may be a different slug if this one
|
||||
// was already taken.
|
||||
RequestedCode *string `json:"requestedCode,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
// Status is "applied", "approved" or "suspended". Only an approved affiliate has
|
||||
// a code that resolves for attribution and accrues commission; suspended keeps
|
||||
// what it already earned but stops earning more.
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// standing answers the caller org's OWN affiliate standing: status, referral
|
||||
@@ -449,9 +460,18 @@ func (o ops) standing(ctx context.Context, _ *noInput) (*affiliateStanding, erro
|
||||
// level (1=direct, 2, 3), the commission rate paid at that level, and how many orgs
|
||||
// sit at that level below the affiliate.
|
||||
type levelView struct {
|
||||
Level int `json:"level"`
|
||||
RateBps int64 `json:"rateBps"`
|
||||
DownlineCount int `json:"downlineCount"`
|
||||
// Level is the upline distance from the org whose spend is being shared: 1 is
|
||||
// the direct referrer, 2 and 3 the referrers above it. Nothing accrues past 3.
|
||||
Level int `json:"level"`
|
||||
// RateBps is the commission paid at this level, in basis points OF Hanzo's
|
||||
// margin (2000 = 20% of margin, never of the customer's bill). Level 1 is the
|
||||
// affiliate's own negotiated rate; 2 and 3 are platform switches read live, so
|
||||
// this is the schedule actually in force, not one compiled in.
|
||||
RateBps int64 `json:"rateBps"`
|
||||
// DownlineCount is how many orgs sit exactly this many hops below the caller. It
|
||||
// is 0 in the schedule quoted to a caller that has not applied, which has no
|
||||
// downline to count.
|
||||
DownlineCount int `json:"downlineCount"`
|
||||
}
|
||||
|
||||
// affiliateSelf is the richer /me self-view: the standing plus the downline
|
||||
@@ -459,25 +479,53 @@ type levelView struct {
|
||||
// enrolled and the not-enrolled shape; a non-enrolled caller gets the level
|
||||
// SCHEDULE instead of a downline, so the console can show what it would earn.
|
||||
type affiliateSelf struct {
|
||||
AccruedCents *int64 `json:"accruedCents,omitempty"`
|
||||
Code *string `json:"code,omitempty"`
|
||||
DefaultRateBps int64 `json:"defaultRateBps,omitempty"`
|
||||
// AccruedCents is lifetime commission accrued, in cents. It only grows — a
|
||||
// payout is recorded against paidCents and never reduces this.
|
||||
AccruedCents *int64 `json:"accruedCents,omitempty"`
|
||||
// Code is the minted referral code, the slug the ?aff link carries. Absent until
|
||||
// staff approve; codes live in ONE global namespace across all affiliates.
|
||||
Code *string `json:"code,omitempty"`
|
||||
// DefaultRateBps is the direct rate a new affiliate starts at, in basis points
|
||||
// of margin (2000 = 20%). Answered ONLY to a caller that has not applied, as the
|
||||
// quote beside `schedule`.
|
||||
DefaultRateBps int64 `json:"defaultRateBps,omitempty"`
|
||||
// DownlineTotal counts every org in the caller's downline across the levels.
|
||||
DownlineTotal *int `json:"downlineTotal,omitempty"`
|
||||
Handle *string `json:"handle,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
IsAffiliate bool `json:"isAffiliate"`
|
||||
DownlineTotal *int `json:"downlineTotal,omitempty"`
|
||||
// Handle is the opt-in public leaderboard name. Empty means opted out: the
|
||||
// caller keeps its rank and still sees its own row, it is just not listed.
|
||||
Handle *string `json:"handle,omitempty"`
|
||||
// ID is the affiliate's server-minted handle, "aff_"-prefixed. Absent until the
|
||||
// org applies.
|
||||
ID string `json:"id,omitempty"`
|
||||
// IsAffiliate says whether the caller org has an affiliate record. On false the
|
||||
// answer carries the rate SCHEDULE and the default rate instead of a downline,
|
||||
// so the console can show what the caller would earn.
|
||||
IsAffiliate bool `json:"isAffiliate"`
|
||||
// Levels is the caller's downline per upline level, with the rate paid there.
|
||||
Levels []levelView `json:"levels,omitempty"`
|
||||
Link *string `json:"link,omitempty"`
|
||||
MarginBps *int64 `json:"marginBps,omitempty"`
|
||||
PaidCents *int64 `json:"paidCents,omitempty"`
|
||||
Payouts *[]remittance `json:"payouts,omitempty"`
|
||||
PendingCents *int64 `json:"pendingCents,omitempty"`
|
||||
RateBps *int64 `json:"rateBps,omitempty"`
|
||||
Levels []levelView `json:"levels,omitempty"`
|
||||
// Link is the shareable ?aff URL built from the code. Empty until a code is
|
||||
// minted, since there is nothing to share before approval.
|
||||
Link *string `json:"link,omitempty"`
|
||||
// MarginBps is the platform gross-margin fraction, in basis points, that every
|
||||
// rate here is a rate OF. Read live per request, so it is the value in force
|
||||
// now, not the one that applied to commission already accrued.
|
||||
MarginBps *int64 `json:"marginBps,omitempty"`
|
||||
// PaidCents is lifetime commission already paid out, in cents — credits grants
|
||||
// and record-only cash disbursements alike.
|
||||
PaidCents *int64 `json:"paidCents,omitempty"`
|
||||
// Payouts is the payout history, newest first, bounded to the last 100 rows.
|
||||
Payouts *[]remittance `json:"payouts,omitempty"`
|
||||
// PendingCents is accrued minus paid, in cents — what the platform still owes
|
||||
// and the ceiling on the next payout. Never negative.
|
||||
PendingCents *int64 `json:"pendingCents,omitempty"`
|
||||
// RateBps is the caller's OWN direct (level 1) commission rate, in basis points
|
||||
// of margin. Levels 2 and 3 are platform-wide and appear in `levels`.
|
||||
RateBps *int64 `json:"rateBps,omitempty"`
|
||||
// Schedule is the rate schedule quoted to a caller that has not applied.
|
||||
Schedule []levelView `json:"schedule,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
// Status is "applied", "approved" or "suspended"; absent for a caller that never
|
||||
// applied. Only "approved" mints links and accrues.
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// self answers the richer self-view: the same lifetime accrued, pending and paid
|
||||
@@ -574,12 +622,25 @@ type applyRequest struct {
|
||||
// call made the row, and the answer's status states the same fact on the wire —
|
||||
// 201 for the first apply, 200 for a re-apply.
|
||||
type application struct {
|
||||
Code string `json:"code"`
|
||||
Created bool `json:"created"`
|
||||
ID string `json:"id"`
|
||||
RateBps int64 `json:"rateBps"`
|
||||
// Code is the minted referral code. Empty on a first apply — applying does not
|
||||
// mint a code, approval does; a re-apply echoes whatever the row already holds.
|
||||
Code string `json:"code"`
|
||||
// Created says whether THIS call made the row. false means the org had already
|
||||
// applied and nothing changed — no second row, no reset of an existing approval.
|
||||
// The HTTP status states the same fact: 201 when true, 200 when false.
|
||||
Created bool `json:"created"`
|
||||
// ID is the affiliate's server-minted handle, "aff_"-prefixed — the id staff
|
||||
// approve, suspend, re-rate and pay against.
|
||||
ID string `json:"id"`
|
||||
// RateBps is the direct (level 1) commission rate the row carries, in basis
|
||||
// points OF Hanzo's margin (2000 = 20% of margin, never of the customer's bill).
|
||||
RateBps int64 `json:"rateBps"`
|
||||
// RequestedCode echoes the vanity code asked for, normalized to lower case. It
|
||||
// is a request only: approval mints a different slug if this one is taken.
|
||||
RequestedCode string `json:"requestedCode"`
|
||||
Status string `json:"status"`
|
||||
// Status is "applied" for a row this call created. A re-apply echoes the
|
||||
// existing row's status, which may already be "approved" or "suspended".
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// StatusCode states which declared status this answer is: 201 on the first
|
||||
@@ -647,10 +708,19 @@ type attributeRequest struct {
|
||||
// attribution is the recorded affiliate↔referred-org edge. Created states
|
||||
// whether this call made it; the status states the same fact on the wire.
|
||||
type attribution struct {
|
||||
Code string `json:"code"`
|
||||
Created bool `json:"created"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
ID string `json:"id"`
|
||||
// Code is the affiliate code the edge was recorded under, normalized to lower
|
||||
// case. On a re-post it is the code of the STANDING edge, which may differ from
|
||||
// the one just sent — first touch wins.
|
||||
Code string `json:"code"`
|
||||
// Created says whether THIS call made the edge. false means the caller org was
|
||||
// already attributed and nothing moved. The HTTP status says the same: 201 when
|
||||
// true, 200 when false.
|
||||
Created bool `json:"created"`
|
||||
// CreatedAt is when the edge was FIRST recorded, Unix seconds UTC. On a re-post
|
||||
// it is the original time, not now.
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
// ID is the attribution edge's server-minted handle, "afr_"-prefixed.
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// StatusCode states which declared status this answer is: 201 for a new edge,
|
||||
@@ -746,7 +816,11 @@ func (o ops) attribute(ctx context.Context, in *attributeRequest) (*attribution,
|
||||
// cloud.OK writes, stated as a type so a typed op can declare it. The customer
|
||||
// /v1/affiliates surface stays bare JSON (read via the /cloud proxy + restGet).
|
||||
type envelope struct {
|
||||
Msg string `json:"msg"`
|
||||
// Msg is an operator-facing note. Empty on every success here — it exists
|
||||
// because the console's admin unwrapper reads the shape cloud.OK writes.
|
||||
Msg string `json:"msg"`
|
||||
// Status is "ok" on every 2xx from this surface; a failure is an HTTP error with
|
||||
// zip's error body, not this envelope carrying a different word.
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
@@ -763,12 +837,17 @@ type page struct {
|
||||
// directoryData is the admin directory: every affiliate with its org exposed,
|
||||
// plus the fleet summary.
|
||||
type directoryData struct {
|
||||
// Affiliates is one row per affiliate across the whole fleet, ORG EXPOSED,
|
||||
// oldest first and bounded by the request's limit.
|
||||
Affiliates []adminAffiliateView `json:"affiliates"`
|
||||
Summary totals `json:"summary"`
|
||||
// Summary tallies exactly the rows above — not the whole table — so a limit that
|
||||
// truncates the page truncates the tally with it.
|
||||
Summary totals `json:"summary"`
|
||||
}
|
||||
|
||||
// directoryOut is the enveloped GET /v1/admin/affiliates answer.
|
||||
type directoryOut struct {
|
||||
// Data is the affiliate directory and its tally.
|
||||
Data directoryData `json:"data"`
|
||||
envelope
|
||||
}
|
||||
@@ -805,12 +884,22 @@ func (o ops) adminList(ctx context.Context, in *page) (*directoryOut, error) {
|
||||
|
||||
// referrerRow is one row of the top-referrers leaderboard on the analytics board.
|
||||
type referrerRow struct {
|
||||
Org string `json:"org"`
|
||||
Code string `json:"code"`
|
||||
Status string `json:"status"`
|
||||
ReferredCount int `json:"referredCount"`
|
||||
AccruedCents int64 `json:"accruedCents"`
|
||||
PendingCents int64 `json:"pendingCents"`
|
||||
// Org is the partner's own org slug. Named only here, on the SuperAdmin board —
|
||||
// the partner-facing leaderboard shows an opt-in handle and never an org.
|
||||
Org string `json:"org"`
|
||||
// Code is that affiliate's minted referral code; empty if it is not approved.
|
||||
Code string `json:"code"`
|
||||
// Status is "applied", "approved" or "suspended".
|
||||
Status string `json:"status"`
|
||||
// ReferredCount is how many orgs this affiliate is the DIRECT referrer of —
|
||||
// its level-1 downline, not the whole three-level chain.
|
||||
ReferredCount int `json:"referredCount"`
|
||||
// AccruedCents is lifetime commission accrued, in cents. The board is sorted by
|
||||
// this, descending.
|
||||
AccruedCents int64 `json:"accruedCents"`
|
||||
// PendingCents is accrued minus paid, in cents — what is still owed to this
|
||||
// affiliate. Never negative.
|
||||
PendingCents int64 `json:"pendingCents"`
|
||||
}
|
||||
|
||||
// topReferrersLimit bounds the leaderboard on the analytics board.
|
||||
@@ -819,38 +908,68 @@ const topReferrersLimit = 25
|
||||
// tally is the analytics board's fleet tally. pendingLiabilityCents is
|
||||
// what the platform owes but has not paid.
|
||||
type tally struct {
|
||||
AccruedLifetimeCents int64 `json:"accruedLifetimeCents"`
|
||||
Affiliates int `json:"affiliates"`
|
||||
Approved int `json:"approved"`
|
||||
PaidLifetimeCents int64 `json:"paidLifetimeCents"`
|
||||
// AccruedLifetimeCents is all commission ever accrued, summed across every
|
||||
// affiliate, in cents. It only grows; a payout does not reduce it.
|
||||
AccruedLifetimeCents int64 `json:"accruedLifetimeCents"`
|
||||
// Affiliates is how many affiliate rows the board read, at every status. The
|
||||
// read is bounded at 1000 rows, so a larger fleet reports the bound.
|
||||
Affiliates int `json:"affiliates"`
|
||||
// Approved is how many of those rows are approved — the only ones whose code
|
||||
// resolves for attribution and whose balance can grow.
|
||||
Approved int `json:"approved"`
|
||||
// PaidLifetimeCents is all commission ever paid out, in cents: credits grants
|
||||
// plus record-only cash disbursements.
|
||||
PaidLifetimeCents int64 `json:"paidLifetimeCents"`
|
||||
// PendingLiabilityCents is accrued minus paid across every affiliate, in cents.
|
||||
// Read it as money OWED and not yet disbursed — a liability, not spend.
|
||||
PendingLiabilityCents int64 `json:"pendingLiabilityCents"`
|
||||
}
|
||||
|
||||
// funnel is the referral conversion: referred orgs that have actually produced
|
||||
// commission, over all referred orgs.
|
||||
type funnel struct {
|
||||
ConvertedOrgs int `json:"convertedOrgs"`
|
||||
RatePct float64 `json:"ratePct"`
|
||||
ReferredOrgs int `json:"referredOrgs"`
|
||||
// ConvertedOrgs is how many distinct referred orgs have produced positive
|
||||
// commission at least once — a referral that actually spent.
|
||||
ConvertedOrgs int `json:"convertedOrgs"`
|
||||
// RatePct is convertedOrgs over referredOrgs as a PERCENTAGE, 0–100, and the one
|
||||
// non-integer figure on this board. It is 0 when nothing has been referred yet,
|
||||
// not undefined.
|
||||
RatePct float64 `json:"ratePct"`
|
||||
// ReferredOrgs is how many attribution edges exist fleet-wide — one per referred
|
||||
// org, first-touch, so it is also the count of distinct referred orgs.
|
||||
ReferredOrgs int `json:"referredOrgs"`
|
||||
}
|
||||
|
||||
// levelSplit is the accrual liability broken out by upline level.
|
||||
type levelSplit struct {
|
||||
// L1Cents is lifetime commission accrued to DIRECT referrers, in cents.
|
||||
L1Cents int64 `json:"l1Cents"`
|
||||
// L2Cents is lifetime commission accrued one step above the direct referrer, in
|
||||
// cents, at the platform-wide level-2 rate.
|
||||
L2Cents int64 `json:"l2Cents"`
|
||||
// L3Cents is lifetime commission accrued two steps above, in cents. Nothing
|
||||
// accrues past level 3, so l1+l2+l3 is the whole accrual.
|
||||
L3Cents int64 `json:"l3Cents"`
|
||||
}
|
||||
|
||||
// referralBoard is the analytics board's data plane.
|
||||
type referralBoard struct {
|
||||
AccrualByLevel levelSplit `json:"accrualByLevel"`
|
||||
Conversion funnel `json:"conversion"`
|
||||
Summary tally `json:"summary"`
|
||||
TopReferrers []referrerRow `json:"topReferrers"`
|
||||
// AccrualByLevel splits the lifetime accrual across the three upline levels —
|
||||
// how much of the liability comes from direct referrals versus the chain above.
|
||||
AccrualByLevel levelSplit `json:"accrualByLevel"`
|
||||
// Conversion is the funnel: referred orgs against those that actually earned.
|
||||
Conversion funnel `json:"conversion"`
|
||||
// Summary is the fleet tally — population by status, and lifetime accrued, paid
|
||||
// and still-owed commission.
|
||||
Summary tally `json:"summary"`
|
||||
// TopReferrers is the 25 affiliates with the most lifetime accrued commission,
|
||||
// descending, orgs named.
|
||||
TopReferrers []referrerRow `json:"topReferrers"`
|
||||
}
|
||||
|
||||
// referralsOut is the enveloped GET /v1/admin/referrals answer.
|
||||
type referralsOut struct {
|
||||
// Data is the referral board: leaders, funnel, tally and per-level liability.
|
||||
Data referralBoard `json:"data"`
|
||||
envelope
|
||||
}
|
||||
@@ -956,12 +1075,15 @@ func (a *approval) UnmarshalJSON(b []byte) error {
|
||||
|
||||
// affiliateData carries one affiliate row inside the admin envelope.
|
||||
type affiliateData struct {
|
||||
// Affiliate is the row as it stands AFTER the action that returned it. Its
|
||||
// referredCount is 0 here: these single-affiliate answers do not run the count.
|
||||
Affiliate adminAffiliateView `json:"affiliate"`
|
||||
}
|
||||
|
||||
// affiliateOut is the enveloped single-affiliate answer approve, suspend and
|
||||
// rate share.
|
||||
type affiliateOut struct {
|
||||
// Data carries the affiliate row the action just wrote.
|
||||
Data affiliateData `json:"data"`
|
||||
envelope
|
||||
}
|
||||
@@ -1055,12 +1177,16 @@ type disbursal struct {
|
||||
|
||||
// settlement is the recorded payout beside the affiliate's updated balances.
|
||||
type settlement struct {
|
||||
// Affiliate is the row re-read AFTER the payout, so its paidCents and
|
||||
// pendingCents already account for the row beside it.
|
||||
Affiliate adminAffiliateView `json:"affiliate"`
|
||||
Payout remittance `json:"payout"`
|
||||
// Payout is the payout row just recorded.
|
||||
Payout remittance `json:"payout"`
|
||||
}
|
||||
|
||||
// payoutOut is the enveloped POST /v1/admin/affiliates/:id/payout answer.
|
||||
type payoutOut struct {
|
||||
// Data is the recorded payout and the balances it left behind.
|
||||
Data settlement `json:"data"`
|
||||
envelope
|
||||
}
|
||||
@@ -1132,9 +1258,18 @@ func (o ops) adminPayout(ctx context.Context, in *disbursal) (*payoutOut, error)
|
||||
// accruals reports one accrual run: sources swept, new commission accruals, and
|
||||
// the OSS-author royalties the same spend read drove.
|
||||
type accruals struct {
|
||||
Accrued int `json:"accrued"`
|
||||
// Accrued is how many NEW commission accruals this run created, counted across
|
||||
// every upline level. The accrual is latched at most once per (affiliate, source
|
||||
// org, period), so a re-run inside the same month reports 0 having changed
|
||||
// nothing — 0 means "already accrued", not "failed".
|
||||
Accrued int `json:"accrued"`
|
||||
// RoyaltiesAccrued is how many OSS-author royalty accruals the SAME spend read
|
||||
// produced in the sibling authors program. One read drives both.
|
||||
RoyaltiesAccrued int `json:"royaltiesAccrued"`
|
||||
Swept int `json:"swept"`
|
||||
// Swept is how many source (referred) orgs the run visited, bounded at 500 per
|
||||
// run. A source with no spend this period, or one whose spend could not be read,
|
||||
// still counts as swept.
|
||||
Swept int `json:"swept"`
|
||||
// RoyaltyFailures is reported, not swallowed: a sweep that could not reach
|
||||
// the royalty store must not read as one that found nothing owed. The count
|
||||
// was already computed and then dropped on the floor, which is the same
|
||||
@@ -1144,6 +1279,7 @@ type accruals struct {
|
||||
|
||||
// accrualsOut is the enveloped POST /v1/admin/affiliates/sweep answer.
|
||||
type accrualsOut struct {
|
||||
// Data is what the run did: sources visited, new accruals, royalties alongside.
|
||||
Data accruals `json:"data"`
|
||||
envelope
|
||||
}
|
||||
@@ -1299,19 +1435,47 @@ func emitAudit(s *cloud.Service[state], ctx context.Context, action string, a Af
|
||||
|
||||
// adminAffiliateView is one row in the SuperAdmin directory (org exposed).
|
||||
type adminAffiliateView struct {
|
||||
ID string `json:"id"`
|
||||
Org string `json:"org"`
|
||||
Code string `json:"code"`
|
||||
// ID is the affiliate's server-minted handle, "aff_"-prefixed — the id the
|
||||
// approve, suspend, rate and payout routes address.
|
||||
ID string `json:"id"`
|
||||
// Org is the partner's own org slug. It appears ONLY on this cross-tenant admin
|
||||
// view; no partner-facing read ever names another org.
|
||||
Org string `json:"org"`
|
||||
// Code is the minted referral code, the slug the ?aff link carries. Empty until
|
||||
// approval mints it. Codes are one global namespace across all affiliates.
|
||||
Code string `json:"code"`
|
||||
// RequestedCode is the vanity code the applicant asked for. A request, not an
|
||||
// allocation: approval mints a different slug if this one was taken. Absent when
|
||||
// none was asked for.
|
||||
RequestedCode string `json:"requestedCode,omitempty"`
|
||||
Status string `json:"status"`
|
||||
RateBps int64 `json:"rateBps"`
|
||||
ReferredCount int `json:"referredCount"`
|
||||
AccruedCents int64 `json:"accruedCents"`
|
||||
PendingCents int64 `json:"pendingCents"`
|
||||
PaidCents int64 `json:"paidCents"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
ApprovedAt int64 `json:"approvedAt"`
|
||||
SuspendedAt int64 `json:"suspendedAt"`
|
||||
// Status is "applied", "approved" or "suspended". Only "approved" resolves for
|
||||
// attribution and accrues; "suspended" stops future earning and claws nothing
|
||||
// back.
|
||||
Status string `json:"status"`
|
||||
// RateBps is this affiliate's DIRECT (level 1) commission rate in basis points
|
||||
// OF Hanzo's margin (2000 = 20% of margin, never of the customer's bill). Levels
|
||||
// 2 and 3 are platform-wide switches and are not carried per affiliate.
|
||||
RateBps int64 `json:"rateBps"`
|
||||
// ReferredCount is how many orgs this affiliate is the DIRECT referrer of,
|
||||
// counted from the attribution edges. It is 0 on the single-affiliate answers
|
||||
// (approve, suspend, rate, payout), which do not run the count.
|
||||
ReferredCount int `json:"referredCount"`
|
||||
// AccruedCents is lifetime commission accrued, in cents. It only grows — a
|
||||
// payout moves paidCents, never this.
|
||||
AccruedCents int64 `json:"accruedCents"`
|
||||
// PendingCents is accrued minus paid, in cents: what is still owed, and the hard
|
||||
// ceiling the next payout is reserved against. Never negative.
|
||||
PendingCents int64 `json:"pendingCents"`
|
||||
// PaidCents is lifetime commission already paid out, in cents — credits grants
|
||||
// and record-only cash disbursements alike.
|
||||
PaidCents int64 `json:"paidCents"`
|
||||
// CreatedAt is when the org applied, Unix seconds UTC.
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
// ApprovedAt is when staff approved, Unix seconds UTC. 0 means never approved.
|
||||
ApprovedAt int64 `json:"approvedAt"`
|
||||
// SuspendedAt is when staff suspended, Unix seconds UTC. 0 means never
|
||||
// suspended; it is not cleared by a later re-approval.
|
||||
SuspendedAt int64 `json:"suspendedAt"`
|
||||
}
|
||||
|
||||
func adminViewOf(a Affiliate, referred int) adminAffiliateView {
|
||||
@@ -1325,12 +1489,24 @@ func adminViewOf(a Affiliate, referred int) adminAffiliateView {
|
||||
|
||||
// remittance is one row of an affiliate's payout history.
|
||||
type remittance struct {
|
||||
ID string `json:"id"`
|
||||
AmountCents int64 `json:"amountCents"`
|
||||
Method string `json:"method"`
|
||||
Reference string `json:"reference,omitempty"`
|
||||
Txn string `json:"txn,omitempty"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
// ID is the payout row's server-minted handle, "apo_"-prefixed.
|
||||
ID string `json:"id"`
|
||||
// AmountCents is the amount disbursed, in cents. It was reserved against pending
|
||||
// commission atomically when recorded, so it never exceeds what was owed.
|
||||
AmountCents int64 `json:"amountCents"`
|
||||
// Method is how it was settled. "credits" issued a commerce grant into the
|
||||
// affiliate org's own wallet; any other value (wire, paypal, check, …) is a
|
||||
// RECORD of cash a human moved out of band.
|
||||
Method string `json:"method"`
|
||||
// Reference is the operator's settlement note — a bank id, a ledger ref. Free
|
||||
// text, absent when none was given.
|
||||
Reference string `json:"reference,omitempty"`
|
||||
// Txn is the commerce ledger transaction id, set ONLY where a "credits" payout
|
||||
// actually issued the grant. Absent for cash methods, which write no ledger row.
|
||||
Txn string `json:"txn,omitempty"`
|
||||
// CreatedAt is when the payout was recorded, Unix seconds UTC — when the balance
|
||||
// moved, not necessarily when the cash landed.
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
func remittanceOf(p Payout) remittance {
|
||||
@@ -1347,13 +1523,26 @@ func remittances(ps []Payout) []remittance {
|
||||
|
||||
// totals is the fleet tally for the admin directory.
|
||||
type totals struct {
|
||||
Total int `json:"total"`
|
||||
Applied int `json:"applied"`
|
||||
Approved int `json:"approved"`
|
||||
Suspended int `json:"suspended"`
|
||||
// Total is how many affiliate rows this page covered, at every status. It is the
|
||||
// page, not the table: a limit that truncates truncates this too.
|
||||
Total int `json:"total"`
|
||||
// Applied is how many of those rows are still awaiting approval — no code, no
|
||||
// accrual yet.
|
||||
Applied int `json:"applied"`
|
||||
// Approved is how many are approved: the only rows whose code resolves for
|
||||
// attribution and whose balance can still grow.
|
||||
Approved int `json:"approved"`
|
||||
// Suspended is how many were suspended. What they already accrued stays accrued
|
||||
// and stays payable.
|
||||
Suspended int `json:"suspended"`
|
||||
// AccruedCents is lifetime commission accrued summed over those rows, in cents.
|
||||
AccruedCents int64 `json:"accruedCents"`
|
||||
// PendingCents is accrued minus paid summed over those rows, in cents — the
|
||||
// outstanding liability across the page.
|
||||
PendingCents int64 `json:"pendingCents"`
|
||||
PaidCents int64 `json:"paidCents"`
|
||||
// PaidCents is lifetime commission already paid out summed over those rows, in
|
||||
// cents.
|
||||
PaidCents int64 `json:"paidCents"`
|
||||
}
|
||||
|
||||
func (s *totals) add(a Affiliate) {
|
||||
|
||||
@@ -80,9 +80,17 @@ const (
|
||||
// ── earnings (the per-affiliate share-ledger projection) ────────────────────────
|
||||
|
||||
type periodEarningView struct {
|
||||
Period string `json:"period"`
|
||||
MarginCents int64 `json:"marginCents"`
|
||||
CommissionCents int64 `json:"commissionCents"`
|
||||
// Period is the accrual bucket: the UTC year-month, "YYYY-MM". Commission is
|
||||
// latched at most once per referred org per period, so one row is one month.
|
||||
Period string `json:"period"`
|
||||
// MarginCents is the margin Hanzo earned in that period on the spend of every
|
||||
// org the caller referred, in cents — the base commission is a rate OF. It is
|
||||
// the aggregate base, never any one customer's bill.
|
||||
MarginCents int64 `json:"marginCents"`
|
||||
// CommissionCents is what the caller earned that period, in cents: the sum over
|
||||
// each referred org and upline level of margin × that level's rate. Always ≤
|
||||
// marginCents, by construction.
|
||||
CommissionCents int64 `json:"commissionCents"`
|
||||
}
|
||||
|
||||
// orgEarningView is the affiliate's per-referred-org contribution: the affiliate's OWN
|
||||
@@ -90,8 +98,13 @@ type periodEarningView struct {
|
||||
// referred org's gross usage is never restated to the affiliate (only the affiliate's
|
||||
// own earned share, which it is entitled to).
|
||||
type orgEarningView struct {
|
||||
ReferredOrg string `json:"referredOrg"`
|
||||
CommissionCents int64 `json:"commissionCents"`
|
||||
// ReferredOrg is the org slug this contribution came from — one the caller
|
||||
// referred, directly or up to three levels down.
|
||||
ReferredOrg string `json:"referredOrg"`
|
||||
// CommissionCents is what the caller earned from that org across ALL periods, in
|
||||
// cents. Deliberately the caller's own share and nothing else: that org's spend
|
||||
// and the margin on it are not restated here.
|
||||
CommissionCents int64 `json:"commissionCents"`
|
||||
}
|
||||
|
||||
// affiliateEarnings is the caller's commission ledger, or the honest
|
||||
@@ -105,7 +118,10 @@ type affiliateEarnings struct {
|
||||
// ByReferredOrg is each referral's aggregate contribution — the affiliate's
|
||||
// OWN share, never the referred org's spend.
|
||||
ByReferredOrg *[]orgEarningView `json:"byReferredOrg,omitempty"`
|
||||
IsAffiliate bool `json:"isAffiliate"`
|
||||
// IsAffiliate says whether the caller org has an affiliate record. On false it
|
||||
// is the ONLY field present — there is no ledger to report, and the zeros you
|
||||
// might expect are absent rather than reported as earnings of nothing.
|
||||
IsAffiliate bool `json:"isAffiliate"`
|
||||
// MarginBps is the platform gross-margin fraction commission is a rate OF.
|
||||
MarginBps *int64 `json:"marginBps,omitempty"`
|
||||
// PaidCents is lifetime commission already paid out, in cents.
|
||||
@@ -171,24 +187,48 @@ func (o ops) earnings(ctx context.Context, _ *noInput) (*affiliateEarnings, erro
|
||||
// (orgs attributed with this code), conversions (of those, how many produced a
|
||||
// commission). Signups/conversions are DERIVED from the ledger, never stored.
|
||||
type codeView struct {
|
||||
Code string `json:"code"`
|
||||
Label string `json:"label"`
|
||||
URL string `json:"url"`
|
||||
Clicks int64 `json:"clicks"`
|
||||
Signups int `json:"signups"`
|
||||
Conversions int `json:"conversions"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
// Code is the link's slug — 3–32 chars of a–z, 0–9 and hyphen — unique across
|
||||
// the WHOLE directory, so any affiliate's code resolves an attribution.
|
||||
Code string `json:"code"`
|
||||
// Label is the caller's own note for the link ("twitter", "newsletter").
|
||||
// Cosmetic: trimmed, stripped of control characters, capped at 48 bytes, and
|
||||
// never part of the code. "primary" on the link mirrored at approval.
|
||||
Label string `json:"label"`
|
||||
// URL is the full shareable link, the brand host plus ?aff=<code>. The host is
|
||||
// the deployment's own brand, so a Lux or Zoo install never mints a hanzo.ai
|
||||
// link.
|
||||
URL string `json:"url"`
|
||||
// Clicks is how many pings this code has taken. The one STORED counter here and
|
||||
// pure vanity: no accrual or payout reads it, pings are coalesced in memory and
|
||||
// flushed in batches, and a dropped tally is accepted rather than contending
|
||||
// with the money write path. Do not reconcile it against anything.
|
||||
Clicks int64 `json:"clicks"`
|
||||
// Signups is how many orgs were attributed with this code — DERIVED by counting
|
||||
// attribution edges, never stored, so it cannot drift from the ledger.
|
||||
Signups int `json:"signups"`
|
||||
// Conversions is how many of those signups have actually produced positive
|
||||
// commission for the caller. Also derived, from the accrual rows, so it is
|
||||
// ≤ signups and lags a referral until the first sweep after it spends.
|
||||
Conversions int `json:"conversions"`
|
||||
// CreatedAt is when the link was minted, Unix seconds UTC.
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
// affiliateLinks is the caller's share links with their funnel, or the honest
|
||||
// `isAffiliate:false` beside the link cap.
|
||||
type affiliateLinks struct {
|
||||
// IsAffiliate says whether the caller org has an affiliate record. On false only
|
||||
// maxLinks comes back — there are no links, and there is no link to mint until
|
||||
// the org applies and is approved.
|
||||
IsAffiliate bool `json:"isAffiliate"`
|
||||
// Links is the caller's share links, each with its URL and funnel.
|
||||
Links *[]codeView `json:"links,omitempty"`
|
||||
// MaxLinks is how many share links one affiliate may hold.
|
||||
MaxLinks int `json:"maxLinks"`
|
||||
Status string `json:"status,omitempty"`
|
||||
MaxLinks int `json:"maxLinks"`
|
||||
// Status is the caller's affiliate status: "applied", "approved" or
|
||||
// "suspended"; absent for a non-affiliate. Minting a link requires "approved",
|
||||
// because a link that cannot accrue quietly loses the referral.
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// links answers the caller's share links, each with its URL and its funnel:
|
||||
@@ -262,6 +302,8 @@ type createLinkRequest struct {
|
||||
|
||||
// linkMint is the minted share link, answered 201.
|
||||
type linkMint struct {
|
||||
// Link is the link just minted, with its full shareable URL. Its funnel counters
|
||||
// all start at zero — nothing has clicked or signed up through it yet.
|
||||
Link codeView `json:"link"`
|
||||
}
|
||||
|
||||
@@ -359,6 +401,10 @@ type clickRequest struct {
|
||||
|
||||
// clickCount reports that the buffer took the ping — not that the code is real.
|
||||
type clickCount struct {
|
||||
// Counted says the in-memory buffer took the ping. It does NOT say the code
|
||||
// exists — this is deliberately not a code-existence oracle, and an unknown code
|
||||
// simply no-ops at flush time. false means the buffer was full and the ping was
|
||||
// dropped, which is harmless: clicks are vanity and move no money.
|
||||
Counted bool `json:"counted"`
|
||||
}
|
||||
|
||||
@@ -410,6 +456,9 @@ type handleRequest struct {
|
||||
|
||||
// handleSet echoes the handle as stored — empty when the caller opted out.
|
||||
type handleSet struct {
|
||||
// Handle is the display name as STORED, echoed back after trimming. Empty means
|
||||
// the caller opted out: it keeps its rank and still sees its own row, it is just
|
||||
// no longer listed to anyone else.
|
||||
Handle string `json:"handle"`
|
||||
}
|
||||
|
||||
@@ -456,11 +505,24 @@ func (o ops) setHandle(ctx context.Context, in *handleRequest) (*handleSet, erro
|
||||
// leaderboardRow is one public leaderboard entry: rank + opt-in handle + aggregate
|
||||
// share + referred count. NEVER an org identity. IsYou flags the caller's own row.
|
||||
type leaderboardRow struct {
|
||||
Rank int `json:"rank"`
|
||||
Handle string `json:"handle"`
|
||||
AccruedCents int64 `json:"accruedCents"`
|
||||
ReferredCount int `json:"referredCount"`
|
||||
IsYou bool `json:"isYou,omitempty"`
|
||||
// Rank is the position in the GLOBAL approved set ordered by lifetime accrued
|
||||
// commission, 1-based. Affiliates that set no handle still occupy their rank and
|
||||
// are simply not listed, so the visible ranks have gaps and the board is not a
|
||||
// complete roster. On the caller's own row the rank is computed over the whole
|
||||
// set, so it is exact well outside the top page.
|
||||
Rank int `json:"rank"`
|
||||
// Handle is the affiliate's self-chosen display name — the only identity the
|
||||
// board ever carries. The org behind it is never disclosed.
|
||||
Handle string `json:"handle"`
|
||||
// AccruedCents is that affiliate's lifetime commission accrued, in cents, and
|
||||
// what the board is ordered by. An aggregate: no per-customer figure is exposed.
|
||||
AccruedCents int64 `json:"accruedCents"`
|
||||
// ReferredCount is how many orgs that affiliate directly referred — a count
|
||||
// only, never which orgs.
|
||||
ReferredCount int `json:"referredCount"`
|
||||
// IsYou marks the caller's own row, so a client can highlight it without
|
||||
// matching on a handle. Absent on every other row.
|
||||
IsYou bool `json:"isYou,omitempty"`
|
||||
}
|
||||
|
||||
// affiliateBoard is the public board plus the caller's own exact rank.
|
||||
|
||||
+230
-26
@@ -12,11 +12,62 @@ func init() {
|
||||
zip.Describe("GET /v1/admin/affiliates", zip.Doc{
|
||||
Description: "Lists every affiliate across the fleet with its ORG exposed, plus a\nfleet summary of lifetime accrued, still-pending and paid commission in\ninteger cents.\n\nPLATFORM SUDO ONLY, and a non-admin is refused outright. This is the\ncross-tenant view and it names orgs — exactly what the partner-facing\nleaderboard refuses to do. There is deliberately no org-scoped variant of this\nread; a partner sees its own standing through its own dashboard. Bounded per\nrequest.",
|
||||
Fields: map[string]string{
|
||||
"page.limit": "Limit caps the rows returned. Absent or non-positive means the default of\n500; anything above 1000 is clamped to 1000.",
|
||||
"adminAffiliateView.accruedCents": "AccruedCents is lifetime commission accrued, in cents. It only grows — a\npayout moves paidCents, never this.",
|
||||
"adminAffiliateView.approvedAt": "ApprovedAt is when staff approved, Unix seconds UTC. 0 means never approved.",
|
||||
"adminAffiliateView.code": "Code is the minted referral code, the slug the ?aff link carries. Empty until\napproval mints it. Codes are one global namespace across all affiliates.",
|
||||
"adminAffiliateView.createdAt": "CreatedAt is when the org applied, Unix seconds UTC.",
|
||||
"adminAffiliateView.id": "ID is the affiliate's server-minted handle, \"aff_\"-prefixed — the id the\napprove, suspend, rate and payout routes address.",
|
||||
"adminAffiliateView.org": "Org is the partner's own org slug. It appears ONLY on this cross-tenant admin\nview; no partner-facing read ever names another org.",
|
||||
"adminAffiliateView.paidCents": "PaidCents is lifetime commission already paid out, in cents — credits grants\nand record-only cash disbursements alike.",
|
||||
"adminAffiliateView.pendingCents": "PendingCents is accrued minus paid, in cents: what is still owed, and the hard\nceiling the next payout is reserved against. Never negative.",
|
||||
"adminAffiliateView.rateBps": "RateBps is this affiliate's DIRECT (level 1) commission rate in basis points\nOF Hanzo's margin (2000 = 20% of margin, never of the customer's bill). Levels\n2 and 3 are platform-wide switches and are not carried per affiliate.",
|
||||
"adminAffiliateView.referredCount": "ReferredCount is how many orgs this affiliate is the DIRECT referrer of,\ncounted from the attribution edges. It is 0 on the single-affiliate answers\n(approve, suspend, rate, payout), which do not run the count.",
|
||||
"adminAffiliateView.requestedCode": "RequestedCode is the vanity code the applicant asked for. A request, not an\nallocation: approval mints a different slug if this one was taken. Absent when\nnone was asked for.",
|
||||
"adminAffiliateView.status": "Status is \"applied\", \"approved\" or \"suspended\". Only \"approved\" resolves for\nattribution and accrues; \"suspended\" stops future earning and claws nothing\nback.",
|
||||
"adminAffiliateView.suspendedAt": "SuspendedAt is when staff suspended, Unix seconds UTC. 0 means never\nsuspended; it is not cleared by a later re-approval.",
|
||||
"directoryData.affiliates": "Affiliates is one row per affiliate across the whole fleet, ORG EXPOSED,\noldest first and bounded by the request's limit.",
|
||||
"directoryData.summary": "Summary tallies exactly the rows above — not the whole table — so a limit that\ntruncates the page truncates the tally with it.",
|
||||
"directoryOut.data": "Data is the affiliate directory and its tally.",
|
||||
"envelope.msg": "Msg is an operator-facing note. Empty on every success here — it exists\nbecause the console's admin unwrapper reads the shape cloud.OK writes.",
|
||||
"envelope.status": "Status is \"ok\" on every 2xx from this surface; a failure is an HTTP error with\nzip's error body, not this envelope carrying a different word.",
|
||||
"page.limit": "Limit caps the rows returned. Absent or non-positive means the default of\n500; anything above 1000 is clamped to 1000.",
|
||||
"totals.accruedCents": "AccruedCents is lifetime commission accrued summed over those rows, in cents.",
|
||||
"totals.applied": "Applied is how many of those rows are still awaiting approval — no code, no\naccrual yet.",
|
||||
"totals.approved": "Approved is how many are approved: the only rows whose code resolves for\nattribution and whose balance can still grow.",
|
||||
"totals.paidCents": "PaidCents is lifetime commission already paid out summed over those rows, in\ncents.",
|
||||
"totals.pendingCents": "PendingCents is accrued minus paid summed over those rows, in cents — the\noutstanding liability across the page.",
|
||||
"totals.suspended": "Suspended is how many were suspended. What they already accrued stays accrued\nand stays payable.",
|
||||
"totals.total": "Total is how many affiliate rows this page covered, at every status. It is the\npage, not the table: a limit that truncates truncates this too.",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/admin/referrals", zip.Doc{
|
||||
Description: "Answers the referral board: the top referrers by lifetime\ncommission, the funnel conversion rate (referred orgs that have actually\nproduced commission, over all referred orgs), and the accrual LIABILITY the\nplatform owes, broken out by upline level.\n\nRead the liability figure carefully — it is commission accrued and NOT yet\npaid, so it is money owed, not money spent, and the per-level split says how\nmuch of it comes from direct referrals versus the second and third levels.\n\nPLATFORM SUDO ONLY, cross-tenant, and it names orgs. It reads the SAME single\nattribution spine the accrual itself walks, so the board and the ledger cannot\ndisagree. Amounts are integer cents.",
|
||||
Fields: map[string]string{
|
||||
"envelope.msg": "Msg is an operator-facing note. Empty on every success here — it exists\nbecause the console's admin unwrapper reads the shape cloud.OK writes.",
|
||||
"envelope.status": "Status is \"ok\" on every 2xx from this surface; a failure is an HTTP error with\nzip's error body, not this envelope carrying a different word.",
|
||||
"funnel.convertedOrgs": "ConvertedOrgs is how many distinct referred orgs have produced positive\ncommission at least once — a referral that actually spent.",
|
||||
"funnel.ratePct": "RatePct is convertedOrgs over referredOrgs as a PERCENTAGE, 0–100, and the one\nnon-integer figure on this board. It is 0 when nothing has been referred yet,\nnot undefined.",
|
||||
"funnel.referredOrgs": "ReferredOrgs is how many attribution edges exist fleet-wide — one per referred\norg, first-touch, so it is also the count of distinct referred orgs.",
|
||||
"levelSplit.l1Cents": "L1Cents is lifetime commission accrued to DIRECT referrers, in cents.",
|
||||
"levelSplit.l2Cents": "L2Cents is lifetime commission accrued one step above the direct referrer, in\ncents, at the platform-wide level-2 rate.",
|
||||
"levelSplit.l3Cents": "L3Cents is lifetime commission accrued two steps above, in cents. Nothing\naccrues past level 3, so l1+l2+l3 is the whole accrual.",
|
||||
"referralBoard.accrualByLevel": "AccrualByLevel splits the lifetime accrual across the three upline levels —\nhow much of the liability comes from direct referrals versus the chain above.",
|
||||
"referralBoard.conversion": "Conversion is the funnel: referred orgs against those that actually earned.",
|
||||
"referralBoard.summary": "Summary is the fleet tally — population by status, and lifetime accrued, paid\nand still-owed commission.",
|
||||
"referralBoard.topReferrers": "TopReferrers is the 25 affiliates with the most lifetime accrued commission,\ndescending, orgs named.",
|
||||
"referralsOut.data": "Data is the referral board: leaders, funnel, tally and per-level liability.",
|
||||
"referrerRow.accruedCents": "AccruedCents is lifetime commission accrued, in cents. The board is sorted by\nthis, descending.",
|
||||
"referrerRow.code": "Code is that affiliate's minted referral code; empty if it is not approved.",
|
||||
"referrerRow.org": "Org is the partner's own org slug. Named only here, on the SuperAdmin board —\nthe partner-facing leaderboard shows an opt-in handle and never an org.",
|
||||
"referrerRow.pendingCents": "PendingCents is accrued minus paid, in cents — what is still owed to this\naffiliate. Never negative.",
|
||||
"referrerRow.referredCount": "ReferredCount is how many orgs this affiliate is the DIRECT referrer of —\nits level-1 downline, not the whole three-level chain.",
|
||||
"referrerRow.status": "Status is \"applied\", \"approved\" or \"suspended\".",
|
||||
"tally.accruedLifetimeCents": "AccruedLifetimeCents is all commission ever accrued, summed across every\naffiliate, in cents. It only grows; a payout does not reduce it.",
|
||||
"tally.affiliates": "Affiliates is how many affiliate rows the board read, at every status. The\nread is bounded at 1000 rows, so a larger fleet reports the bound.",
|
||||
"tally.approved": "Approved is how many of those rows are approved — the only ones whose code\nresolves for attribution and whose balance can grow.",
|
||||
"tally.paidLifetimeCents": "PaidLifetimeCents is all commission ever paid out, in cents: credits grants\nplus record-only cash disbursements.",
|
||||
"tally.pendingLiabilityCents": "PendingLiabilityCents is accrued minus paid across every affiliate, in cents.\nRead it as money OWED and not yet disbursed — a liability, not spend.",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/affiliates", zip.Doc{
|
||||
Description: "Answers the caller org's OWN affiliate standing: status, referral\ncode and share link, commission rate, how many orgs it has referred, and its\nlifetime accrued, still-pending and already-paid commission in integer cents,\nwith its payout history.\n\nAn org that never applied gets an honest `isAffiliate:false` and the default\nrate rather than a 404 — the console renders the apply form off that answer.\n\nThe affiliate is resolved from the VALIDATED org, never from a field, so this\ncan only ever read the caller's own row; without a principal it is refused. It\nis a PURE READ: nothing accrues until the sweep runs. Commission is earned on\nHanzo's MARGIN, never on the referred customer's bill, so nothing here changes\nwhat that customer pays.",
|
||||
@@ -25,6 +76,8 @@ func init() {
|
||||
"affiliateStanding.code": "Code is the minted referral code; empty until staff approve.",
|
||||
"affiliateStanding.defaultRateBps": "DefaultRateBps is the direct rate a new affiliate would get, answered only\nto a caller that has not applied.",
|
||||
"affiliateStanding.handle": "Handle is the opt-in public leaderboard name; empty means opted out.",
|
||||
"affiliateStanding.id": "ID is the affiliate's server-minted handle, \"aff_\"-prefixed — what staff\napprove, suspend, re-rate and pay against. Absent until the org applies.",
|
||||
"affiliateStanding.isAffiliate": "IsAffiliate says whether the caller org has an affiliate record at all. It is\nthe ONE field an org that never applied gets besides defaultRateBps: on false,\nread nothing else here — every other field is absent, not zero.",
|
||||
"affiliateStanding.link": "Link is the shareable ?aff URL; empty until a code is minted.",
|
||||
"affiliateStanding.marginBps": "MarginBps is the platform gross-margin fraction commission is a rate OF.",
|
||||
"affiliateStanding.paidCents": "PaidCents is lifetime commission already paid out, in cents.",
|
||||
@@ -32,82 +85,219 @@ func init() {
|
||||
"affiliateStanding.pendingCents": "PendingCents is accrued minus paid — what the platform still owes.",
|
||||
"affiliateStanding.rateBps": "RateBps is the affiliate's own direct commission rate, in basis points.",
|
||||
"affiliateStanding.referredCount": "ReferredCount is how many orgs this affiliate has referred.",
|
||||
"affiliateStanding.requestedCode": "RequestedCode is the vanity code asked for at apply time — a request, not an\nallocation. Approval mints `code`, which may be a different slug if this one\nwas already taken.",
|
||||
"affiliateStanding.status": "Status is \"applied\", \"approved\" or \"suspended\". Only an approved affiliate has\na code that resolves for attribution and accrues commission; suspended keeps\nwhat it already earned but stops earning more.",
|
||||
"remittance.amountCents": "AmountCents is the amount disbursed, in cents. It was reserved against pending\ncommission atomically when recorded, so it never exceeds what was owed.",
|
||||
"remittance.createdAt": "CreatedAt is when the payout was recorded, Unix seconds UTC — when the balance\nmoved, not necessarily when the cash landed.",
|
||||
"remittance.id": "ID is the payout row's server-minted handle, \"apo_\"-prefixed.",
|
||||
"remittance.method": "Method is how it was settled. \"credits\" issued a commerce grant into the\naffiliate org's own wallet; any other value (wire, paypal, check, …) is a\nRECORD of cash a human moved out of band.",
|
||||
"remittance.reference": "Reference is the operator's settlement note — a bank id, a ledger ref. Free\ntext, absent when none was given.",
|
||||
"remittance.txn": "Txn is the commerce ledger transaction id, set ONLY where a \"credits\" payout\nactually issued the grant. Absent for cash methods, which write no ledger row.",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/affiliates/leaderboard", zip.Doc{
|
||||
Description: "Answers the top affiliates by lifetime accrued commission, shown by\nOPT-IN HANDLE with aggregate figures only, plus the caller's own exact rank.\n\nIt never discloses an org identity and never a referred org's usage. An\naffiliate that has set no handle still OCCUPIES its rank but is not listed —\nso opting out hides the name, not the position, and the visible board must not\nbe read as a complete roster.\n\nThe caller's own row carries its exact GLOBAL rank, computed over the whole\napproved set rather than over the page, so it is right well outside the top of\nthe board. Only an approved affiliate has a rank. Requires a validated\nprincipal; a signed-in non-affiliate may read the board but gets no personal\nrow.",
|
||||
Fields: map[string]string{
|
||||
"affiliateBoard.leaders": "Leaders are the top opt-in affiliates, by handle and aggregate figures only.",
|
||||
"affiliateBoard.total": "Total is the approved population where it is known; omitted where the top\npage truncated and the caller has no rank to derive it from.",
|
||||
"affiliateBoard.you": "You is the caller's own row with its exact global rank; only an approved\naffiliate has one.",
|
||||
"affiliateBoard.leaders": "Leaders are the top opt-in affiliates, by handle and aggregate figures only.",
|
||||
"affiliateBoard.total": "Total is the approved population where it is known; omitted where the top\npage truncated and the caller has no rank to derive it from.",
|
||||
"affiliateBoard.you": "You is the caller's own row with its exact global rank; only an approved\naffiliate has one.",
|
||||
"leaderboardRow.accruedCents": "AccruedCents is that affiliate's lifetime commission accrued, in cents, and\nwhat the board is ordered by. An aggregate: no per-customer figure is exposed.",
|
||||
"leaderboardRow.handle": "Handle is the affiliate's self-chosen display name — the only identity the\nboard ever carries. The org behind it is never disclosed.",
|
||||
"leaderboardRow.isYou": "IsYou marks the caller's own row, so a client can highlight it without\nmatching on a handle. Absent on every other row.",
|
||||
"leaderboardRow.rank": "Rank is the position in the GLOBAL approved set ordered by lifetime accrued\ncommission, 1-based. Affiliates that set no handle still occupy their rank and\nare simply not listed, so the visible ranks have gaps and the board is not a\ncomplete roster. On the caller's own row the rank is computed over the whole\nset, so it is exact well outside the top page.",
|
||||
"leaderboardRow.referredCount": "ReferredCount is how many orgs that affiliate directly referred — a count\nonly, never which orgs.",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/affiliates/me", zip.Doc{
|
||||
Description: "Answers the richer self-view: the same lifetime accrued, pending and paid\ncommission and payout history, plus the caller's downline broken out by upline\nLEVEL — direct, second, third — each with the rate paid at that level and how\nmany orgs sit there.\n\nCommission is MULTI-LEVEL: a referred org's spend pays up its referral chain,\nthree levels deep and no further. The direct level is the affiliate's own\nnegotiated rate; the second and third are platform-wide switches, read live,\nso the schedule shown is the one actually in force rather than one compiled\nin. A caller that has not applied still gets that schedule alongside\n`isAffiliate:false`, so the console can show what it would earn.\n\nScoped to the validated org and nothing else, and refused without a\nprincipal. A PURE READ — it reports the downline but accrues nothing.",
|
||||
Fields: map[string]string{
|
||||
"affiliateSelf.downlineTotal": "DownlineTotal counts every org in the caller's downline across the levels.",
|
||||
"affiliateSelf.levels": "Levels is the caller's downline per upline level, with the rate paid there.",
|
||||
"affiliateSelf.schedule": "Schedule is the rate schedule quoted to a caller that has not applied.",
|
||||
"affiliateSelf.accruedCents": "AccruedCents is lifetime commission accrued, in cents. It only grows — a\npayout is recorded against paidCents and never reduces this.",
|
||||
"affiliateSelf.code": "Code is the minted referral code, the slug the ?aff link carries. Absent until\nstaff approve; codes live in ONE global namespace across all affiliates.",
|
||||
"affiliateSelf.defaultRateBps": "DefaultRateBps is the direct rate a new affiliate starts at, in basis points\nof margin (2000 = 20%). Answered ONLY to a caller that has not applied, as the\nquote beside `schedule`.",
|
||||
"affiliateSelf.downlineTotal": "DownlineTotal counts every org in the caller's downline across the levels.",
|
||||
"affiliateSelf.handle": "Handle is the opt-in public leaderboard name. Empty means opted out: the\ncaller keeps its rank and still sees its own row, it is just not listed.",
|
||||
"affiliateSelf.id": "ID is the affiliate's server-minted handle, \"aff_\"-prefixed. Absent until the\norg applies.",
|
||||
"affiliateSelf.isAffiliate": "IsAffiliate says whether the caller org has an affiliate record. On false the\nanswer carries the rate SCHEDULE and the default rate instead of a downline,\nso the console can show what the caller would earn.",
|
||||
"affiliateSelf.levels": "Levels is the caller's downline per upline level, with the rate paid there.",
|
||||
"affiliateSelf.link": "Link is the shareable ?aff URL built from the code. Empty until a code is\nminted, since there is nothing to share before approval.",
|
||||
"affiliateSelf.marginBps": "MarginBps is the platform gross-margin fraction, in basis points, that every\nrate here is a rate OF. Read live per request, so it is the value in force\nnow, not the one that applied to commission already accrued.",
|
||||
"affiliateSelf.paidCents": "PaidCents is lifetime commission already paid out, in cents — credits grants\nand record-only cash disbursements alike.",
|
||||
"affiliateSelf.payouts": "Payouts is the payout history, newest first, bounded to the last 100 rows.",
|
||||
"affiliateSelf.pendingCents": "PendingCents is accrued minus paid, in cents — what the platform still owes\nand the ceiling on the next payout. Never negative.",
|
||||
"affiliateSelf.rateBps": "RateBps is the caller's OWN direct (level 1) commission rate, in basis points\nof margin. Levels 2 and 3 are platform-wide and appear in `levels`.",
|
||||
"affiliateSelf.schedule": "Schedule is the rate schedule quoted to a caller that has not applied.",
|
||||
"affiliateSelf.status": "Status is \"applied\", \"approved\" or \"suspended\"; absent for a caller that never\napplied. Only \"approved\" mints links and accrues.",
|
||||
"levelView.downlineCount": "DownlineCount is how many orgs sit exactly this many hops below the caller. It\nis 0 in the schedule quoted to a caller that has not applied, which has no\ndownline to count.",
|
||||
"levelView.level": "Level is the upline distance from the org whose spend is being shared: 1 is\nthe direct referrer, 2 and 3 the referrers above it. Nothing accrues past 3.",
|
||||
"levelView.rateBps": "RateBps is the commission paid at this level, in basis points OF Hanzo's\nmargin (2000 = 20% of margin, never of the customer's bill). Level 1 is the\naffiliate's own negotiated rate; 2 and 3 are platform switches read live, so\nthis is the schedule actually in force, not one compiled in.",
|
||||
"remittance.amountCents": "AmountCents is the amount disbursed, in cents. It was reserved against pending\ncommission atomically when recorded, so it never exceeds what was owed.",
|
||||
"remittance.createdAt": "CreatedAt is when the payout was recorded, Unix seconds UTC — when the balance\nmoved, not necessarily when the cash landed.",
|
||||
"remittance.id": "ID is the payout row's server-minted handle, \"apo_\"-prefixed.",
|
||||
"remittance.method": "Method is how it was settled. \"credits\" issued a commerce grant into the\naffiliate org's own wallet; any other value (wire, paypal, check, …) is a\nRECORD of cash a human moved out of band.",
|
||||
"remittance.reference": "Reference is the operator's settlement note — a bank id, a ledger ref. Free\ntext, absent when none was given.",
|
||||
"remittance.txn": "Txn is the commerce ledger transaction id, set ONLY where a \"credits\" payout\nactually issued the grant. Absent for cash methods, which write no ledger row.",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/affiliates/me/earnings", zip.Doc{
|
||||
Description: "Answers the caller's own commission ledger: per period, the margin it\nearned against and the commission taken from that margin; and per referred\norg, that referral's aggregate contribution. Integer cents throughout.\n\nThe per-org view deliberately carries the affiliate's OWN earned share and NOT\nthe referred org's spend or margin. An affiliate is entitled to what it\nearned, not to a restatement of its customer's usage — the period view is\nwhere the margin base appears, aggregated across every referral.\n\nScoped server-side to the validated caller's affiliate; a caller that is not\none gets `isAffiliate:false`.",
|
||||
Fields: map[string]string{
|
||||
"affiliateEarnings.accruedCents": "AccruedCents is lifetime commission accrued, in cents.",
|
||||
"affiliateEarnings.byPeriod": "ByPeriod is the per-period ledger: the margin earned against and the\ncommission taken from it.",
|
||||
"affiliateEarnings.byReferredOrg": "ByReferredOrg is each referral's aggregate contribution — the affiliate's\nOWN share, never the referred org's spend.",
|
||||
"affiliateEarnings.marginBps": "MarginBps is the platform gross-margin fraction commission is a rate OF.",
|
||||
"affiliateEarnings.paidCents": "PaidCents is lifetime commission already paid out, in cents.",
|
||||
"affiliateEarnings.pendingCents": "PendingCents is accrued minus paid — what the platform still owes.",
|
||||
"affiliateEarnings.accruedCents": "AccruedCents is lifetime commission accrued, in cents.",
|
||||
"affiliateEarnings.byPeriod": "ByPeriod is the per-period ledger: the margin earned against and the\ncommission taken from it.",
|
||||
"affiliateEarnings.byReferredOrg": "ByReferredOrg is each referral's aggregate contribution — the affiliate's\nOWN share, never the referred org's spend.",
|
||||
"affiliateEarnings.isAffiliate": "IsAffiliate says whether the caller org has an affiliate record. On false it\nis the ONLY field present — there is no ledger to report, and the zeros you\nmight expect are absent rather than reported as earnings of nothing.",
|
||||
"affiliateEarnings.marginBps": "MarginBps is the platform gross-margin fraction commission is a rate OF.",
|
||||
"affiliateEarnings.paidCents": "PaidCents is lifetime commission already paid out, in cents.",
|
||||
"affiliateEarnings.pendingCents": "PendingCents is accrued minus paid — what the platform still owes.",
|
||||
"orgEarningView.commissionCents": "CommissionCents is what the caller earned from that org across ALL periods, in\ncents. Deliberately the caller's own share and nothing else: that org's spend\nand the margin on it are not restated here.",
|
||||
"orgEarningView.referredOrg": "ReferredOrg is the org slug this contribution came from — one the caller\nreferred, directly or up to three levels down.",
|
||||
"periodEarningView.commissionCents": "CommissionCents is what the caller earned that period, in cents: the sum over\neach referred org and upline level of margin × that level's rate. Always ≤\nmarginCents, by construction.",
|
||||
"periodEarningView.marginCents": "MarginCents is the margin Hanzo earned in that period on the spend of every\norg the caller referred, in cents — the base commission is a rate OF. It is\nthe aggregate base, never any one customer's bill.",
|
||||
"periodEarningView.period": "Period is the accrual bucket: the UTC year-month, \"YYYY-MM\". Commission is\nlatched at most once per referred org per period, so one row is one month.",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/affiliates/me/links", zip.Doc{
|
||||
Description: "Answers the caller's share links, each with its URL and its funnel:\nclicks tracked, signups — orgs attributed with that code — and conversions,\nmeaning how many of those signups have actually produced commission.\n\nSignups and conversions are DERIVED from the commission ledger and never\nstored, so they cannot drift from the money. Clicks are the one stored counter\nand the one that is pure vanity.\n\nAny pending public click pings are folded into the store before the read, in\none batch — which is how the counters stay current without a database write\nper click. Scoped to the validated caller's own affiliate; a non-affiliate\ngets `isAffiliate:false` and the link cap.",
|
||||
Fields: map[string]string{
|
||||
"affiliateLinks.links": "Links is the caller's share links, each with its URL and funnel.",
|
||||
"affiliateLinks.maxLinks": "MaxLinks is how many share links one affiliate may hold.",
|
||||
"affiliateLinks.isAffiliate": "IsAffiliate says whether the caller org has an affiliate record. On false only\nmaxLinks comes back — there are no links, and there is no link to mint until\nthe org applies and is approved.",
|
||||
"affiliateLinks.links": "Links is the caller's share links, each with its URL and funnel.",
|
||||
"affiliateLinks.maxLinks": "MaxLinks is how many share links one affiliate may hold.",
|
||||
"affiliateLinks.status": "Status is the caller's affiliate status: \"applied\", \"approved\" or\n\"suspended\"; absent for a non-affiliate. Minting a link requires \"approved\",\nbecause a link that cannot accrue quietly loses the referral.",
|
||||
"codeView.clicks": "Clicks is how many pings this code has taken. The one STORED counter here and\npure vanity: no accrual or payout reads it, pings are coalesced in memory and\nflushed in batches, and a dropped tally is accepted rather than contending\nwith the money write path. Do not reconcile it against anything.",
|
||||
"codeView.code": "Code is the link's slug — 3–32 chars of a–z, 0–9 and hyphen — unique across\nthe WHOLE directory, so any affiliate's code resolves an attribution.",
|
||||
"codeView.conversions": "Conversions is how many of those signups have actually produced positive\ncommission for the caller. Also derived, from the accrual rows, so it is\n≤ signups and lags a referral until the first sweep after it spends.",
|
||||
"codeView.createdAt": "CreatedAt is when the link was minted, Unix seconds UTC.",
|
||||
"codeView.label": "Label is the caller's own note for the link (\"twitter\", \"newsletter\").\nCosmetic: trimmed, stripped of control characters, capped at 48 bytes, and\nnever part of the code. \"primary\" on the link mirrored at approval.",
|
||||
"codeView.signups": "Signups is how many orgs were attributed with this code — DERIVED by counting\nattribution edges, never stored, so it cannot drift from the ledger.",
|
||||
"codeView.url": "URL is the full shareable link, the brand host plus ?aff=<code>. The host is\nthe deployment's own brand, so a Lux or Zoo install never mints a hanzo.ai\nlink.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/admin/affiliates/:id/approve", zip.Doc{
|
||||
Description: "Approves an affiliate and MINTS its referral code — the moment\nthe partner has a working share link and starts accruing.\n\nThe code is taken from the body if one is given, else the vanity code the\napplicant requested, else a slug derived for them. Codes are ONE global\nnamespace, so a taken code is a 409 and nothing is approved. The minted code\nis also mirrored as a link row so click tracking is uniform across every code\nthe affiliate holds; that mirror is best-effort and its failure never fails\nthe approval.\n\nApproval is what makes an affiliate eligible: before it, attribution against\nits code does not resolve and no sweep accrues to it. PLATFORM SUDO ONLY.\nAudited.",
|
||||
Fields: map[string]string{
|
||||
"approval.code": "Code overrides the minted code; else the requested vanity code, else a\nderived slug.",
|
||||
"approval.id": "ID is the affiliate to approve, from the path.",
|
||||
"adminAffiliateView.accruedCents": "AccruedCents is lifetime commission accrued, in cents. It only grows — a\npayout moves paidCents, never this.",
|
||||
"adminAffiliateView.approvedAt": "ApprovedAt is when staff approved, Unix seconds UTC. 0 means never approved.",
|
||||
"adminAffiliateView.code": "Code is the minted referral code, the slug the ?aff link carries. Empty until\napproval mints it. Codes are one global namespace across all affiliates.",
|
||||
"adminAffiliateView.createdAt": "CreatedAt is when the org applied, Unix seconds UTC.",
|
||||
"adminAffiliateView.id": "ID is the affiliate's server-minted handle, \"aff_\"-prefixed — the id the\napprove, suspend, rate and payout routes address.",
|
||||
"adminAffiliateView.org": "Org is the partner's own org slug. It appears ONLY on this cross-tenant admin\nview; no partner-facing read ever names another org.",
|
||||
"adminAffiliateView.paidCents": "PaidCents is lifetime commission already paid out, in cents — credits grants\nand record-only cash disbursements alike.",
|
||||
"adminAffiliateView.pendingCents": "PendingCents is accrued minus paid, in cents: what is still owed, and the hard\nceiling the next payout is reserved against. Never negative.",
|
||||
"adminAffiliateView.rateBps": "RateBps is this affiliate's DIRECT (level 1) commission rate in basis points\nOF Hanzo's margin (2000 = 20% of margin, never of the customer's bill). Levels\n2 and 3 are platform-wide switches and are not carried per affiliate.",
|
||||
"adminAffiliateView.referredCount": "ReferredCount is how many orgs this affiliate is the DIRECT referrer of,\ncounted from the attribution edges. It is 0 on the single-affiliate answers\n(approve, suspend, rate, payout), which do not run the count.",
|
||||
"adminAffiliateView.requestedCode": "RequestedCode is the vanity code the applicant asked for. A request, not an\nallocation: approval mints a different slug if this one was taken. Absent when\nnone was asked for.",
|
||||
"adminAffiliateView.status": "Status is \"applied\", \"approved\" or \"suspended\". Only \"approved\" resolves for\nattribution and accrues; \"suspended\" stops future earning and claws nothing\nback.",
|
||||
"adminAffiliateView.suspendedAt": "SuspendedAt is when staff suspended, Unix seconds UTC. 0 means never\nsuspended; it is not cleared by a later re-approval.",
|
||||
"affiliateData.affiliate": "Affiliate is the row as it stands AFTER the action that returned it. Its\nreferredCount is 0 here: these single-affiliate answers do not run the count.",
|
||||
"affiliateOut.data": "Data carries the affiliate row the action just wrote.",
|
||||
"approval.code": "Code overrides the minted code; else the requested vanity code, else a\nderived slug.",
|
||||
"approval.id": "ID is the affiliate to approve, from the path.",
|
||||
"envelope.msg": "Msg is an operator-facing note. Empty on every success here — it exists\nbecause the console's admin unwrapper reads the shape cloud.OK writes.",
|
||||
"envelope.status": "Status is \"ok\" on every 2xx from this surface; a failure is an HTTP error with\nzip's error body, not this envelope carrying a different word.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/admin/affiliates/:id/payout", zip.Doc{
|
||||
Description: "Pays out accrued commission and answers the payout row with the\naffiliate's updated balances.\n\nThe amount is reserved atomically against the affiliate's PENDING commission —\naccrued minus paid — so a payout can never exceed what is owed. The METHOD\ndecides whether money actually moves: `credits` issues a commerce grant into\nthe affiliate ORG's own wallet, tagged so the ledger can tell an affiliate\npayout apart from an admin or referral grant; every other method — wire,\npaypal and the rest — is RECORD-ONLY: the payout row and the balances move,\nthe cash is disbursed out of band.\n\nThe amount is integer cents and must be positive. PLATFORM SUDO ONLY.\nAudited.",
|
||||
Fields: map[string]string{
|
||||
"disbursal.amountCents": "AmountCents is the payout, integer cents; it must be positive and can\nnever exceed the affiliate's pending commission. Body-only (`url:\"-\"`,\nlike every money field here): a payout must never ride the URL into\naccess logs, and the raw handler read only the body.",
|
||||
"disbursal.id": "ID is the affiliate to pay, from the path.",
|
||||
"disbursal.method": "Method decides whether money moves: `credits` issues a commerce grant,\nevery other method (wire, paypal, …) is record-only.",
|
||||
"disbursal.reference": "Reference is the operator's settlement note (a bank id, a ledger ref).",
|
||||
"adminAffiliateView.accruedCents": "AccruedCents is lifetime commission accrued, in cents. It only grows — a\npayout moves paidCents, never this.",
|
||||
"adminAffiliateView.approvedAt": "ApprovedAt is when staff approved, Unix seconds UTC. 0 means never approved.",
|
||||
"adminAffiliateView.code": "Code is the minted referral code, the slug the ?aff link carries. Empty until\napproval mints it. Codes are one global namespace across all affiliates.",
|
||||
"adminAffiliateView.createdAt": "CreatedAt is when the org applied, Unix seconds UTC.",
|
||||
"adminAffiliateView.id": "ID is the affiliate's server-minted handle, \"aff_\"-prefixed — the id the\napprove, suspend, rate and payout routes address.",
|
||||
"adminAffiliateView.org": "Org is the partner's own org slug. It appears ONLY on this cross-tenant admin\nview; no partner-facing read ever names another org.",
|
||||
"adminAffiliateView.paidCents": "PaidCents is lifetime commission already paid out, in cents — credits grants\nand record-only cash disbursements alike.",
|
||||
"adminAffiliateView.pendingCents": "PendingCents is accrued minus paid, in cents: what is still owed, and the hard\nceiling the next payout is reserved against. Never negative.",
|
||||
"adminAffiliateView.rateBps": "RateBps is this affiliate's DIRECT (level 1) commission rate in basis points\nOF Hanzo's margin (2000 = 20% of margin, never of the customer's bill). Levels\n2 and 3 are platform-wide switches and are not carried per affiliate.",
|
||||
"adminAffiliateView.referredCount": "ReferredCount is how many orgs this affiliate is the DIRECT referrer of,\ncounted from the attribution edges. It is 0 on the single-affiliate answers\n(approve, suspend, rate, payout), which do not run the count.",
|
||||
"adminAffiliateView.requestedCode": "RequestedCode is the vanity code the applicant asked for. A request, not an\nallocation: approval mints a different slug if this one was taken. Absent when\nnone was asked for.",
|
||||
"adminAffiliateView.status": "Status is \"applied\", \"approved\" or \"suspended\". Only \"approved\" resolves for\nattribution and accrues; \"suspended\" stops future earning and claws nothing\nback.",
|
||||
"adminAffiliateView.suspendedAt": "SuspendedAt is when staff suspended, Unix seconds UTC. 0 means never\nsuspended; it is not cleared by a later re-approval.",
|
||||
"disbursal.amountCents": "AmountCents is the payout, integer cents; it must be positive and can\nnever exceed the affiliate's pending commission. Body-only (`url:\"-\"`,\nlike every money field here): a payout must never ride the URL into\naccess logs, and the raw handler read only the body.",
|
||||
"disbursal.id": "ID is the affiliate to pay, from the path.",
|
||||
"disbursal.method": "Method decides whether money moves: `credits` issues a commerce grant,\nevery other method (wire, paypal, …) is record-only.",
|
||||
"disbursal.reference": "Reference is the operator's settlement note (a bank id, a ledger ref).",
|
||||
"envelope.msg": "Msg is an operator-facing note. Empty on every success here — it exists\nbecause the console's admin unwrapper reads the shape cloud.OK writes.",
|
||||
"envelope.status": "Status is \"ok\" on every 2xx from this surface; a failure is an HTTP error with\nzip's error body, not this envelope carrying a different word.",
|
||||
"payoutOut.data": "Data is the recorded payout and the balances it left behind.",
|
||||
"remittance.amountCents": "AmountCents is the amount disbursed, in cents. It was reserved against pending\ncommission atomically when recorded, so it never exceeds what was owed.",
|
||||
"remittance.createdAt": "CreatedAt is when the payout was recorded, Unix seconds UTC — when the balance\nmoved, not necessarily when the cash landed.",
|
||||
"remittance.id": "ID is the payout row's server-minted handle, \"apo_\"-prefixed.",
|
||||
"remittance.method": "Method is how it was settled. \"credits\" issued a commerce grant into the\naffiliate org's own wallet; any other value (wire, paypal, check, …) is a\nRECORD of cash a human moved out of band.",
|
||||
"remittance.reference": "Reference is the operator's settlement note — a bank id, a ledger ref. Free\ntext, absent when none was given.",
|
||||
"remittance.txn": "Txn is the commerce ledger transaction id, set ONLY where a \"credits\" payout\nactually issued the grant. Absent for cash methods, which write no ledger row.",
|
||||
"settlement.affiliate": "Affiliate is the row re-read AFTER the payout, so its paidCents and\npendingCents already account for the row beside it.",
|
||||
"settlement.payout": "Payout is the payout row just recorded.",
|
||||
},
|
||||
Example: json.RawMessage(`{"amountCents":1200,"method":"credits","reference":"ledger-1"}`),
|
||||
})
|
||||
zip.Describe("POST /v1/admin/affiliates/:id/rate", zip.Doc{
|
||||
Description: "Sets one affiliate's DIRECT commission rate, in basis points of\nHanzo's margin.\n\nThe rate is CAPPED so that the direct rate plus the platform-wide second- and\nthird-level rates can never exceed the whole margin — the structural guarantee\nthat everything paid on one source event stays inside the margin actually\nearned. The cap is resolved from the rates in force at the moment of the call\nand quoted in the refusal, because those switches move; a hardcoded bound\nwould start lying the moment somebody edits the schedule.\n\nOnly the direct level is per-affiliate. The second and third levels are\nplatform switches and are not settable here. The change applies to FUTURE\naccruals — commission already latched for a period is not recomputed. PLATFORM\nSUDO ONLY. Audited.",
|
||||
Fields: map[string]string{
|
||||
"rateSet.id": "ID is the affiliate whose direct rate moves, from the path.",
|
||||
"rateSet.rateBps": "RateBps is the direct commission rate, in basis points of Hanzo's margin;\ncapped so the whole L1+L2+L3 schedule never exceeds the margin. Body-only\n(`url:\"-\"`): a money parameter must never ride the URL into access logs.",
|
||||
"adminAffiliateView.accruedCents": "AccruedCents is lifetime commission accrued, in cents. It only grows — a\npayout moves paidCents, never this.",
|
||||
"adminAffiliateView.approvedAt": "ApprovedAt is when staff approved, Unix seconds UTC. 0 means never approved.",
|
||||
"adminAffiliateView.code": "Code is the minted referral code, the slug the ?aff link carries. Empty until\napproval mints it. Codes are one global namespace across all affiliates.",
|
||||
"adminAffiliateView.createdAt": "CreatedAt is when the org applied, Unix seconds UTC.",
|
||||
"adminAffiliateView.id": "ID is the affiliate's server-minted handle, \"aff_\"-prefixed — the id the\napprove, suspend, rate and payout routes address.",
|
||||
"adminAffiliateView.org": "Org is the partner's own org slug. It appears ONLY on this cross-tenant admin\nview; no partner-facing read ever names another org.",
|
||||
"adminAffiliateView.paidCents": "PaidCents is lifetime commission already paid out, in cents — credits grants\nand record-only cash disbursements alike.",
|
||||
"adminAffiliateView.pendingCents": "PendingCents is accrued minus paid, in cents: what is still owed, and the hard\nceiling the next payout is reserved against. Never negative.",
|
||||
"adminAffiliateView.rateBps": "RateBps is this affiliate's DIRECT (level 1) commission rate in basis points\nOF Hanzo's margin (2000 = 20% of margin, never of the customer's bill). Levels\n2 and 3 are platform-wide switches and are not carried per affiliate.",
|
||||
"adminAffiliateView.referredCount": "ReferredCount is how many orgs this affiliate is the DIRECT referrer of,\ncounted from the attribution edges. It is 0 on the single-affiliate answers\n(approve, suspend, rate, payout), which do not run the count.",
|
||||
"adminAffiliateView.requestedCode": "RequestedCode is the vanity code the applicant asked for. A request, not an\nallocation: approval mints a different slug if this one was taken. Absent when\nnone was asked for.",
|
||||
"adminAffiliateView.status": "Status is \"applied\", \"approved\" or \"suspended\". Only \"approved\" resolves for\nattribution and accrues; \"suspended\" stops future earning and claws nothing\nback.",
|
||||
"adminAffiliateView.suspendedAt": "SuspendedAt is when staff suspended, Unix seconds UTC. 0 means never\nsuspended; it is not cleared by a later re-approval.",
|
||||
"affiliateData.affiliate": "Affiliate is the row as it stands AFTER the action that returned it. Its\nreferredCount is 0 here: these single-affiliate answers do not run the count.",
|
||||
"affiliateOut.data": "Data carries the affiliate row the action just wrote.",
|
||||
"envelope.msg": "Msg is an operator-facing note. Empty on every success here — it exists\nbecause the console's admin unwrapper reads the shape cloud.OK writes.",
|
||||
"envelope.status": "Status is \"ok\" on every 2xx from this surface; a failure is an HTTP error with\nzip's error body, not this envelope carrying a different word.",
|
||||
"rateSet.id": "ID is the affiliate whose direct rate moves, from the path.",
|
||||
"rateSet.rateBps": "RateBps is the direct commission rate, in basis points of Hanzo's margin;\ncapped so the whole L1+L2+L3 schedule never exceeds the margin. Body-only\n(`url:\"-\"`): a money parameter must never ride the URL into access logs.",
|
||||
},
|
||||
Example: json.RawMessage(`{"rateBps":2500}`),
|
||||
})
|
||||
zip.Describe("POST /v1/admin/affiliates/:id/suspend", zip.Doc{
|
||||
Description: "Suspends an affiliate: it stops accruing on the next sweep, and\nits code stops resolving for new attributions.\n\nIt CLAWS NOTHING BACK. Commission already accrued stays accrued and stays\npayable, and existing attribution edges are left standing — suspension ends\nearning, it does not unwind history. PLATFORM SUDO ONLY. Audited.",
|
||||
Fields: map[string]string{
|
||||
"affiliateRef.id": "ID is the affiliate's server-minted handle, \"aff_\"-prefixed.",
|
||||
"adminAffiliateView.accruedCents": "AccruedCents is lifetime commission accrued, in cents. It only grows — a\npayout moves paidCents, never this.",
|
||||
"adminAffiliateView.approvedAt": "ApprovedAt is when staff approved, Unix seconds UTC. 0 means never approved.",
|
||||
"adminAffiliateView.code": "Code is the minted referral code, the slug the ?aff link carries. Empty until\napproval mints it. Codes are one global namespace across all affiliates.",
|
||||
"adminAffiliateView.createdAt": "CreatedAt is when the org applied, Unix seconds UTC.",
|
||||
"adminAffiliateView.id": "ID is the affiliate's server-minted handle, \"aff_\"-prefixed — the id the\napprove, suspend, rate and payout routes address.",
|
||||
"adminAffiliateView.org": "Org is the partner's own org slug. It appears ONLY on this cross-tenant admin\nview; no partner-facing read ever names another org.",
|
||||
"adminAffiliateView.paidCents": "PaidCents is lifetime commission already paid out, in cents — credits grants\nand record-only cash disbursements alike.",
|
||||
"adminAffiliateView.pendingCents": "PendingCents is accrued minus paid, in cents: what is still owed, and the hard\nceiling the next payout is reserved against. Never negative.",
|
||||
"adminAffiliateView.rateBps": "RateBps is this affiliate's DIRECT (level 1) commission rate in basis points\nOF Hanzo's margin (2000 = 20% of margin, never of the customer's bill). Levels\n2 and 3 are platform-wide switches and are not carried per affiliate.",
|
||||
"adminAffiliateView.referredCount": "ReferredCount is how many orgs this affiliate is the DIRECT referrer of,\ncounted from the attribution edges. It is 0 on the single-affiliate answers\n(approve, suspend, rate, payout), which do not run the count.",
|
||||
"adminAffiliateView.requestedCode": "RequestedCode is the vanity code the applicant asked for. A request, not an\nallocation: approval mints a different slug if this one was taken. Absent when\nnone was asked for.",
|
||||
"adminAffiliateView.status": "Status is \"applied\", \"approved\" or \"suspended\". Only \"approved\" resolves for\nattribution and accrues; \"suspended\" stops future earning and claws nothing\nback.",
|
||||
"adminAffiliateView.suspendedAt": "SuspendedAt is when staff suspended, Unix seconds UTC. 0 means never\nsuspended; it is not cleared by a later re-approval.",
|
||||
"affiliateData.affiliate": "Affiliate is the row as it stands AFTER the action that returned it. Its\nreferredCount is 0 here: these single-affiliate answers do not run the count.",
|
||||
"affiliateOut.data": "Data carries the affiliate row the action just wrote.",
|
||||
"affiliateRef.id": "ID is the affiliate's server-minted handle, \"aff_\"-prefixed.",
|
||||
"envelope.msg": "Msg is an operator-facing note. Empty on every success here — it exists\nbecause the console's admin unwrapper reads the shape cloud.OK writes.",
|
||||
"envelope.status": "Status is \"ok\" on every 2xx from this surface; a failure is an HTTP error with\nzip's error body, not this envelope carrying a different word.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/admin/affiliates/sweep", zip.Doc{
|
||||
Description: "Runs the accrual: for each referred org it reads that org's metered\nspend for the current period and accrues commission to every affiliate up its\nreferral chain, then answers how many sources were swept and how many NEW\naccruals landed.\n\nThis is the cron path, and it is LATCHED at most once per affiliate, source\norg and period — so re-running it inside the same period accrues nothing\nfurther. Safe to retry, and safe to run by hand beside the schedule.\n\nCommission is a rate of Hanzo's MARGIN on that spend, never of the customer's\ngross bill, so every level's share summed over one source event stays within\nthe margin actually earned and the customer's charge is untouched. Nothing\naccrues past the third upline level, and only an APPROVED affiliate accrues at\nall.\n\nThe same spend read drives the OSS author royalty — one read, both programs —\nso the answer reports royalties accrued alongside. PLATFORM SUDO ONLY. Bounded\nper run; a source whose spend cannot be read is skipped and picked up next\ntime, never half-accrued.",
|
||||
Fields: map[string]string{
|
||||
"accruals.royaltyFailures": "RoyaltyFailures is reported, not swallowed: a sweep that could not reach\nthe royalty store must not read as one that found nothing owed. The count\nwas already computed and then dropped on the floor, which is the same\nsilence the typed leg was added to end.",
|
||||
"accruals.accrued": "Accrued is how many NEW commission accruals this run created, counted across\nevery upline level. The accrual is latched at most once per (affiliate, source\norg, period), so a re-run inside the same month reports 0 having changed\nnothing — 0 means \"already accrued\", not \"failed\".",
|
||||
"accruals.royaltiesAccrued": "RoyaltiesAccrued is how many OSS-author royalty accruals the SAME spend read\nproduced in the sibling authors program. One read drives both.",
|
||||
"accruals.royaltyFailures": "RoyaltyFailures is reported, not swallowed: a sweep that could not reach\nthe royalty store must not read as one that found nothing owed. The count\nwas already computed and then dropped on the floor, which is the same\nsilence the typed leg was added to end.",
|
||||
"accruals.swept": "Swept is how many source (referred) orgs the run visited, bounded at 500 per\nrun. A source with no spend this period, or one whose spend could not be read,\nstill counts as swept.",
|
||||
"accrualsOut.data": "Data is what the run did: sources visited, new accruals, royalties alongside.",
|
||||
"envelope.msg": "Msg is an operator-facing note. Empty on every success here — it exists\nbecause the console's admin unwrapper reads the shape cloud.OK writes.",
|
||||
"envelope.status": "Status is \"ok\" on every 2xx from this surface; a failure is an HTTP error with\nzip's error body, not this envelope carrying a different word.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/affiliates/apply", zip.Doc{
|
||||
Description: "Enrolls the caller's OWN org as an affiliate at status `applied`,\noptionally requesting a vanity code, and answers the record — 201 on the first\napply, 200 with `created:false` afterwards.\n\nIDEMPOTENT, first apply wins: one affiliate per org, so re-applying never\ncreates a second row and never resets an existing approval. Applying is not\njoining — no code is minted and nothing accrues until staff approve, which is\nwhere both the code and the commission rate come from.\n\nThe org is the validated caller's, never a field. A malformed vanity code is\nrefused up front; the code is only REQUESTED here, and approval may mint a\ndifferent one if the requested code is taken.",
|
||||
Fields: map[string]string{
|
||||
"application.code": "Code is the minted referral code. Empty on a first apply — applying does not\nmint a code, approval does; a re-apply echoes whatever the row already holds.",
|
||||
"application.created": "Created says whether THIS call made the row. false means the org had already\napplied and nothing changed — no second row, no reset of an existing approval.\nThe HTTP status states the same fact: 201 when true, 200 when false.",
|
||||
"application.id": "ID is the affiliate's server-minted handle, \"aff_\"-prefixed — the id staff\napprove, suspend, re-rate and pay against.",
|
||||
"application.rateBps": "RateBps is the direct (level 1) commission rate the row carries, in basis\npoints OF Hanzo's margin (2000 = 20% of margin, never of the customer's bill).",
|
||||
"application.requestedCode": "RequestedCode echoes the vanity code asked for, normalized to lower case. It\nis a request only: approval mints a different slug if this one is taken.",
|
||||
"application.status": "Status is \"applied\" for a row this call created. A re-apply echoes the\nexisting row's status, which may already be \"approved\" or \"suspended\".",
|
||||
"applyRequest.requestedCode": "RequestedCode is the vanity code the applicant asks for; approval may mint\na different one if it is taken. Body-only: the URL cannot supply it.",
|
||||
},
|
||||
Example: json.RawMessage(`{"requestedCode":"acme"}`),
|
||||
@@ -116,13 +306,18 @@ func init() {
|
||||
Description: "Records the first-touch edge every later commission is computed\nfrom: the caller's org was referred by the affiliate that owns this code.\n\nThe REFERRED org is the validated caller, never a field. A caller that could\nname the referred org could attach itself to somebody else's revenue. The\naffiliate is resolved from the code, and only an APPROVED affiliate's code\nresolves.\n\nFIRST TOUCH WINS, set once: one affiliate per referred org, so a re-post\nanswers the existing edge with `created:false` rather than moving the\nattribution. Self-attribution is refused, and so is a code that would make a\ncycle in the upline chain. An unknown code is a 404, deliberately: an\naffiliate code IS a public shareable link, so whether one is real is public by\ndesign, and the caller legitimately needs to know its link resolved.\n\nA user-level mirror of the edge is written best-effort; a conflict there never\nfails the org attribution, which is the money-bearing one.",
|
||||
Fields: map[string]string{
|
||||
"attributeRequest.code": "Code is the affiliate code the referred org arrived with. Body-only: the\nURL cannot supply it.",
|
||||
"attribution.code": "Code is the affiliate code the edge was recorded under, normalized to lower\ncase. On a re-post it is the code of the STANDING edge, which may differ from\nthe one just sent — first touch wins.",
|
||||
"attribution.created": "Created says whether THIS call made the edge. false means the caller org was\nalready attributed and nothing moved. The HTTP status says the same: 201 when\ntrue, 200 when false.",
|
||||
"attribution.createdAt": "CreatedAt is when the edge was FIRST recorded, Unix seconds UTC. On a re-post\nit is the original time, not now.",
|
||||
"attribution.id": "ID is the attribution edge's server-minted handle, \"afr_\"-prefixed.",
|
||||
},
|
||||
Example: json.RawMessage(`{"code":"acme"}`),
|
||||
})
|
||||
zip.Describe("POST /v1/affiliates/click", zip.Doc{
|
||||
Description: "Counts a click on a share link. PUBLIC — it takes no principal, because\na visitor clicking a shareable link has no session yet.\n\nThe ping folds into an in-memory buffer and NEVER writes the money database\nsynchronously, so a click flood cannot contend with the accrual and payout\nwrite path; tallies are flushed in one batch on the next authenticated links\nread and at shutdown. Clicks are a vanity metric: no accrual and no payout\never reads them — those key on real metered spend — so click inflation cannot\nmove money.\n\nAny well-formed code is accepted WITHOUT checking that it exists,\ndeliberately: this is not a code-existence oracle. `counted` reports that the\nbuffer took the ping, not that the code is real; an unknown code simply no-ops\nat flush time.",
|
||||
Fields: map[string]string{
|
||||
"clickRequest.code": "Code is the share-link code that was clicked. Body-only: the URL cannot\nsupply it.",
|
||||
"clickCount.counted": "Counted says the in-memory buffer took the ping. It does NOT say the code\nexists — this is deliberately not a code-existence oracle, and an unknown code\nsimply no-ops at flush time. false means the buffer was full and the ping was\ndropped, which is harmless: clicks are vanity and move no money.",
|
||||
"clickRequest.code": "Code is the share-link code that was clicked. Body-only: the URL cannot\nsupply it.",
|
||||
},
|
||||
Example: json.RawMessage(`{"code":"acme"}`),
|
||||
})
|
||||
@@ -130,14 +325,23 @@ func init() {
|
||||
Description: "Sets the caller's public leaderboard display name, or clears it.\n\nThe handle IS the opt-in. An empty handle opts out: the affiliate keeps its\nrank and can still see its own row, it simply stops being listed to anyone\nelse. That is the whole privacy control — there is no separate visibility\nflag, and no way to be listed without choosing a name.\n\nRequires a validated principal and an existing affiliate record; apply first.\nThe handle is bounded and restricted to letters, digits, space, hyphen,\nunderscore and dot.",
|
||||
Fields: map[string]string{
|
||||
"handleRequest.handle": "Handle is the public leaderboard display name; empty opts out. Body-only:\nthe URL cannot supply it.",
|
||||
"handleSet.handle": "Handle is the display name as STORED, echoed back after trimming. Empty means\nthe caller opted out: it keeps its rank and still sees its own row, it is just\nno longer listed to anyone else.",
|
||||
},
|
||||
Example: json.RawMessage(`{"handle":"acme partners"}`),
|
||||
})
|
||||
zip.Describe("POST /v1/affiliates/me/links", zip.Doc{
|
||||
Description: "Mints a new share link for the caller's own affiliate and answers it\nwith its full URL, 201.\n\nAPPROVAL IS REQUIRED: an org that has applied but is not approved is refused,\nbecause a link that cannot accrue is a link that quietly loses the referral. A\nrequested vanity code must be valid and free across the WHOLE directory —\ncodes are one global namespace, so a taken code is a 409 rather than a silent\nalias. Omit the code and a random one is minted.\n\nBounded per affiliate. The label is cosmetic: it is trimmed, stripped of\ncontrol characters and capped, and it is never part of a code.",
|
||||
Fields: map[string]string{
|
||||
"codeView.clicks": "Clicks is how many pings this code has taken. The one STORED counter here and\npure vanity: no accrual or payout reads it, pings are coalesced in memory and\nflushed in batches, and a dropped tally is accepted rather than contending\nwith the money write path. Do not reconcile it against anything.",
|
||||
"codeView.code": "Code is the link's slug — 3–32 chars of a–z, 0–9 and hyphen — unique across\nthe WHOLE directory, so any affiliate's code resolves an attribution.",
|
||||
"codeView.conversions": "Conversions is how many of those signups have actually produced positive\ncommission for the caller. Also derived, from the accrual rows, so it is\n≤ signups and lags a referral until the first sweep after it spends.",
|
||||
"codeView.createdAt": "CreatedAt is when the link was minted, Unix seconds UTC.",
|
||||
"codeView.label": "Label is the caller's own note for the link (\"twitter\", \"newsletter\").\nCosmetic: trimmed, stripped of control characters, capped at 48 bytes, and\nnever part of the code. \"primary\" on the link mirrored at approval.",
|
||||
"codeView.signups": "Signups is how many orgs were attributed with this code — DERIVED by counting\nattribution edges, never stored, so it cannot drift from the ledger.",
|
||||
"codeView.url": "URL is the full shareable link, the brand host plus ?aff=<code>. The host is\nthe deployment's own brand, so a Lux or Zoo install never mints a hanzo.ai\nlink.",
|
||||
"createLinkRequest.code": "Code is an optional vanity code; it must be free across the whole\ndirectory, and omitting it mints a random one. Body-only.",
|
||||
"createLinkRequest.label": "Label is cosmetic — trimmed, stripped of control characters, capped — and\nnever part of a code. Body-only: the URL cannot supply it.",
|
||||
"linkMint.link": "Link is the link just minted, with its full shareable URL. Its funnel counters\nall start at zero — nothing has clicked or signed up through it yet.",
|
||||
},
|
||||
Example: json.RawMessage(`{"label":"twitter"}`),
|
||||
})
|
||||
|
||||
+248
-27
@@ -196,6 +196,21 @@ type agentRunView struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
|
||||
// What an operator needs to answer "what ran, for whom, and what did it do" —
|
||||
// and, through traceId, to leave this record for the waterfall of the very
|
||||
// same run rather than a search that hopefully lands near it.
|
||||
//
|
||||
// Agent is on the row because the org-wide feed lists runs across agents, and
|
||||
// a run that cannot name its agent is an orphan in exactly the view built to
|
||||
// make sense of many of them. Every field is omitempty: a run recorded before
|
||||
// these columns existed reports absence rather than a zero it never measured.
|
||||
Agent string `json:"agent,omitempty"`
|
||||
Actor string `json:"actor,omitempty"`
|
||||
TraceID string `json:"traceId,omitempty"`
|
||||
PromptTokens int `json:"promptTokens,omitempty"`
|
||||
CompletionTokens int `json:"completionTokens,omitempty"`
|
||||
ToolCalls int `json:"toolCalls,omitempty"`
|
||||
}
|
||||
|
||||
// ---- overview shapes (console Agents dashboard: metrics + activity) ----
|
||||
@@ -270,6 +285,8 @@ func toRunView(r Run) agentRunView {
|
||||
return agentRunView{
|
||||
ID: r.ID, Status: r.Status, Model: cloud.ZenModel(r.Model), Input: r.Input, Output: r.Output,
|
||||
Error: r.Error, DurationMs: r.DurationMs, CreatedAt: rfc3339(r.CreatedAt),
|
||||
Agent: r.AgentName, Actor: r.Actor, TraceID: r.TraceID,
|
||||
PromptTokens: r.PromptTokens, CompletionTokens: r.CompletionTokens, ToolCalls: r.ToolCalls,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,6 +385,7 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
|
||||
// collision, not a precedence.
|
||||
zip.Get(g, "/metrics", o.metrics)
|
||||
zip.Get(g, "/activity", o.activity)
|
||||
zip.Get(g, "/runs", o.orgRuns)
|
||||
// Live agent-session control plane: /v1/agents/sessions[/...].
|
||||
mountSessions(s, app)
|
||||
// Agent targets: /v1/agents/targets[/...] — the #48 dispatch destinations a
|
||||
@@ -445,6 +463,18 @@ type runList struct {
|
||||
Runs []agentRunView `json:"runs"`
|
||||
}
|
||||
|
||||
// orgRunsQuery pages the org's runs across every agent.
|
||||
type orgRunsQuery struct {
|
||||
// Limit caps how many runs come back, newest first. Absent, zero or out of
|
||||
// range (1..200) reads as 50.
|
||||
Limit int `json:"limit"`
|
||||
// Status keeps only runs with this outcome ("ok" or "error"). Empty keeps
|
||||
// both. It is the filter an operator reaches for first — "show me what broke"
|
||||
// — and answering it here rather than by paging the whole history client-side
|
||||
// is the difference between a usable feed and a download.
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// metricsQuery selects the dashboard window.
|
||||
type metricsQuery struct {
|
||||
// Range is the window to bucket: 24H, 7D or 30D. Anything else reads as 30D.
|
||||
@@ -876,11 +906,41 @@ func runAgent(s *cloud.Service[state], ctx context.Context, a Agent, input, acto
|
||||
// nests under it, shipped over ZAP to o11y.
|
||||
ctx, span := agentTracer.Start(ctx, "agent.run "+a.Name, trace.WithSpanKind(trace.SpanKindInternal))
|
||||
defer span.End()
|
||||
|
||||
// The run's NAME, minted before the work rather than after it.
|
||||
//
|
||||
// It used to be minted at the end of executeRun, beside the row it fills in,
|
||||
// which reads naturally and made the run unobservable: every span the run
|
||||
// produced — the step, each tool call, each LLM call — was already finished
|
||||
// and exported by the time the run had a name, so none of them could carry
|
||||
// it, and neither could the per-token debits the metering decorator makes
|
||||
// round by round. The id existed only on the record of a thing that was
|
||||
// already over. Minting it here is what lets one value be on the span, on the
|
||||
// row and on the money, which is the whole of "drill into this run".
|
||||
id, _ := genID("run")
|
||||
|
||||
// hanzo.org, not a name of this package's own, because the TRACE PLANE reads
|
||||
// exactly this key: apps/o11y/planesink.go planeOrg files each row under
|
||||
// attrs["hanzo.org"] and falls back to the PLATFORM's org when it is absent.
|
||||
// It is a per-span read — OTel children do not inherit a parent's attributes —
|
||||
// so the correctly stamped HTTP span above this one buys the run nothing. A
|
||||
// run that named its tenant in a private spelling was stored as the platform's
|
||||
// own telemetry: invisible to the org-scoped read the console issues, and
|
||||
// sitting in the platform's bucket with this tenant's tool names and users in
|
||||
// it. One tenant attribute, the one the plane already reads.
|
||||
span.SetAttributes(
|
||||
attribute.String("hanzo.agent.name", a.Name),
|
||||
attribute.String("hanzo.agent.org", a.Org),
|
||||
attribute.String("hanzo.org", a.Org),
|
||||
attribute.String("gen_ai.request.model", a.Model),
|
||||
attribute.String("hanzo.agent.run_id", id),
|
||||
)
|
||||
// WHO, not just which tenant. org answers "whose ledger"; actor answers "which
|
||||
// person", and an operator asking why a run happened needs the second. A
|
||||
// scheduled run has no person and says so by carrying no attribute, rather
|
||||
// than by naming one that does not exist.
|
||||
if sub := actorSub(a.Org, actor); sub != "" {
|
||||
span.SetAttributes(attribute.String("hanzo.user", sub))
|
||||
}
|
||||
|
||||
fee := cloud.ResourceFeeCents(agentFeeEnvPrefix, meterKind)
|
||||
// Gate the AGENT's own org — never a caller default, never another tenant.
|
||||
@@ -892,12 +952,25 @@ func runAgent(s *cloud.Service[state], ctx context.Context, a Agent, input, acto
|
||||
return Run{}, err
|
||||
}
|
||||
|
||||
r := executeRun(ctx, s.State.ai, a.Org, a, input, s.State.failoverModel)
|
||||
r := executeRun(ctx, s.State.ai, a.Org, actor, a, input, s.State.failoverModel, id)
|
||||
// The trace this run IS, written onto the run itself. Without it the console
|
||||
// has a run with no way to reach its spans and a trace with no way to name its
|
||||
// run: two records of one event that cannot be joined. It is read off the live
|
||||
// span context, so it is the real id o11y stored, never a second one minted here.
|
||||
if sc := span.SpanContext(); sc.HasTraceID() {
|
||||
r.TraceID = sc.TraceID().String()
|
||||
}
|
||||
span.SetAttributes(
|
||||
attribute.String("hanzo.agent.run_id", r.ID),
|
||||
attribute.String("hanzo.agent.run_status", r.Status),
|
||||
attribute.Int64("hanzo.agent.duration_ms", r.DurationMs),
|
||||
attribute.String("gen_ai.response.model", r.Model),
|
||||
// The run's own token account, on the run's own span. The per-call gen_ai
|
||||
// spans carry each round's usage; a run is the sum of its rounds, and an
|
||||
// operator asking "how many tokens did this run cost" should not have to
|
||||
// add up a waterfall to find out.
|
||||
attribute.Int("gen_ai.usage.input_tokens", r.PromptTokens),
|
||||
attribute.Int("gen_ai.usage.output_tokens", r.CompletionTokens),
|
||||
attribute.Int("hanzo.agent.tool_calls", r.ToolCalls),
|
||||
)
|
||||
if r.Status == "error" {
|
||||
span.SetStatus(codes.Error, r.Error)
|
||||
@@ -950,19 +1023,40 @@ const (
|
||||
)
|
||||
|
||||
// executeRun composes the agent's instructions with the caller input and runs
|
||||
// one chat completion through the AI client — with a bounded retry on transient
|
||||
// upstream overload and, if the agent's own model stays throttled, ONE failover
|
||||
// to the deployment's reliable model (fallback) so an autonomous bot reply still
|
||||
// lands. It returns the resulting Run — status "ok" with output and Model set to
|
||||
// the model that ACTUALLY answered (so metering bills that model), or "error"
|
||||
// with the final upstream failure. Pure of HTTP and persistence so it is directly
|
||||
// testable; the caller records + responds. This reliability policy is the agent
|
||||
// runner's ALONE — the interactive user-facing chat path is untouched.
|
||||
func executeRun(ctx context.Context, ai types.AIClient, org string, a Agent, input, fallback string) Run {
|
||||
// the agent — with a bounded retry on transient upstream overload and, if the
|
||||
// agent's own model stays throttled, ONE failover to the deployment's reliable
|
||||
// model (fallback) so an autonomous bot reply still lands. It returns the
|
||||
// resulting Run — status "ok" with output and Model set to the model that
|
||||
// ACTUALLY answered (so metering bills that model), or "error" with the final
|
||||
// upstream failure. Pure of HTTP and persistence so it is directly testable; the
|
||||
// caller records + responds. This reliability policy is the agent runner's ALONE
|
||||
// — the interactive user-facing chat path is untouched.
|
||||
//
|
||||
// An agent that declares TOOLS and whose tools the plane actually offers runs the
|
||||
// bounded tool loop instead of a single completion (tools.go). One with none —
|
||||
// or one whose declared names resolve to nothing — takes the single completion
|
||||
// this has always been, unchanged.
|
||||
//
|
||||
// actor is the run's billing identity (billingActor's "org/sub"), threaded so a
|
||||
// tool dispatch runs as the principal the run is charged to.
|
||||
func executeRun(ctx context.Context, ai types.AIClient, org, actor string, a Agent, input, fallback, runID string) Run {
|
||||
// Child step span; the AI client opens its own GenAI span nested under this.
|
||||
ctx, span := agentTracer.Start(ctx, "agent.step", trace.WithSpanKind(trace.SpanKindInternal))
|
||||
defer span.End()
|
||||
span.SetAttributes(attribute.String("gen_ai.request.model", a.Model))
|
||||
// The run's name on every span it produces, not only on the root. A trace
|
||||
// query that finds a slow LLM call or a failing tool should answer "which run"
|
||||
// from the row it already has, rather than by walking parents up a waterfall —
|
||||
// and a step whose parent was dropped (a sampled or truncated trace) is still
|
||||
// attributable rather than orphaned.
|
||||
span.SetAttributes(
|
||||
attribute.String("gen_ai.request.model", a.Model),
|
||||
attribute.String("hanzo.agent.run_id", runID),
|
||||
// The tenant, on this span too. A step that named no org was filed under
|
||||
// the platform's, which put the middle of every run's waterfall in a
|
||||
// bucket the tenant cannot read — the run above it and the tool calls
|
||||
// below it were visible and the step joining them was not.
|
||||
attribute.String("hanzo.org", org),
|
||||
)
|
||||
|
||||
prompt := a.Instructions
|
||||
if in := strings.TrimSpace(input); in != "" {
|
||||
@@ -972,12 +1066,38 @@ func executeRun(ctx context.Context, ai types.AIClient, org string, a Agent, inp
|
||||
prompt += in
|
||||
}
|
||||
start := time.Now()
|
||||
resp, used, aiErr := completeWithFailover(ctx, ai, org, prompt, a.Model, fallback)
|
||||
var (
|
||||
resp *types.ChatResponse
|
||||
used string
|
||||
aiErr error
|
||||
)
|
||||
// An agent nested at the depth limit is offered nothing and has to answer for
|
||||
// itself — the one thing that stops a cycle of agents-as-tools, since each
|
||||
// level would otherwise start its round cap over (tools.go).
|
||||
var offer []string
|
||||
if agentDepth(ctx) < maxAgentDepth {
|
||||
offer = callableTools(a)
|
||||
}
|
||||
defs := runTools.catalog(ctx, org, actor, offer)
|
||||
// BOTH numbers, always. An agent that declares tools and is offered none is
|
||||
// the exact shape of the split-fleet gap tools.go describes, and it is only
|
||||
// diagnosable if the span says "declared 3, offered 0" rather than staying
|
||||
// silent about a run that quietly had no hands.
|
||||
span.SetAttributes(
|
||||
attribute.Int("hanzo.agent.tools_declared", len(a.Tools)),
|
||||
attribute.Int("hanzo.agent.tools", len(defs)),
|
||||
)
|
||||
var tools int
|
||||
if len(defs) > 0 {
|
||||
resp, used, aiErr, tools = completeWithTools(ctx, ai, org, actor, prompt, a.Model, fallback, defs, runID)
|
||||
} else {
|
||||
resp, used, aiErr = completeWithFailover(ctx, ai,
|
||||
&types.ChatRequest{Model: a.Model, Org: org, Prompt: prompt, RunID: runID}, fallback)
|
||||
}
|
||||
dur := time.Since(start).Milliseconds()
|
||||
id, _ := genID("run")
|
||||
r := Run{
|
||||
ID: id, Org: org, AgentName: a.Name, Model: used, Input: input,
|
||||
DurationMs: dur, CreatedAt: time.Now().Unix(),
|
||||
ID: runID, Org: org, AgentName: a.Name, Model: used, Input: input, Actor: actor,
|
||||
DurationMs: dur, CreatedAt: time.Now().Unix(), ToolCalls: tools,
|
||||
}
|
||||
if aiErr != nil {
|
||||
span.RecordError(aiErr)
|
||||
@@ -988,25 +1108,51 @@ func executeRun(ctx context.Context, ai types.AIClient, org string, a Agent, inp
|
||||
r.Status = "ok"
|
||||
if resp != nil {
|
||||
r.Output = resp.Content
|
||||
// The tokens the gateway actually reported. They were already in hand
|
||||
// here and thrown away, which is why a run could be billed for an
|
||||
// amount nothing on the run could explain.
|
||||
r.PromptTokens, r.CompletionTokens = resp.PromptTokens, resp.CompletionTokens
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// completeWithFailover runs the completion on the agent's model with a bounded
|
||||
// completeWithFailover runs one completion on req's own model with a bounded
|
||||
// retry (completeWithRetry), then — only if that model is STILL throttled after
|
||||
// its retries — fails over ONCE to fallback, a reliable model. It returns the
|
||||
// response, the model that actually produced it (for honest metering), and the
|
||||
// final error. A non-transient failure on either model returns immediately (the
|
||||
// next model would fail identically). ONE ordered mechanism, no config sprawl.
|
||||
func completeWithFailover(ctx context.Context, ai types.AIClient, org, prompt, model, fallback string) (*types.ChatResponse, string, error) {
|
||||
//
|
||||
// It takes the whole request rather than a prompt string because a tool round IS
|
||||
// the request: the transcript so far and the tools on offer are part of what is
|
||||
// being retried, and a helper that only knew a prompt would have to grow a second
|
||||
// copy of this policy for the loop to reuse (tools.go). req.Model is set per
|
||||
// attempt; everything else is the caller's.
|
||||
func completeWithFailover(ctx context.Context, ai types.AIClient, req *types.ChatRequest, fallback string) (*types.ChatResponse, string, error) {
|
||||
model := req.Model
|
||||
models := []string{model}
|
||||
if f := strings.TrimSpace(fallback); f != "" && f != model {
|
||||
models = append(models, f)
|
||||
}
|
||||
var lastErr error
|
||||
for _, m := range models {
|
||||
resp, err := completeWithRetry(ctx, ai, org, prompt, m)
|
||||
for i, m := range models {
|
||||
req.Model = m
|
||||
resp, attempts, err := completeWithRetry(ctx, ai, req)
|
||||
// A model call that did not succeed first time, said out loud. Retries and
|
||||
// failovers were previously invisible AS SUCH: each attempt produced its own
|
||||
// chat span, so three attempts looked like three unrelated calls and the
|
||||
// switch to the reliable model looked like an agent that had simply asked
|
||||
// for a different one. The waterfall showed the cost and never the reason.
|
||||
//
|
||||
// It is an EVENT, not an attribute, because completeWithFailover runs once
|
||||
// per ROUND of the tool loop — an attribute would be overwritten by every
|
||||
// later round and the span would report only the last one, while events
|
||||
// accumulate. And nothing at all is recorded for the ordinary case, so the
|
||||
// presence of one of these always means something happened.
|
||||
if attempts > 1 || i > 0 {
|
||||
noteRetry(ctx, m, attempts, i > 0, err)
|
||||
}
|
||||
if err == nil {
|
||||
return resp, m, nil
|
||||
}
|
||||
@@ -1023,25 +1169,48 @@ func completeWithFailover(ctx context.Context, ai types.AIClient, org, prompt, m
|
||||
// completeWithRetry calls the completion up to maxAttempts times, retrying ONLY a
|
||||
// transient upstream overload (types.ErrUpstreamBusy) with jittered backoff and
|
||||
// respecting context cancellation. A non-transient error returns immediately.
|
||||
func completeWithRetry(ctx context.Context, ai types.AIClient, org, prompt, model string) (*types.ChatResponse, error) {
|
||||
// It returns how many attempts it MADE alongside the outcome, so the caller can
|
||||
// record a retry as a retry. Counting inside is the only place the number is
|
||||
// known — from outside, three attempts and three unrelated calls look identical.
|
||||
func completeWithRetry(ctx context.Context, ai types.AIClient, req *types.ChatRequest) (*types.ChatResponse, int, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
resp, err := ai.ChatCompletion(ctx, &types.ChatRequest{Model: model, Prompt: prompt, Org: org})
|
||||
resp, err := ai.ChatCompletion(ctx, req)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
return resp, attempt + 1, nil
|
||||
}
|
||||
lastErr = err
|
||||
if !errors.Is(err, types.ErrUpstreamBusy) {
|
||||
return nil, err // permanent — do not burn retries repeating it
|
||||
return nil, attempt + 1, err // permanent — do not burn retries repeating it
|
||||
}
|
||||
if attempt == maxAttempts-1 {
|
||||
break
|
||||
}
|
||||
if err := sleepBackoff(ctx, attempt); err != nil {
|
||||
return nil, err // context cancelled/expired mid-backoff
|
||||
return nil, attempt + 1, err // context cancelled/expired mid-backoff
|
||||
}
|
||||
}
|
||||
return nil, lastErr
|
||||
return nil, maxAttempts, lastErr
|
||||
}
|
||||
|
||||
// noteRetry records one model call that needed more than a first attempt, on
|
||||
// whichever span is current — the step for a plain run, the same step for every
|
||||
// round of a tool loop.
|
||||
//
|
||||
// The reason is carried when there is one: "it retried three times" and "it
|
||||
// retried three times because the gateway kept answering 429" are different
|
||||
// facts, and only the second tells an operator whether to look at us or at the
|
||||
// upstream.
|
||||
func noteRetry(ctx context.Context, model string, attempts int, failover bool, err error) {
|
||||
attrs := []attribute.KeyValue{
|
||||
attribute.String("gen_ai.request.model", model),
|
||||
attribute.Int("hanzo.agent.model_attempts", attempts),
|
||||
attribute.Bool("hanzo.agent.failover", failover),
|
||||
}
|
||||
if err != nil {
|
||||
attrs = append(attrs, attribute.String("hanzo.agent.retry_reason", err.Error()))
|
||||
}
|
||||
trace.SpanFromContext(ctx).AddEvent("model retry", trace.WithAttributes(attrs...))
|
||||
}
|
||||
|
||||
// sleepBackoff waits an exponential, equal-jittered delay before the next
|
||||
@@ -1096,6 +1265,58 @@ func (o agentOps) runs(ctx context.Context, in *runsQuery) (*runList, error) {
|
||||
return &runList{Runs: out}, nil
|
||||
}
|
||||
|
||||
// ListOrgRuns returns the org's agent runs across EVERY agent, newest first —
|
||||
// what ran here, for whom, on which model, how long it took, and why it failed.
|
||||
//
|
||||
// It is the feed the per-agent history could not be: an operator asking "what is
|
||||
// this tenant's agent plane doing" does not start out knowing an agent ref, and
|
||||
// answering by listing the agents and then paging each one's history is N+1 round
|
||||
// trips to reconstruct one ordering the database already has (RunsSince, ordered
|
||||
// by created_at over the org index).
|
||||
//
|
||||
// The org is the CALLER's, resolved from identity by tenantStore — never a
|
||||
// parameter. There is deliberately no org field on orgRunsQuery to forge: run
|
||||
// history is the tenant's own record, and the only tenant this can answer for is
|
||||
// the one asking.
|
||||
//
|
||||
// Example: {"limit": 20, "status": "error"}
|
||||
func (o agentOps) orgRuns(ctx context.Context, in *orgRunsQuery) (*runList, error) {
|
||||
s := o.s
|
||||
sto, org, err := tenantStore(ctx, &s.State)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
limit := in.Limit
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
// since=0 is "no lower bound" — the newest runs regardless of age, which is
|
||||
// what a feed means. A status filter reads more rows than it returns, so it
|
||||
// asks for a bounded multiple rather than scanning the whole history: the cap
|
||||
// keeps a tenant with a million clean runs from paying a full scan to find no
|
||||
// failures, and the page it returns is still exactly `limit` when they exist.
|
||||
scan := limit
|
||||
if strings.TrimSpace(in.Status) != "" {
|
||||
scan = limit * 20
|
||||
}
|
||||
runs, err := sto.RunsSince(ctx, org, 0, scan)
|
||||
if err != nil {
|
||||
return nil, zip.Errorf(http.StatusInternalServerError, "runs: %v", err)
|
||||
}
|
||||
want := strings.TrimSpace(in.Status)
|
||||
out := make([]agentRunView, 0, limit)
|
||||
for _, r := range runs {
|
||||
if want != "" && r.Status != want {
|
||||
continue
|
||||
}
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
out = append(out, toRunView(r))
|
||||
}
|
||||
return &runList{Runs: out}, nil
|
||||
}
|
||||
|
||||
// AgentMetrics serves the invocations-over-time histogram for the org's Agents
|
||||
// dashboard. Every point is a REAL count of recorded runs in that time bucket —
|
||||
// one series line per agent that ran in the window. The Resource Usage rollup is
|
||||
|
||||
@@ -156,7 +156,7 @@ func TestExecuteRunOK(t *testing.T) {
|
||||
ai := &fakeAI{content: "hi there"}
|
||||
a := mk("maxpower", "greeter")
|
||||
a.Instructions = "You are a greeter."
|
||||
r := executeRun(context.Background(), ai, "maxpower", a, "say hi", "")
|
||||
r := executeRun(context.Background(), ai, "maxpower", "maxpower/u1", a, "say hi", "", "run_test")
|
||||
|
||||
if r.Status != "ok" {
|
||||
t.Fatalf("want ok, got %q err=%q", r.Status, r.Error)
|
||||
@@ -177,7 +177,7 @@ func TestExecuteRunOK(t *testing.T) {
|
||||
|
||||
func TestExecuteRunRecordsError(t *testing.T) {
|
||||
ai := &fakeAI{err: errors.New("model unavailable")}
|
||||
r := executeRun(context.Background(), ai, "maxpower", mk("maxpower", "x"), "in", "")
|
||||
r := executeRun(context.Background(), ai, "maxpower", "maxpower/u1", mk("maxpower", "x"), "in", "", "run_test")
|
||||
if r.Status != "error" {
|
||||
t.Fatalf("want error status, got %q", r.Status)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
)
|
||||
|
||||
// An org that connected Slack and did nothing else has NO agent rows, and the
|
||||
// bridges ask for the conventional ref — so without a built-in default @hanzo
|
||||
// answers "the agent hit an error handling that" in every fresh workspace.
|
||||
func TestBuiltinResolvesTheConventionalRef(t *testing.T) {
|
||||
a, ok := builtinAgent("acme", "hanzo", "zen-70b")
|
||||
if !ok {
|
||||
t.Fatal("the conventional ref must resolve to the built-in default")
|
||||
}
|
||||
if a.Org != "acme" {
|
||||
t.Errorf("the default must be scoped to the asking org, got %q", a.Org)
|
||||
}
|
||||
if a.Model != "zen-70b" {
|
||||
t.Errorf("the default must use the deployment's model, got %q", a.Model)
|
||||
}
|
||||
// The default declares the fleet's whole door. The tool loop still decides what
|
||||
// is OFFERED per run, but an agent that declares nothing is offered nothing —
|
||||
// which is how the assistant came to report it could not reach a cloud that was
|
||||
// one socket away.
|
||||
if len(a.Tools) != 1 || a.Tools[0] != ToolsAll {
|
||||
t.Errorf("the default must declare the whole door (%q), got %v", ToolsAll, a.Tools)
|
||||
}
|
||||
if a.Instructions == "" {
|
||||
t.Error("the default must know what it is")
|
||||
}
|
||||
}
|
||||
|
||||
// Case is not a reason to fail: Slack sends whatever the user typed.
|
||||
func TestBuiltinIsCaseInsensitive(t *testing.T) {
|
||||
if _, ok := builtinAgent("acme", "Hanzo", "m"); !ok {
|
||||
t.Error("the ref must match case-insensitively")
|
||||
}
|
||||
}
|
||||
|
||||
// An UNKNOWN ref stays unknown. Silently substituting the chat agent would make
|
||||
// a typo in `code: repo` run the wrong thing and look like it worked.
|
||||
func TestUnknownRefIsStillAMiss(t *testing.T) {
|
||||
for _, ref := range []string{"deployer", "hanzo-coder", "", "hanz"} {
|
||||
if _, ok := builtinAgent("acme", ref, "m"); ok {
|
||||
t.Errorf("%q must not resolve to the default", ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No model configured is an honest miss, not a run that fails deeper in.
|
||||
func TestNoModelIsAMiss(t *testing.T) {
|
||||
if _, ok := builtinAgent("acme", "hanzo", " "); ok {
|
||||
t.Error("with no model configured the default must not resolve")
|
||||
}
|
||||
}
|
||||
|
||||
// The chat brain is cloud.ChatModel — one constant in the file that owns model
|
||||
// policy, not a literal here plus a BRIDGE_AGENT_MODEL knob beside it.
|
||||
//
|
||||
// The knob was never set in any deployment, and the literal was justified by the
|
||||
// claim that enso auto-routes per query, which it does not. This test is the guard
|
||||
// against a second place regrowing: there is exactly one line that names the tier
|
||||
// and it is not in this package.
|
||||
func TestBuiltinModelIsTheChatConstant(t *testing.T) {
|
||||
a, ok := builtinAgent("acme", "hanzo", cloud.ChatModel)
|
||||
if !ok {
|
||||
t.Fatal("the conventional ref must resolve to the built-in")
|
||||
}
|
||||
if a.Model != cloud.ChatModel {
|
||||
t.Errorf("the chat brain must be cloud.ChatModel (%q), got %q", cloud.ChatModel, a.Model)
|
||||
}
|
||||
if a.Model == cloud.FallbackModel {
|
||||
t.Error(`"best" is the degraded fallback tier, never the interactive default`)
|
||||
}
|
||||
// The menu must be able to express the default, or a person who opens App Home
|
||||
// sees a blank selector and their own model looks lost.
|
||||
if !knownChatModel(cloud.ChatModel) {
|
||||
t.Errorf("the App Home menu must offer the default tier %q", cloud.ChatModel)
|
||||
}
|
||||
}
|
||||
|
||||
// The App Home pin still wins over the default — a person's explicit choice is
|
||||
// the one thing that may override it, and only for the built-in.
|
||||
func TestAppHomePinBeatsTheDefault(t *testing.T) {
|
||||
for _, m := range []string{"enso", "enso-flash", "enso-ultra"} {
|
||||
if !knownChatModel(m) {
|
||||
t.Errorf("App Home offers %q, so the turn must accept it", m)
|
||||
}
|
||||
}
|
||||
if knownChatModel("gpt-4o") || knownChatModel("best") {
|
||||
t.Error("only the enso family may be pinned from a client")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
package agents
|
||||
|
||||
// door.go — where a run's tools come from once the fleet is more than one
|
||||
// process: the fleet's OWN agent door, asked over the internal socket.
|
||||
//
|
||||
// # Why this is not a new mechanism
|
||||
//
|
||||
// The fleet already aggregates. fleet.Door asks every composed app what it
|
||||
// serves right now, merges the answers, remembers which app listed which name,
|
||||
// and forwards a tools/call to that app (fleet/mcp.go). It is what serves
|
||||
// api.hanzo.ai/v1/mcp and what a Slack MCP client already talks to. Building a
|
||||
// tools_catalog/tools_call op pair on the tool plane would have been a SECOND
|
||||
// aggregation over the same children, with a second place for the curation rule
|
||||
// to be applied — or forgotten.
|
||||
//
|
||||
// So nothing here aggregates. The host publishes the door it already built on
|
||||
// the socket every child can already reach (cmd/cloud/wake.go), and an agent is
|
||||
// simply another MCP client of it. Same JSON-RPC, same union, same order, same
|
||||
// [fleet] denylist — which is enforced inside gather, where the routing table is
|
||||
// written, so a name the door will not project is not routable for anyone. An
|
||||
// agent therefore CANNOT see a surface an external client cannot; there is no
|
||||
// second surface to see.
|
||||
//
|
||||
// # The address, and what "no door" means
|
||||
//
|
||||
// plane.HostApp is the router's own socket — the one plane.Reach dials to wake a
|
||||
// cold app — and the door rides it at manifest.MCPPath. Reaching for it answers
|
||||
// one of exactly three things, which is the rule plane/ask.go already states:
|
||||
//
|
||||
// listening ask it; this is production
|
||||
// no listener THIS PROCESS IS THE FLEET — a single-app binary, a test, a dev
|
||||
// box. Fall back to [registryTools], which is the real answer
|
||||
// there and empty everywhere else.
|
||||
// unusable an outage. Zero tools, recorded on the run's span, never
|
||||
// laundered into "this fleet has no tools".
|
||||
//
|
||||
// # Identity is stated by the RUN, and the model never touches it
|
||||
//
|
||||
// The org and actor a dispatch carries are the run's own — the pair its fee is
|
||||
// billed under — passed as arguments from executeRun and written onto the
|
||||
// request as zip's identity headers here. The model contributes a tool NAME and
|
||||
// an ARGUMENTS object and nothing else, so there is no path by which it can name
|
||||
// a tenant. The inbound caller's headers are deliberately NOT forwarded: a
|
||||
// scheduled run has no inbound request at all, and a nested one may be running
|
||||
// for a different principal than whoever made the outermost HTTP call.
|
||||
//
|
||||
// The socket carries no credential and needs none: it is 0700 in the fleet's own
|
||||
// run directory and the kernel attests the peer, which is the same trust
|
||||
// zip.WithCaller rides on for every other internal call.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/hanzoai/cloud/fleet"
|
||||
"github.com/hanzoai/cloud/manifest"
|
||||
"github.com/hanzoai/cloud/plane"
|
||||
"github.com/hanzoai/cloud/types"
|
||||
"github.com/valyala/fasthttp"
|
||||
zaphttp "github.com/zap-proto/http"
|
||||
"github.com/zap-proto/zip"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// doorTools is the tool plane read from the FLEET's composed agent door.
|
||||
type doorTools struct{}
|
||||
|
||||
// errNoDoor reports that this process is not part of a fleet: nothing is
|
||||
// listening on the router's socket, so there is no composed door to ask.
|
||||
//
|
||||
// It is the ONE error a caller may read as "fall back", exactly as plane.ErrNoPeer
|
||||
// is on the peer plane. Every other failure is an outage and is reported as one —
|
||||
// a door that is present and broken must never read as a fleet with no tools.
|
||||
var errNoDoor = errors.New("agents: no fleet door on this host")
|
||||
|
||||
// catalog resolves the agent's declared names against the fleet's own surface.
|
||||
//
|
||||
// It asks the door WHAT IS OFFERED and then, for the handful of names this agent
|
||||
// declared, what each one takes. Those are two questions because the door's
|
||||
// tools/list answers only the first: it publishes one tool per subsystem, whose
|
||||
// `op` enum carries the operation names and no schemas, since the flat list of
|
||||
// this fleet's operations was 977 KB that no model can hold and every client
|
||||
// truncates (fleet/grouped.go). fleet.Describe answers the second, one operation
|
||||
// at a time, out of the same gathered set — so a declared name the door does not
|
||||
// offer is simply absent, which is the same rule registryTools follows: offering
|
||||
// a tool that would be refused at dispatch teaches the model a lie.
|
||||
func (doorTools) catalog(ctx context.Context, org, actor string, want []string) []types.ToolDef {
|
||||
if org == "" || len(want) == 0 {
|
||||
return nil
|
||||
}
|
||||
// ToolsAll is the ONE way to say "whatever the fleet serves", and it has to be
|
||||
// said rather than implied.
|
||||
//
|
||||
// An agent that declares nothing gets nothing — that default is correct and
|
||||
// stays, because a user-defined agent's tool list is its authority and an
|
||||
// empty one means it asked for none. But the DEFAULT ASSISTANT cannot enumerate
|
||||
// its tools: the door's surface is discovered at runtime (88 grouped tools
|
||||
// today, and the whole point of grouping was that the set changes without a
|
||||
// code edit), so any list written here would be stale the next time a
|
||||
// subsystem ships.
|
||||
//
|
||||
// Not stating it cost a full turn of user-visible wrongness: the assistant was
|
||||
// told in its instructions that it had tools and how to call them, then handed
|
||||
// an empty offer by this function, so it correctly reported that it could not
|
||||
// reach the cloud — while the door was serving 88 tools one socket away. The
|
||||
// two halves have to agree, and this is the half that was missing.
|
||||
all := false
|
||||
for _, n := range want {
|
||||
if strings.TrimSpace(n) == ToolsAll {
|
||||
all = true
|
||||
break
|
||||
}
|
||||
}
|
||||
wanted := make(map[string]bool, len(want))
|
||||
for _, n := range want {
|
||||
if n = strings.TrimSpace(n); n != "" && n != ToolsAll {
|
||||
wanted[n] = true
|
||||
}
|
||||
}
|
||||
if len(wanted) == 0 && !all {
|
||||
return nil
|
||||
}
|
||||
res, err := askDoor(ctx, org, actor, []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`))
|
||||
if errors.Is(err, errNoDoor) {
|
||||
return registryTools{}.catalog(ctx, org, actor, want)
|
||||
}
|
||||
if err != nil {
|
||||
// An outage, and it is SAID so. The run continues with no tools — killing
|
||||
// a turn the org has paid for because a sibling is down is the worse
|
||||
// answer — but "declared 3, offered 0" is already a number on the step
|
||||
// span, and this is the reason beside it.
|
||||
trace.SpanFromContext(ctx).RecordError(err)
|
||||
return nil
|
||||
}
|
||||
var listed struct {
|
||||
Tools []struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema json.RawMessage `json:"inputSchema"`
|
||||
} `json:"tools"`
|
||||
// Meta is the door's own account of why its list may be SHORT: the
|
||||
// subsystems it could not ask, and how many names policy withheld. The door
|
||||
// went to the trouble of never shortening quietly, so throwing it away here
|
||||
// would put the silence back one layer down.
|
||||
Meta json.RawMessage `json:"_meta"`
|
||||
}
|
||||
if err := json.Unmarshal(res, &listed); err != nil {
|
||||
trace.SpanFromContext(ctx).RecordError(fmt.Errorf("agents: the fleet door's tools/list is not a tool list: %w", err))
|
||||
return nil
|
||||
}
|
||||
offered := map[string]bool{}
|
||||
for _, t := range listed.Tools {
|
||||
for _, op := range opsOf(t.InputSchema) {
|
||||
offered[op] = true
|
||||
}
|
||||
}
|
||||
// ToolsAll offers the door's tools AS THE DOOR GROUPS THEM — one per subsystem,
|
||||
// named for it, carrying an `op` enum, plus [fleet.Describe] — and not the ops
|
||||
// flattened back out.
|
||||
//
|
||||
// The grouping is the whole reason the surface is affordable: 1,189 flat tools
|
||||
// were 977 KB (~244k tokens) merely to LIST, and the same operations grouped
|
||||
// are 88 tools in 63 KB. Expanding them here would hand back every byte the
|
||||
// door just saved and blow the context before the question is read.
|
||||
//
|
||||
// It is also what the assistant's instructions describe — pick a subsystem,
|
||||
// choose an op from its enum, call [fleet.Describe] for a shape you do not know.
|
||||
// The prose and the offer have to be the same surface or the model is being
|
||||
// taught a protocol it cannot practise.
|
||||
if all {
|
||||
out := make([]types.ToolDef, 0, len(listed.Tools))
|
||||
for _, t := range listed.Tools {
|
||||
out = append(out, types.ToolDef{
|
||||
Name: t.Name, Description: t.Description, Schema: t.InputSchema,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// In the agent's own declared order, which is the order the model meets them
|
||||
// in, and once each however often it was declared.
|
||||
out := make([]types.ToolDef, 0, len(wanted))
|
||||
done := make(map[string]bool, len(wanted))
|
||||
for _, n := range want {
|
||||
n = strings.TrimSpace(n)
|
||||
if !wanted[n] || done[n] || !offered[n] {
|
||||
continue
|
||||
}
|
||||
done[n] = true
|
||||
def, err := describe(ctx, org, actor, n)
|
||||
if err != nil {
|
||||
// It was offered a moment ago, so this is an outage between the two
|
||||
// asks and not a refusal. Same policy as above: the turn goes on with
|
||||
// one fewer tool, and the reason is on the span.
|
||||
trace.SpanFromContext(ctx).RecordError(err)
|
||||
continue
|
||||
}
|
||||
out = append(out, def)
|
||||
}
|
||||
// A declared name that resolved to nothing has two very different causes — a
|
||||
// subsystem that is DOWN and a tool the fleet REFUSES to project — and the
|
||||
// door already distinguishes them. Carrying its answer onto the span is what
|
||||
// makes "declared 3, offered 1" diagnosable instead of a shrug.
|
||||
if len(out) < len(wanted) && len(listed.Meta) > 0 {
|
||||
trace.SpanFromContext(ctx).SetAttributes(
|
||||
attribute.String("hanzo.agent.tools_meta", clip(string(listed.Meta), maxDoorMeta)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// opsOf reads the operation names out of one subsystem tool's schema — its `op`
|
||||
// enum, which is where the door carries them.
|
||||
func opsOf(schema json.RawMessage) []string {
|
||||
var s struct {
|
||||
Properties struct {
|
||||
Op struct {
|
||||
Enum []string `json:"enum"`
|
||||
} `json:"op"`
|
||||
} `json:"properties"`
|
||||
}
|
||||
if json.Unmarshal(schema, &s) != nil {
|
||||
return nil
|
||||
}
|
||||
return s.Properties.Op.Enum
|
||||
}
|
||||
|
||||
// describe fetches ONE operation's descriptor through the door's own
|
||||
// fleet.Describe, and reads the owning subsystem's bytes back out of it.
|
||||
//
|
||||
// What the seam guarantees is that the model is offered exactly what it will
|
||||
// CALL, and op is that name: it came out of a subsystem tool's `op` enum a
|
||||
// moment ago, and a tools/call naming it reaches the operation's own handler.
|
||||
// So the offer is named op, with the owner's own description and schema behind
|
||||
// it.
|
||||
//
|
||||
// The descriptor's own `name` is NOT compared to op, and that is a change. The
|
||||
// door publishes an operation as a verb on an object — `deploy_project` for
|
||||
// `post_v1_projects_by_slug_deploy` (fleet/verbs.go) — while the descriptor it
|
||||
// hands back is the owning subsystem's, carried verbatim, so it still says the
|
||||
// id. Requiring the two to match would reject 1,730 of the fleet's 2,229
|
||||
// operations for being correctly named.
|
||||
func describe(ctx context.Context, org, actor, op string) (types.ToolDef, error) {
|
||||
args, err := json.Marshal(map[string]string{"op": op})
|
||||
if err != nil {
|
||||
return types.ToolDef{}, err
|
||||
}
|
||||
body, err := toolCallBody(fleet.Describe, string(args))
|
||||
if err != nil {
|
||||
return types.ToolDef{}, err
|
||||
}
|
||||
res, err := askDoor(ctx, org, actor, body)
|
||||
if err != nil {
|
||||
return types.ToolDef{}, err
|
||||
}
|
||||
text, err := toolResult(res)
|
||||
if err != nil {
|
||||
return types.ToolDef{}, err
|
||||
}
|
||||
var d struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema json.RawMessage `json:"inputSchema"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(text), &d); err != nil || d.Name == "" || len(d.InputSchema) == 0 {
|
||||
return types.ToolDef{}, fmt.Errorf("agents: %s did not answer %s's own descriptor", fleet.Describe, op)
|
||||
}
|
||||
return types.ToolDef{Name: op, Description: d.Description, Schema: d.InputSchema}, nil
|
||||
}
|
||||
|
||||
// maxDoorMeta bounds what one span attribute may carry: `_meta` names every
|
||||
// subsystem that did not answer, and a fleet-wide outage would otherwise put a
|
||||
// hundred rows on every run's trace.
|
||||
const maxDoorMeta = 1024
|
||||
|
||||
func clip(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
|
||||
// call runs one tool through the door's own dispatch: the door names the app
|
||||
// that listed it and forwards this message verbatim to that app's registry, so
|
||||
// the host can only ever ROUTE a call and never invoke something the owner did
|
||||
// not declare.
|
||||
func (doorTools) call(ctx context.Context, org, actor, name, args string) (string, error) {
|
||||
body, err := toolCallBody(name, args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
res, err := askDoor(ctx, org, actor, body)
|
||||
if errors.Is(err, errNoDoor) {
|
||||
return registryTools{}.call(ctx, org, actor, name, args)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return toolResult(res)
|
||||
}
|
||||
|
||||
// toolCallBody builds one MCP tools/call, with the model's arguments carried
|
||||
// VERBATIM.
|
||||
//
|
||||
// The arguments are validated as a JSON OBJECT and then embedded unparsed: they
|
||||
// belong to the tool that declared the schema, which is the only thing that
|
||||
// knows how to read them, and re-encoding them here would be this process having
|
||||
// an opinion about a shape it does not own. A model that emits something else is
|
||||
// told so — the same sentence registryTools gives it — and the turn goes on.
|
||||
func toolCallBody(name, args string) ([]byte, error) {
|
||||
raw := json.RawMessage("{}")
|
||||
if s := strings.TrimSpace(args); s != "" && s != "null" {
|
||||
var probe map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(s), &probe); err != nil {
|
||||
return nil, fmt.Errorf("arguments are not a JSON object: %w", err)
|
||||
}
|
||||
raw = json.RawMessage(s)
|
||||
}
|
||||
return json.Marshal(struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params struct {
|
||||
Name string `json:"name"`
|
||||
Arguments json.RawMessage `json:"arguments"`
|
||||
} `json:"params"`
|
||||
}{
|
||||
JSONRPC: "2.0", ID: 1, Method: "tools/call",
|
||||
Params: struct {
|
||||
Name string `json:"name"`
|
||||
Arguments json.RawMessage `json:"arguments"`
|
||||
}{Name: name, Arguments: raw},
|
||||
})
|
||||
}
|
||||
|
||||
// toolResult reads one MCP tool result into the text the model is handed.
|
||||
//
|
||||
// isError is a FAILURE and comes back as one, so dispatchOne renders it as a
|
||||
// tool result the model can react to rather than as a success it would believe.
|
||||
// That is the same distinction the door itself draws when a hop fails.
|
||||
func toolResult(res json.RawMessage) (string, error) {
|
||||
var out struct {
|
||||
Content []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
IsError bool `json:"isError"`
|
||||
}
|
||||
if err := json.Unmarshal(res, &out); err != nil {
|
||||
return "", fmt.Errorf("agents: the fleet door answered a tool result that will not decode: %w", err)
|
||||
}
|
||||
parts := make([]string, 0, len(out.Content))
|
||||
for _, c := range out.Content {
|
||||
if c.Text != "" {
|
||||
parts = append(parts, c.Text)
|
||||
}
|
||||
}
|
||||
text := strings.Join(parts, "\n")
|
||||
if out.IsError {
|
||||
// The tool's OWN sentence, so the model reads what actually went wrong.
|
||||
if text == "" {
|
||||
text = "the tool reported a failure with no message"
|
||||
}
|
||||
return "", errors.New(truncateToolResult(text))
|
||||
}
|
||||
return truncateToolResult(text), nil
|
||||
}
|
||||
|
||||
// askDoor puts one JSON-RPC message to the fleet's door as (org, actor) and
|
||||
// returns the `result` member.
|
||||
//
|
||||
// A JSON-RPC ERROR is an error here, deliberately: a tool the door will not
|
||||
// route answers -32602, and folding that into an empty result would make "this
|
||||
// tool is not yours to call" indistinguishable from "it ran and said nothing".
|
||||
func askDoor(ctx context.Context, org, actor string, body []byte) (json.RawMessage, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addr, err := doorAddr()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req := fasthttp.AcquireRequest()
|
||||
defer fasthttp.ReleaseRequest(req)
|
||||
resp := fasthttp.AcquireResponse()
|
||||
defer fasthttp.ReleaseResponse(resp)
|
||||
|
||||
req.Header.SetMethod(fasthttp.MethodPost)
|
||||
req.Header.SetContentType("application/json")
|
||||
req.SetHost(plane.HostApp)
|
||||
req.URI().SetPath(manifest.MCPPath)
|
||||
// The RUN's identity, in zip's own spelling, and nothing else. The door
|
||||
// copies these onto every hop it makes, so a subsystem whose tools depend on
|
||||
// the tenant answers for the org this run is billed to. A blank subject is a
|
||||
// run with no person behind it (a schedule, a service token); the org is the
|
||||
// authority either way and inventing a user would attribute the call to
|
||||
// nobody.
|
||||
req.Header.Set(zip.HeaderOrg, org)
|
||||
if sub := actorSub(org, actor); sub != "" {
|
||||
req.Header.Set(zip.HeaderUser, sub)
|
||||
}
|
||||
req.SetBody(body)
|
||||
|
||||
if err := doorClient(addr).Do(req, resp); err != nil {
|
||||
return nil, fmt.Errorf("agents: the fleet door at %s did not answer: %w", addr, err)
|
||||
}
|
||||
if code := resp.StatusCode(); code < 200 || code > 299 {
|
||||
return nil, fmt.Errorf("agents: the fleet door answered %d", code)
|
||||
}
|
||||
var env struct {
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Body(), &env); err != nil {
|
||||
return nil, fmt.Errorf("agents: the fleet door answered something that is not JSON-RPC: %w", err)
|
||||
}
|
||||
if env.Error != nil {
|
||||
return nil, errors.New(env.Error.Message)
|
||||
}
|
||||
return append(json.RawMessage(nil), env.Result...), nil
|
||||
}
|
||||
|
||||
// doorAddr resolves the fleet door's socket, or says which of the two failures
|
||||
// it is. See [errNoDoor].
|
||||
//
|
||||
// It probes by CONNECTING, because the file does not answer the question: a
|
||||
// socket path outlives the process that bound it wherever the run directory is a
|
||||
// volume. plane.Listening is the one implementation of that rule.
|
||||
func doorAddr() (string, error) {
|
||||
plane.Bind()
|
||||
path := zip.SocketPath(plane.HostApp)
|
||||
up, err := plane.Listening(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("agents: the fleet door's socket is unusable: %w", err)
|
||||
}
|
||||
if !up {
|
||||
return "", fmt.Errorf("%w (%s)", errNoDoor, path)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// doorClients is one pooled transport per ADDRESS, for the reason fleet keeps
|
||||
// one: a transport holds a connection pool, so dialing per ask turns every tool
|
||||
// call into a fresh connect. Keyed by address rather than kept in a single var
|
||||
// because a test points the run directory somewhere else.
|
||||
var doorClients sync.Map // addr -> *zaphttp.Transport
|
||||
|
||||
func doorClient(addr string) *zaphttp.Transport {
|
||||
if c, ok := doorClients.Load(addr); ok {
|
||||
return c.(*zaphttp.Transport)
|
||||
}
|
||||
t := zaphttp.Dial("unix", addr)
|
||||
// The whole run's ceiling, not the library's 30s. A tools/list is a fan-out
|
||||
// across every composed app and the first ask of a cold one pays that app's
|
||||
// startup, so a transport that gave up sooner than the run does would report
|
||||
// an outage for a fleet that was merely waking up.
|
||||
t.SetReadTimeout(toolRunBudget)
|
||||
c, _ := doorClients.LoadOrStore(addr, t)
|
||||
return c.(*zaphttp.Transport)
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package agents
|
||||
|
||||
// door_test.go — the tool plane, over the wire it actually uses.
|
||||
//
|
||||
// Nothing is stubbed at the seam under test. Every test here brings up real
|
||||
// subsystem apps on real ZAP sockets, composes the REAL fleet.Door over them,
|
||||
// publishes it on the router's socket exactly as cmd/cloud/wake.go does, and
|
||||
// then drives doorTools — so what is asserted is what a deployed agent gets.
|
||||
//
|
||||
// A fake door would have proved nothing: the two properties worth having are
|
||||
// that the agent inherits the door's CURATION and that the run's org reaches the
|
||||
// owning subsystem, and both live in code a stub would have replaced.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/apps/principal"
|
||||
"github.com/hanzoai/cloud/fleet"
|
||||
"github.com/hanzoai/cloud/manifest"
|
||||
"github.com/hanzoai/cloud/plane"
|
||||
"github.com/hanzoai/cloud/types"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
type echoIn struct {
|
||||
Say string `json:"say"`
|
||||
}
|
||||
|
||||
type echoOut struct {
|
||||
App string `json:"app"`
|
||||
Say string `json:"say"`
|
||||
Org string `json:"org"`
|
||||
}
|
||||
|
||||
// subsystem starts one app serving the named ops on its own socket, the shape
|
||||
// cloud.Serve gives every plugin binary. Each op echoes its input AND the org it
|
||||
// was reached as, so a test can prove the run's tenant travelled the whole way.
|
||||
func subsystem(t *testing.T, name string, ops ...string) string {
|
||||
t.Helper()
|
||||
sock := shortDir(t) + "/" + name + ".sock"
|
||||
app := zip.New(zip.Config{AppName: name, DisableStartupMessage: true})
|
||||
for _, id := range ops {
|
||||
zip.Post(app, "/v1/"+name+"/"+id, func(ctx context.Context, in *echoIn) (*echoOut, error) {
|
||||
return &echoOut{App: name, Say: in.Say, Org: zip.CallerOf(ctx).Org}, nil
|
||||
}, zip.WithOperationID(id), zip.WithSummary("what "+name+" does at "+id))
|
||||
}
|
||||
go func() { _ = app.Listen(sock) }()
|
||||
t.Cleanup(func() { _ = app.Shutdown() })
|
||||
accepts(t, sock)
|
||||
return sock
|
||||
}
|
||||
|
||||
// fleetDoor composes the real door over those apps and puts it where a child
|
||||
// looks for it — plane.HostApp's socket, at manifest.MCPPath. This is
|
||||
// serveWake's two lines, not a reimplementation of them.
|
||||
//
|
||||
// at names each app's EDGE door and inside names the PLANE door of the ones that
|
||||
// have one, which is the pair cmd/cloud registers (locate / inside). An app with
|
||||
// no entry in inside is reached at its edge door — production's remotely-mounted
|
||||
// case, where there is no local plane socket to reach.
|
||||
func fleetDoor(t *testing.T, at map[string]string, inside map[string]string) {
|
||||
t.Helper()
|
||||
run := shortDir(t)
|
||||
t.Setenv("ZIP_RUNTIME_DIR", run)
|
||||
plane.Unbind()
|
||||
t.Cleanup(plane.Unbind)
|
||||
|
||||
host := zip.New(zip.Config{AppName: "cloud", DisableStartupMessage: true, MCP: zip.MCPConfig{Disabled: true}})
|
||||
apps := make([]string, 0, len(at))
|
||||
for name := range at {
|
||||
apps = append(apps, name)
|
||||
}
|
||||
edge := func(app string) (addr, path string, err error) {
|
||||
sock, ok := at[app]
|
||||
if !ok {
|
||||
return "", "", &net.AddrError{Err: "no instance running", Addr: app}
|
||||
}
|
||||
return sock, manifest.FrameworkMCPPath, nil
|
||||
}
|
||||
d := fleet.Mount(host, manifest.MCPPath, apps, edge)
|
||||
|
||||
door := zip.New(zip.Config{AppName: "plane", DisableStartupMessage: true})
|
||||
d.Serve(door, manifest.MCPPath, func(app string) (addr, path string, err error) {
|
||||
if sock, ok := inside[app]; ok {
|
||||
return sock, manifest.MCPPath, nil
|
||||
}
|
||||
return edge(app)
|
||||
})
|
||||
path := zip.SocketPath(plane.HostApp)
|
||||
go func() { _ = door.Listen(path) }()
|
||||
t.Cleanup(func() { _ = door.Shutdown() })
|
||||
accepts(t, path)
|
||||
}
|
||||
|
||||
// noFleetDoor points the run directory at an empty one: nothing is listening, so
|
||||
// this process is the whole fleet.
|
||||
func noFleetDoor(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("ZIP_RUNTIME_DIR", shortDir(t))
|
||||
plane.Unbind()
|
||||
t.Cleanup(plane.Unbind)
|
||||
}
|
||||
|
||||
// shortDir is a temp directory with a SHORT name, because a unix socket path is
|
||||
// capped near 104 bytes and t.TempDir() spends most of that on the test's name.
|
||||
func shortDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir, err := os.MkdirTemp("", "ag")
|
||||
if err != nil {
|
||||
t.Fatalf("temp dir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
||||
return dir
|
||||
}
|
||||
|
||||
func accepts(t *testing.T, sock string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if c, err := net.Dial("unix", sock); err == nil {
|
||||
_ = c.Close()
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("%s never accepted", sock)
|
||||
}
|
||||
|
||||
// TestAgentResolvesItsToolsFromTheFleetDoor is the whole claim: a declared name
|
||||
// resolves to the OWNING subsystem's own descriptor, across a process boundary,
|
||||
// with nothing in this binary that knows what that subsystem serves.
|
||||
func TestAgentResolvesItsToolsFromTheFleetDoor(t *testing.T) {
|
||||
fleetDoor(t, map[string]string{
|
||||
"alpha": subsystem(t, "alpha", "alpha_echo", "alpha_other"),
|
||||
"beta": subsystem(t, "beta", "beta_echo"),
|
||||
}, nil)
|
||||
|
||||
door := doorTools{}
|
||||
defs := door.catalog(context.Background(), "acme", "acme/u-1", []string{"alpha_echo", "beta_echo"})
|
||||
if len(defs) != 2 {
|
||||
t.Fatalf("the agent declared two tools the fleet serves and was offered %d: %+v", len(defs), defs)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, d := range defs {
|
||||
got[d.Name] = true
|
||||
if d.Description == "" {
|
||||
t.Errorf("%s came back with no description, so the model is offered a tool it cannot choose", d.Name)
|
||||
}
|
||||
if len(d.Schema) == 0 {
|
||||
t.Errorf("%s came back with no schema, so the model cannot fill its arguments", d.Name)
|
||||
}
|
||||
}
|
||||
if !got["alpha_echo"] || !got["beta_echo"] {
|
||||
t.Fatalf("offered %v, want alpha_echo and beta_echo", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A declared name NOTHING in the fleet serves is absent, never offered. Offering
|
||||
// a tool that would be refused at dispatch teaches the model a lie.
|
||||
func TestUnservedNamesAreNotOffered(t *testing.T) {
|
||||
fleetDoor(t, map[string]string{"alpha": subsystem(t, "alpha", "alpha_echo")}, nil)
|
||||
|
||||
door := doorTools{}
|
||||
defs := door.catalog(context.Background(), "acme", "acme/u-1",
|
||||
[]string{"alpha_echo", "slack_post_message"})
|
||||
if len(defs) != 1 || defs[0].Name != "alpha_echo" {
|
||||
t.Fatalf("offered %+v, want alpha_echo alone", defs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheAgentInheritsTheDoorsDenylist is the security bar, as a test.
|
||||
//
|
||||
// The curation rule lives in fleet/surface.go and is applied inside gather,
|
||||
// where the routing table is written. An agent reaching the door through any
|
||||
// other path would have seen a surface external MCP clients cannot — so this
|
||||
// asserts BOTH halves: the credential-minting op is not offered, and naming it
|
||||
// anyway does not run it.
|
||||
func TestTheAgentInheritsTheDoorsDenylist(t *testing.T) {
|
||||
fleetDoor(t, map[string]string{
|
||||
"iam": subsystem(t, "iam", "CreateServiceAccountKey", "GetRole"),
|
||||
}, nil)
|
||||
door := doorTools{}
|
||||
ctx := context.Background()
|
||||
|
||||
defs := door.catalog(ctx, "acme", "acme/u-1", []string{"CreateServiceAccountKey", "GetRole"})
|
||||
if len(defs) != 1 || defs[0].Name != "GetRole" {
|
||||
t.Fatalf("the agent was offered %+v; the door projects GetRole and refuses CreateServiceAccountKey", defs)
|
||||
}
|
||||
if _, err := door.call(ctx, "acme", "acme/u-1", "CreateServiceAccountKey", `{"say":"hi"}`); err == nil {
|
||||
t.Fatal("a refused tool RAN for an agent that named it directly — the denylist is a suggestion, not a boundary")
|
||||
}
|
||||
}
|
||||
|
||||
// TestADispatchCarriesTheRunsOrg: the tenant reaches the subsystem that owns the
|
||||
// tool, and it comes from the run rather than from anything the model emitted.
|
||||
func TestADispatchCarriesTheRunsOrg(t *testing.T) {
|
||||
fleetDoor(t, map[string]string{"alpha": subsystem(t, "alpha", "alpha_echo")}, nil)
|
||||
|
||||
door := doorTools{}
|
||||
out, err := door.call(context.Background(), "acme", "acme/u-1", "alpha_echo", `{"say":"pong"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("call: %v", err)
|
||||
}
|
||||
for _, want := range []string{`"app":"alpha"`, `"say":"pong"`, `"org":"acme"`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("alpha answered %q, which does not carry %s", out, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// guarded is one subsystem in its PRODUCTION shape, which is the shape the
|
||||
// fixture above deliberately is not: the identity boundary in front of it, and its
|
||||
// tool resolving the tenant the way every org-scoped op does — principal.OrgFrom,
|
||||
// never a header. It returns its EDGE socket and its PLANE socket.
|
||||
//
|
||||
// The bare fixture answers zip.CallerOf directly, so it reports the org for any
|
||||
// caller that names one. That is why every test above passed while every tool the
|
||||
// @hanzo Slack agent called refused it: production has a boundary, the boundary
|
||||
// deletes an authority header no credential backs, and nothing in this file had
|
||||
// one.
|
||||
func guarded(t *testing.T, name, op string) (edge, plane string) {
|
||||
t.Helper()
|
||||
cloud.ResetPlane()
|
||||
t.Cleanup(cloud.ResetPlane)
|
||||
|
||||
app := zip.New(zip.Config{AppName: name, DisableStartupMessage: true})
|
||||
cloud.Identify(app, &cloud.Config{})
|
||||
zip.Post(app, "/v1/"+name+"/"+op, func(ctx context.Context, in *echoIn) (*echoOut, error) {
|
||||
org, ok := principal.OrgFrom(ctx)
|
||||
if !ok {
|
||||
return nil, zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
return &echoOut{App: name, Say: in.Say, Org: org}, nil
|
||||
}, zip.WithOperationID(op), zip.WithSummary("what "+name+" does at "+op))
|
||||
cloud.Door(app)
|
||||
|
||||
dir := shortDir(t)
|
||||
edge, plane = dir+"/"+name+".sock", dir+"/"+name+"-plane.sock"
|
||||
go func() { _ = app.Listen(edge) }()
|
||||
go func() { _ = cloud.Plane().Listen(plane) }()
|
||||
t.Cleanup(func() { _ = app.Shutdown() })
|
||||
accepts(t, edge)
|
||||
accepts(t, plane)
|
||||
return edge, plane
|
||||
}
|
||||
|
||||
// TestATenantedToolAnswersTheRun is the @hanzo Slack failure, as a test: a run
|
||||
// with a principal the fleet resolved server-side calls a tool that scopes by
|
||||
// tenant, and gets an answer instead of `X-Org-Id required`.
|
||||
//
|
||||
// It is the same call TestADispatchCarriesTheRunsOrg makes, against a subsystem
|
||||
// that has the boundary production has. Point the door's internal reach at the
|
||||
// EDGE door instead and it fails exactly the way the deployed fleet did.
|
||||
func TestATenantedToolAnswersTheRun(t *testing.T) {
|
||||
edge, plane := guarded(t, "alpha", "alpha_tenant")
|
||||
fleetDoor(t, map[string]string{"alpha": edge}, map[string]string{"alpha": plane})
|
||||
|
||||
out, err := doorTools{}.call(context.Background(), "acme", "acme/u-1", "alpha_tenant", `{"say":"pong"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("a tool that scopes by tenant refused the run it belongs to: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, `"org":"acme"`) {
|
||||
t.Fatalf("alpha answered %q, which does not carry the run's tenant", out)
|
||||
}
|
||||
}
|
||||
|
||||
// A tool the door cannot route is an ERROR the model reads, never a silent empty
|
||||
// result and never a killed turn.
|
||||
func TestAnUnroutableToolIsAnErrorNotAnEmptyResult(t *testing.T) {
|
||||
fleetDoor(t, map[string]string{"alpha": subsystem(t, "alpha", "alpha_echo")}, nil)
|
||||
|
||||
door := doorTools{}
|
||||
out, err := door.call(context.Background(), "acme", "acme/u-1", "nobody_serves_this", `{}`)
|
||||
if err == nil {
|
||||
t.Fatalf("an unroutable tool answered %q with no error", out)
|
||||
}
|
||||
if got := dispatchOne(context.Background(), "acme", "acme/u-1",
|
||||
types.ToolCall{ID: "c1", Name: "nobody_serves_this", Arguments: `{}`}, "run_test", 0); !strings.Contains(got, "error:") {
|
||||
t.Fatalf("the model was handed %q for a tool that cannot run", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Arguments that are not a JSON object are refused HERE, before the wire, and
|
||||
// the model is told so — the same sentence the co-resident plane gives it.
|
||||
func TestMalformedArgumentsNeverReachTheDoor(t *testing.T) {
|
||||
fleetDoor(t, map[string]string{"alpha": subsystem(t, "alpha", "alpha_echo")}, nil)
|
||||
|
||||
door := doorTools{}
|
||||
if _, err := door.call(context.Background(), "acme", "acme/u-1", "alpha_echo", `["not","an","object"]`); err == nil {
|
||||
t.Fatal("a JSON array was accepted as a tool's arguments")
|
||||
}
|
||||
}
|
||||
|
||||
// With no router on this host, this process IS the fleet: doorTools falls back
|
||||
// to the in-process registry rather than reporting an outage — and a run in a
|
||||
// single-app binary keeps working instead of crashing.
|
||||
func TestNoFleetDoorFallsBackToThisProcesssRegistry(t *testing.T) {
|
||||
noFleetDoor(t)
|
||||
door := doorTools{}
|
||||
ctx := context.Background()
|
||||
|
||||
if defs := door.catalog(ctx, "acme", "acme/u-1", []string{"alpha_echo"}); len(defs) != 0 {
|
||||
t.Fatalf("this process serves no such tool, so nothing may be offered: %+v", defs)
|
||||
}
|
||||
if _, err := door.call(ctx, "acme", "acme/u-1", "alpha_echo", `{}`); err == nil {
|
||||
t.Fatal("a tool nothing in this process registers reported success")
|
||||
}
|
||||
}
|
||||
@@ -148,7 +148,7 @@ func (s *Store) copyOrgTo(ctx context.Context, org string, dst *Store) error {
|
||||
`INSERT OR IGNORE INTO agents (` + agentCols + `) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`},
|
||||
{"runs",
|
||||
`SELECT ` + runCols + ` FROM agent_runs WHERE org=?`,
|
||||
`INSERT OR IGNORE INTO agent_runs (` + runCols + `) VALUES (?,?,?,?,?,?,?,?,?,?)`},
|
||||
`INSERT OR IGNORE INTO agent_runs (` + runCols + `) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`},
|
||||
{"sessions",
|
||||
`SELECT ` + sessionCols + ` FROM agent_sessions WHERE org=?`,
|
||||
`INSERT OR IGNORE INTO agent_sessions (` + sessionCols + `) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`},
|
||||
|
||||
@@ -0,0 +1,681 @@
|
||||
package agents
|
||||
|
||||
// observability_test.go — the proof that ONE agent turn is readable afterwards.
|
||||
//
|
||||
// These tests run a real turn (real HTTP handler, real tool loop, real OpenAI-wire
|
||||
// client against a stand-in gateway) with the real span pipeline installed, and
|
||||
// assert on the spans that actually reached a sink plus the record the console
|
||||
// reads. They exist because every fact below was, at one point, emitted by code
|
||||
// that looked correct and observable by nobody: the run's own id was minted AFTER
|
||||
// the work finished, so no span or debit the run produced could carry it.
|
||||
//
|
||||
// What is asserted is the operator's question, not the implementation's shape:
|
||||
// what ran, for which org and which user, on which model, how many tokens, which
|
||||
// tools it called, how long it took, and why it failed.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/hanzoai/cloud/clients"
|
||||
"github.com/hanzoai/cloud/types"
|
||||
"go.opentelemetry.io/otel"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
)
|
||||
|
||||
// spanSink captures the batches the tracer provider exports, which is the only
|
||||
// honest place to assert from: a span that is created but never exported is
|
||||
// exactly the failure mode this file exists to catch.
|
||||
type spanSink struct {
|
||||
mu sync.Mutex
|
||||
spans []sdktrace.ReadOnlySpan
|
||||
}
|
||||
|
||||
func (s *spanSink) export(_ context.Context, batch []sdktrace.ReadOnlySpan) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.spans = append(s.spans, batch...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// find returns the first captured span whose name matches, and whether there was one.
|
||||
func (s *spanSink) find(name string) (sdktrace.ReadOnlySpan, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, sp := range s.spans {
|
||||
if sp.Name() == name {
|
||||
return sp, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// all returns every captured span with the given name.
|
||||
func (s *spanSink) all(name string) []sdktrace.ReadOnlySpan {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var out []sdktrace.ReadOnlySpan
|
||||
for _, sp := range s.spans {
|
||||
if sp.Name() == name {
|
||||
out = append(out, sp)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// attr reads one string/int attribute off a span as text. A missing attribute is
|
||||
// "", which is what the assertions below are checking for.
|
||||
func attr(sp sdktrace.ReadOnlySpan, key string) string {
|
||||
for _, kv := range sp.Attributes() {
|
||||
if string(kv.Key) == key {
|
||||
return kv.Value.Emit()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// traced makes this process's spans observable to the test, and returns the sink
|
||||
// they land in.
|
||||
//
|
||||
// The provider is installed ONCE per process, and only the SINK is swapped per
|
||||
// test. That is not tidiness — it is the contract OTel's global actually has: a
|
||||
// tracer handle taken at package init (agentTracer here, aiTracer in clients)
|
||||
// binds to the FIRST provider installed and keeps it forever, so a second install
|
||||
// does not rebind those handles. A test that installed its own provider and shut
|
||||
// it down on cleanup therefore left every later span-asserting test in the same
|
||||
// binary exporting into a dead provider, and seeing nothing. Measured: the
|
||||
// six-tool test below failed with "produced no span" for spans that were in fact
|
||||
// created, purely because it ran second.
|
||||
//
|
||||
// Export is SYNCHRONOUS (WithSyncer): a span is in the sink when End() returns, so
|
||||
// an assertion never races a batch timer and no test has to sleep to be correct.
|
||||
var (
|
||||
obsOnce sync.Once
|
||||
obsSink atomic.Pointer[spanSink]
|
||||
)
|
||||
|
||||
// obsExporter forwards to whichever sink is currently installed. Nil sink means a
|
||||
// test that is not asserting on spans is running; its spans are discarded rather
|
||||
// than accumulated into another test's assertions.
|
||||
type obsExporter struct{}
|
||||
|
||||
func (obsExporter) ExportSpans(ctx context.Context, spans []sdktrace.ReadOnlySpan) error {
|
||||
if s := obsSink.Load(); s != nil {
|
||||
return s.export(ctx, spans)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (obsExporter) Shutdown(context.Context) error { return nil }
|
||||
|
||||
func traced(t *testing.T) *spanSink {
|
||||
t.Helper()
|
||||
obsOnce.Do(func() {
|
||||
otel.SetTracerProvider(sdktrace.NewTracerProvider(sdktrace.WithSyncer(obsExporter{})))
|
||||
})
|
||||
sink := &spanSink{}
|
||||
obsSink.Store(sink)
|
||||
t.Cleanup(func() { obsSink.Store(nil) })
|
||||
return sink
|
||||
}
|
||||
|
||||
// stubPlane is a deterministic tool plane: it offers the named tools and fails
|
||||
// exactly the ones named in fail.
|
||||
type stubPlane struct {
|
||||
mu sync.Mutex
|
||||
offer []string
|
||||
fail map[string]bool
|
||||
called []string
|
||||
// result is what a named tool returns; anything unnamed returns a default so
|
||||
// the common case stays a one-line fixture.
|
||||
result map[string]string
|
||||
}
|
||||
|
||||
func (p *stubPlane) catalog(context.Context, string, string, []string) []types.ToolDef {
|
||||
out := make([]types.ToolDef, 0, len(p.offer))
|
||||
for _, n := range p.offer {
|
||||
out = append(out, types.ToolDef{Name: n, Description: "d", Schema: json.RawMessage(`{"type":"object"}`)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (p *stubPlane) call(_ context.Context, _, _, name, _ string) (string, error) {
|
||||
p.mu.Lock()
|
||||
p.called = append(p.called, name)
|
||||
p.mu.Unlock()
|
||||
if p.fail[name] {
|
||||
return "", fmt.Errorf("upstream refused %s", name)
|
||||
}
|
||||
if out, ok := p.result[name]; ok {
|
||||
return out, nil
|
||||
}
|
||||
return "result of " + name, nil
|
||||
}
|
||||
|
||||
// toolGatewayArgs answers with ONE tool call carrying the given arguments
|
||||
// verbatim, then a final answer — so a test can pin what a dispatch records about
|
||||
// arguments it did not choose.
|
||||
func toolGatewayArgs(t *testing.T, name, args string) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if strings.Contains(string(body), `"role":"tool"`) || !strings.Contains(string(body), `"tools":[`) {
|
||||
fmt.Fprint(w, `{"id":"c2","model":"gpt-4o-mini","choices":[{"index":0,"finish_reason":"stop",`+
|
||||
`"message":{"role":"assistant","content":"the answer"}}],`+
|
||||
`"usage":{"prompt_tokens":40,"completion_tokens":9,"total_tokens":49}}`)
|
||||
return
|
||||
}
|
||||
call, _ := json.Marshal(args) // the arguments ride as a JSON *string*, as the wire has them
|
||||
fmt.Fprintf(w, `{"id":"c1","model":"gpt-4o-mini","choices":[{"index":0,"finish_reason":"tool_calls",`+
|
||||
`"message":{"role":"assistant","tool_calls":[`+
|
||||
`{"id":"tc0","type":"function","function":{"name":%q,"arguments":%s}}]}}],`+
|
||||
`"usage":{"prompt_tokens":11,"completion_tokens":22,"total_tokens":33}}`, name, call)
|
||||
}))
|
||||
}
|
||||
|
||||
// toolGateway answers the REQUEST rather than a call counter: a turn already
|
||||
// holding tool results gets the final answer; a turn offered tools asks for all of
|
||||
// them at once. Answering a counter makes the fixture depend on whoever happened
|
||||
// to call the gateway first, which is not a property of the code under test.
|
||||
func toolGateway(t *testing.T, ask []string) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
req := string(body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if strings.Contains(req, `"role":"tool"`) || !strings.Contains(req, `"tools":[`) {
|
||||
fmt.Fprint(w, `{"id":"c2","model":"gpt-4o-mini","choices":[{"index":0,"finish_reason":"stop",`+
|
||||
`"message":{"role":"assistant","content":"the answer"}}],`+
|
||||
`"usage":{"prompt_tokens":40,"completion_tokens":9,"total_tokens":49}}`)
|
||||
return
|
||||
}
|
||||
calls := make([]string, 0, len(ask))
|
||||
for i, n := range ask {
|
||||
calls = append(calls, fmt.Sprintf(
|
||||
`{"id":"tc%d","type":"function","function":{"name":%q,"arguments":"{}"}}`, i, n))
|
||||
}
|
||||
fmt.Fprintf(w, `{"id":"c1","model":"gpt-4o-mini","choices":[{"index":0,"finish_reason":"tool_calls",`+
|
||||
`"message":{"role":"assistant","tool_calls":[%s]}}],`+
|
||||
`"usage":{"prompt_tokens":11,"completion_tokens":22,"total_tokens":33}}`, strings.Join(calls, ","))
|
||||
}))
|
||||
}
|
||||
|
||||
// TestOneRunIsObservableEndToEnd: after one turn an operator can answer every
|
||||
// question from the spans plus the run record, and can get from one to the other.
|
||||
func TestOneRunIsObservableEndToEnd(t *testing.T) {
|
||||
sink := traced(t)
|
||||
|
||||
plane := &stubPlane{offer: []string{"post_v1_search_query"}}
|
||||
old := runTools
|
||||
runTools = plane
|
||||
t.Cleanup(func() { runTools = old })
|
||||
|
||||
gw := toolGateway(t, []string{"post_v1_search_query"})
|
||||
defer gw.Close()
|
||||
|
||||
app := mountApp(t, clients.AIHTTPAt(gw.URL+"/v1", "k", "gpt-4o-mini"))
|
||||
do(t, app, http.MethodPost, "/v1/agents", "acme", map[string]any{
|
||||
"name": "a", "model": "gpt-4o-mini", "instructions": "x", "tools": []string{"post_v1_search_query"}})
|
||||
code, body := do(t, app, http.MethodPost, "/v1/agents/a/run", "acme", map[string]any{"input": "hi"})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("run want 200, got %d (%s)", code, body)
|
||||
}
|
||||
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Model string `json:"model"`
|
||||
Agent string `json:"agent"`
|
||||
Actor string `json:"actor"`
|
||||
TraceID string `json:"traceId"`
|
||||
PromptTokens int `json:"promptTokens"`
|
||||
CompletionTokens int `json:"completionTokens"`
|
||||
ToolCalls int `json:"toolCalls"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &rec); err != nil {
|
||||
t.Fatalf("run body: %v (%s)", err, body)
|
||||
}
|
||||
|
||||
// WHO and WHAT, on the record the console reads.
|
||||
if rec.Actor != "acme/u-acme" {
|
||||
t.Fatalf("run record must name the person who ran it, got actor %q", rec.Actor)
|
||||
}
|
||||
if rec.Agent != "a" {
|
||||
t.Fatalf("run record must name its agent, got %q", rec.Agent)
|
||||
}
|
||||
if rec.PromptTokens == 0 || rec.CompletionTokens == 0 {
|
||||
t.Fatalf("run record must carry the tokens the gateway reported, got %d/%d",
|
||||
rec.PromptTokens, rec.CompletionTokens)
|
||||
}
|
||||
if rec.ToolCalls != 1 {
|
||||
t.Fatalf("run record must count its tool calls, got %d", rec.ToolCalls)
|
||||
}
|
||||
|
||||
// THE JOIN. Without this the run history and the trace store hold two accounts
|
||||
// of one event with no key in common, and "drill into this run" has no target.
|
||||
if rec.TraceID == "" {
|
||||
t.Fatal("run record carries no traceId: the console cannot reach this run's spans")
|
||||
}
|
||||
|
||||
root, ok := sink.find("agent.run a")
|
||||
if !ok {
|
||||
t.Fatal("no agent.run span was exported for a run that happened")
|
||||
}
|
||||
if got := root.SpanContext().TraceID().String(); got != rec.TraceID {
|
||||
t.Fatalf("run record points at trace %q but the run span is in %q", rec.TraceID, got)
|
||||
}
|
||||
if got := attr(root, "hanzo.agent.run_id"); got != rec.ID {
|
||||
t.Fatalf("run span names run %q, record is %q", got, rec.ID)
|
||||
}
|
||||
if got := attr(root, "hanzo.user"); got != "u-acme" {
|
||||
t.Fatalf("run span must name the user, got %q", got)
|
||||
}
|
||||
if got := attr(root, "hanzo.org"); got != "acme" {
|
||||
t.Fatalf("run span must name the org, got %q", got)
|
||||
}
|
||||
|
||||
// EVERY span the run produced names the run, so attribution never depends on
|
||||
// walking a parent chain that sampling or a truncated batch may have broken.
|
||||
for _, name := range []string{"agent.step", "agent.tool post_v1_search_query", "chat gpt-4o-mini"} {
|
||||
sp, ok := sink.find(name)
|
||||
if !ok {
|
||||
t.Fatalf("no %q span was exported", name)
|
||||
}
|
||||
if got := attr(sp, "hanzo.agent.run_id"); got != rec.ID {
|
||||
t.Fatalf("%s names run %q, want %q", name, got, rec.ID)
|
||||
}
|
||||
if got := sp.SpanContext().TraceID().String(); got != rec.TraceID {
|
||||
t.Fatalf("%s is in trace %q, want the run's %q", name, got, rec.TraceID)
|
||||
}
|
||||
}
|
||||
|
||||
// The model calls carry the token usage, per call.
|
||||
for _, sp := range sink.all("chat gpt-4o-mini") {
|
||||
if attr(sp, "gen_ai.usage.input_tokens") == "" {
|
||||
t.Fatal("a gen_ai span carries no input token count")
|
||||
}
|
||||
}
|
||||
|
||||
// The tool dispatch is readable as a dispatch: which tool, whose, which
|
||||
// subsystem answers for it, and how it turned out.
|
||||
tool, _ := sink.find("agent.tool post_v1_search_query")
|
||||
if got := attr(tool, "hanzo.agent.tool_subsystem"); got != "search" {
|
||||
t.Fatalf("tool span must name the owning subsystem, got %q", got)
|
||||
}
|
||||
if got := attr(tool, "hanzo.agent.tool_outcome"); got != "ok" {
|
||||
t.Fatalf("a tool that worked must say so, got outcome %q", got)
|
||||
}
|
||||
if got := attr(tool, "hanzo.user"); got != "u-acme" {
|
||||
t.Fatalf("tool span must name the actor it ran as, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFailedToolIsReadableAsSuchOnItsRun: a run that called six tools and failed
|
||||
// on the fourth must be readable as exactly that — the failing dispatch names its
|
||||
// round and its outcome, and the run still completes, because a tool failure is a
|
||||
// fact the model acts on rather than an aborted turn.
|
||||
func TestFailedToolIsReadableAsSuchOnItsRun(t *testing.T) {
|
||||
sink := traced(t)
|
||||
|
||||
names := []string{
|
||||
"post_v1_search_query", "get_v1_git_repos", "post_v1_exec_run",
|
||||
"get_v1_kms_secrets", "post_v1_notify_send", "get_v1_index_docs",
|
||||
}
|
||||
plane := &stubPlane{offer: names, fail: map[string]bool{"get_v1_kms_secrets": true}}
|
||||
old := runTools
|
||||
runTools = plane
|
||||
t.Cleanup(func() { runTools = old })
|
||||
|
||||
gw := toolGateway(t, names)
|
||||
defer gw.Close()
|
||||
|
||||
app := mountApp(t, clients.AIHTTPAt(gw.URL+"/v1", "k", "gpt-4o-mini"))
|
||||
do(t, app, http.MethodPost, "/v1/agents", "acme", map[string]any{
|
||||
"name": "a", "model": "gpt-4o-mini", "instructions": "x", "tools": names})
|
||||
code, body := do(t, app, http.MethodPost, "/v1/agents/a/run", "acme", map[string]any{"input": "go"})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("a run whose tool failed still answers 200, got %d (%s)", code, body)
|
||||
}
|
||||
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
ToolCalls int `json:"toolCalls"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &rec)
|
||||
if rec.ToolCalls != len(names) {
|
||||
t.Fatalf("the run must count all %d dispatches, got %d", len(names), rec.ToolCalls)
|
||||
}
|
||||
|
||||
// The one that failed says so, names itself, and is attributable to this run.
|
||||
bad, ok := sink.find("agent.tool get_v1_kms_secrets")
|
||||
if !ok {
|
||||
t.Fatal("the failing tool produced no span")
|
||||
}
|
||||
if got := attr(bad, "hanzo.agent.tool_outcome"); got != "error" {
|
||||
t.Fatalf("the failing dispatch must record outcome=error, got %q", got)
|
||||
}
|
||||
if bad.Status().Code.String() != "Error" {
|
||||
t.Fatalf("the failing dispatch must carry error status, got %s", bad.Status().Code)
|
||||
}
|
||||
if !strings.Contains(bad.Status().Description+fmt.Sprint(bad.Events()), "tool call failed") &&
|
||||
bad.Status().Description == "" {
|
||||
t.Fatalf("the failing dispatch records no reason")
|
||||
}
|
||||
if got := attr(bad, "hanzo.agent.run_id"); got != rec.ID {
|
||||
t.Fatalf("the failing dispatch names run %q, want %q", got, rec.ID)
|
||||
}
|
||||
if got := attr(bad, "hanzo.agent.tool_subsystem"); got != "kms" {
|
||||
t.Fatalf("the failing dispatch must name the subsystem that refused, got %q", got)
|
||||
}
|
||||
|
||||
// Its five siblings succeeded, in the same run and the same round — so the
|
||||
// operator reads "six called, one failed", not "the run broke".
|
||||
okCount := 0
|
||||
for _, n := range names {
|
||||
sp, found := sink.find("agent.tool " + n)
|
||||
if !found {
|
||||
t.Fatalf("no span for dispatched tool %s", n)
|
||||
}
|
||||
if attr(sp, "hanzo.agent.tool_outcome") == "ok" {
|
||||
okCount++
|
||||
}
|
||||
if got := attr(sp, "hanzo.agent.tool_round"); got != "0" {
|
||||
t.Fatalf("%s reports round %q, want 0", n, got)
|
||||
}
|
||||
}
|
||||
if okCount != len(names)-1 {
|
||||
t.Fatalf("want %d successful dispatches beside the failure, got %d", len(names)-1, okCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunIDReachesTheModelCall: the run's name is on the request the metering
|
||||
// decorator prices, on EVERY round of a tool loop. That is what lets the ledger's
|
||||
// per-token rows be summed back to the run that caused them — the agents-side half
|
||||
// of "what did this run cost".
|
||||
func TestRunIDReachesTheModelCall(t *testing.T) {
|
||||
rec := &runIDRecorder{}
|
||||
plane := &stubPlane{offer: []string{"post_v1_search_query"}}
|
||||
old := runTools
|
||||
runTools = plane
|
||||
t.Cleanup(func() { runTools = old })
|
||||
|
||||
app := mountApp(t, rec)
|
||||
do(t, app, http.MethodPost, "/v1/agents", "acme", map[string]any{
|
||||
"name": "a", "model": "m", "instructions": "x", "tools": []string{"post_v1_search_query"}})
|
||||
code, body := do(t, app, http.MethodPost, "/v1/agents/a/run", "acme", map[string]any{"input": "hi"})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("run want 200, got %d (%s)", code, body)
|
||||
}
|
||||
var out struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &out)
|
||||
|
||||
rec.mu.Lock()
|
||||
defer rec.mu.Unlock()
|
||||
if len(rec.runIDs) < 2 {
|
||||
t.Fatalf("want at least 2 completion rounds, got %d", len(rec.runIDs))
|
||||
}
|
||||
for i, got := range rec.runIDs {
|
||||
if got != out.ID {
|
||||
t.Fatalf("round %d priced under run %q, want %q — its cost would not sum to this run",
|
||||
i, got, out.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runIDRecorder answers one tool call then a final answer, recording the RunID it
|
||||
// was asked under on every round.
|
||||
type runIDRecorder struct {
|
||||
mu sync.Mutex
|
||||
runIDs []string
|
||||
rounds int
|
||||
}
|
||||
|
||||
func (r *runIDRecorder) ChatCompletion(_ context.Context, req *types.ChatRequest) (*types.ChatResponse, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.runIDs = append(r.runIDs, req.RunID)
|
||||
r.rounds++
|
||||
if r.rounds == 1 {
|
||||
return &types.ChatResponse{
|
||||
ToolCalls: []types.ToolCall{{ID: "tc0", Name: "post_v1_search_query", Arguments: "{}"}},
|
||||
PromptTokens: 5, CompletionTokens: 6, TotalTokens: 11,
|
||||
}, nil
|
||||
}
|
||||
return &types.ChatResponse{Content: "done", PromptTokens: 7, CompletionTokens: 8, TotalTokens: 15}, nil
|
||||
}
|
||||
|
||||
func (r *runIDRecorder) Embed(context.Context, *types.EmbedRequest) ([][]float32, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// TestToolSubsystemReadsTheNameNotAnIndex pins the derivation: the owner is a fact
|
||||
// the operation name already states, so it answers the same in a fused binary and
|
||||
// in a single-app plugin process — where a mount-index lookup would answer "" for
|
||||
// every sibling's tool.
|
||||
func TestToolSubsystemReadsTheNameNotAnIndex(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"post_v1_search_query": "search",
|
||||
"get_v1_git_repos": "git",
|
||||
"delete_v1_kms_secrets": "kms",
|
||||
"get_v1_agents_sessions": "agents",
|
||||
"http": "", // a registry-local tool owns no subsystem
|
||||
"": "",
|
||||
"v1": "", // "v1" with nothing after it names nothing
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := toolSubsystem(in); got != want {
|
||||
t.Fatalf("toolSubsystem(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolCallRecordsWhatItDidWithoutItsCredential is the "exactly what steps it
|
||||
// took" contract, and the one place where getting observability right and getting
|
||||
// security right are the same edit.
|
||||
//
|
||||
// A dispatch used to record WHICH tool ran and nothing about what it ran WITH, so
|
||||
// a trace could say an agent called post_v1_exec_run six times and never say what
|
||||
// it executed. Recording the arguments closes that. But a tool argument routinely
|
||||
// carries a live credential — a clone URL with a token in its userinfo is the
|
||||
// ordinary shape apps/coding builds — and a span store is built to be queried and
|
||||
// kept, so a trace that records one is worse than no trace.
|
||||
//
|
||||
// Both halves are asserted here together, because either alone is a bug: capture
|
||||
// with no redaction leaks, redaction with no capture is the silence we started
|
||||
// from.
|
||||
func TestToolCallRecordsWhatItDidWithoutItsCredential(t *testing.T) {
|
||||
sink := traced(t)
|
||||
|
||||
const token = "ghp_LIVE_TOKEN_VALUE"
|
||||
const args = `{"cloneUrl":"https://x-access-token:` + token + `@github.com/acme/repo","branch":"main"}`
|
||||
|
||||
// The tool hands back a credential too — a result is as capable of carrying one
|
||||
// as an argument, and it is recorded on the same span.
|
||||
plane := &stubPlane{offer: []string{"post_v1_exec_run"}, result: map[string]string{
|
||||
"post_v1_exec_run": `{"ok":true,"remote":"https://deploy:s3cr3t@git.example.com/x"}`,
|
||||
}}
|
||||
old := runTools
|
||||
runTools = plane
|
||||
t.Cleanup(func() { runTools = old })
|
||||
|
||||
gw := toolGatewayArgs(t, "post_v1_exec_run", args)
|
||||
defer gw.Close()
|
||||
|
||||
app := mountApp(t, clients.AIHTTPAt(gw.URL+"/v1", "k", "gpt-4o-mini"))
|
||||
do(t, app, http.MethodPost, "/v1/agents", "acme", map[string]any{
|
||||
"name": "a", "model": "gpt-4o-mini", "instructions": "x", "tools": []string{"post_v1_exec_run"}})
|
||||
if code, body := do(t, app, http.MethodPost, "/v1/agents/a/run", "acme", map[string]any{"input": "go"}); code != http.StatusOK {
|
||||
t.Fatalf("run want 200, got %d (%s)", code, body)
|
||||
}
|
||||
|
||||
sp, ok := sink.find("agent.tool post_v1_exec_run")
|
||||
if !ok {
|
||||
t.Fatal("the dispatch produced no span")
|
||||
}
|
||||
|
||||
// CAPTURED: the call is legible as a call.
|
||||
gotArgs := attr(sp, "gen_ai.tool.call.arguments")
|
||||
if gotArgs == "" {
|
||||
t.Fatal("the dispatch recorded no arguments — the trace cannot say what the tool was called with")
|
||||
}
|
||||
if !strings.Contains(gotArgs, "github.com/acme/repo") || !strings.Contains(gotArgs, "main") {
|
||||
t.Fatalf("the recorded arguments lost the detail that makes them worth reading: %s", gotArgs)
|
||||
}
|
||||
gotResult := attr(sp, "gen_ai.tool.call.result")
|
||||
if gotResult == "" {
|
||||
t.Fatal("the dispatch recorded no result — the trace cannot say what came back")
|
||||
}
|
||||
|
||||
// REDACTED: no credential reached the span, from either half.
|
||||
for _, secret := range []string{token, "s3cr3t"} {
|
||||
for _, where := range []struct{ name, v string }{{"arguments", gotArgs}, {"result", gotResult}} {
|
||||
if strings.Contains(where.v, secret) {
|
||||
t.Fatalf("a live credential reached the span in %s: %s", where.name, where.v)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The non-secret half of the userinfo survives — it names HOW the call
|
||||
// authenticated, which is worth reading and is not the secret.
|
||||
if !strings.Contains(gotArgs, "x-access-token") {
|
||||
t.Fatalf("redaction removed the authenticating user, not just its secret: %s", gotArgs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordedValueIsCutAndSaysSo: a tool argument larger than a span records is
|
||||
// bounded, and the value itself states that it was — a silently clipped argument
|
||||
// reads as the whole one, which is how an operator concludes a tool was called
|
||||
// with something it never saw.
|
||||
func TestRecordedValueIsCutAndSaysSo(t *testing.T) {
|
||||
big := `{"blob":"` + strings.Repeat("x", maxRecorded*2) + `"}`
|
||||
got := recordable(big)
|
||||
if len(got) > maxRecorded+64 {
|
||||
t.Fatalf("recorded value is %d bytes, want it bounded near %d", len(got), maxRecorded)
|
||||
}
|
||||
if !strings.Contains(got, "cut") {
|
||||
t.Fatalf("a cut value must say it was cut, got tail %q", got[max(0, len(got)-40):])
|
||||
}
|
||||
if !utf8.ValidString(got) {
|
||||
t.Fatal("cutting produced invalid UTF-8")
|
||||
}
|
||||
// A value that fits is returned whole, with no marker inviting a reader to
|
||||
// wonder what is missing.
|
||||
if small := recordable(`{"a":"b"}`); small != `{"a":"b"}` {
|
||||
t.Fatalf("a value within the bound must pass through unchanged, got %q", small)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEverySpanOfARunIsFiledUnderItsTenant is the one that decides whether any of
|
||||
// the rest is visible.
|
||||
//
|
||||
// The trace plane files each row under ONE attribute — hanzo.org — read per span
|
||||
// by apps/o11y/planesink.go planeOrg, which falls back to the PLATFORM's own org
|
||||
// ("hanzo") when it is absent. OTel children do not inherit a parent's
|
||||
// attributes, so a correctly stamped HTTP server span above the run buys its
|
||||
// children nothing: every span states its own tenant or is filed under the
|
||||
// platform.
|
||||
//
|
||||
// A run whose spans miss it fails twice over. The tenant opens the console and
|
||||
// sees a trace with a hole where its agent was, because the org-scoped read never
|
||||
// returns those rows; and the tenant's tool names, run ids and user subjects sit
|
||||
// in the platform's bucket instead. That is why this asserts on EVERY exported
|
||||
// span rather than on the root: one unstamped span is one missing branch of the
|
||||
// waterfall.
|
||||
func TestEverySpanOfARunIsFiledUnderItsTenant(t *testing.T) {
|
||||
sink := traced(t)
|
||||
|
||||
plane := &stubPlane{offer: []string{"post_v1_search_query"}}
|
||||
old := runTools
|
||||
runTools = plane
|
||||
t.Cleanup(func() { runTools = old })
|
||||
|
||||
gw := toolGateway(t, []string{"post_v1_search_query"})
|
||||
defer gw.Close()
|
||||
|
||||
app := mountApp(t, clients.AIHTTPAt(gw.URL+"/v1", "k", "gpt-4o-mini"))
|
||||
do(t, app, http.MethodPost, "/v1/agents", "acme", map[string]any{
|
||||
"name": "a", "model": "gpt-4o-mini", "instructions": "x", "tools": []string{"post_v1_search_query"}})
|
||||
if code, body := do(t, app, http.MethodPost, "/v1/agents/a/run", "acme", map[string]any{"input": "hi"}); code != http.StatusOK {
|
||||
t.Fatalf("run want 200, got %d (%s)", code, body)
|
||||
}
|
||||
|
||||
// The spans a run IS. Named explicitly so a span that stops being produced
|
||||
// fails here rather than silently shrinking the set under assertion.
|
||||
want := []string{"agent.run a", "agent.step", "agent.tool post_v1_search_query", "chat gpt-4o-mini"}
|
||||
for _, name := range want {
|
||||
spans := sink.all(name)
|
||||
if len(spans) == 0 {
|
||||
t.Fatalf("no %q span was exported", name)
|
||||
}
|
||||
for _, sp := range spans {
|
||||
if got := attr(sp, "hanzo.org"); got != "acme" {
|
||||
t.Fatalf("%s carries hanzo.org=%q, want \"acme\" — the plane files this span "+
|
||||
"under the platform org, so the tenant cannot see its own run", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOneOrgsRunNeverCarriesAnothersTenant proves the org stamp PARTITIONS the
|
||||
// spans: two tenants running the same agent name against the same process produce
|
||||
// two disjoint sets of rows, and neither names the other.
|
||||
//
|
||||
// Tenancy on the trace plane is exactly this attribute — the org-scoped read binds
|
||||
// it as its first predicate — so a run that stamped the wrong tenant, or stamped
|
||||
// none and fell back to the platform, would disclose one org's tool calls and user
|
||||
// subjects to a reader scoped to another. The gate is fail-closed downstream; this
|
||||
// asserts the value it closes on is right at the source.
|
||||
func TestOneOrgsRunNeverCarriesAnothersTenant(t *testing.T) {
|
||||
sink := traced(t)
|
||||
|
||||
plane := &stubPlane{offer: []string{"post_v1_search_query"}}
|
||||
old := runTools
|
||||
runTools = plane
|
||||
t.Cleanup(func() { runTools = old })
|
||||
|
||||
gw := toolGateway(t, []string{"post_v1_search_query"})
|
||||
defer gw.Close()
|
||||
|
||||
app := mountApp(t, clients.AIHTTPAt(gw.URL+"/v1", "k", "gpt-4o-mini"))
|
||||
for _, org := range []string{"acme", "globex"} {
|
||||
do(t, app, http.MethodPost, "/v1/agents", org, map[string]any{
|
||||
"name": "a", "model": "gpt-4o-mini", "instructions": "x", "tools": []string{"post_v1_search_query"}})
|
||||
if code, body := do(t, app, http.MethodPost, "/v1/agents/a/run", org, map[string]any{"input": "hi"}); code != http.StatusOK {
|
||||
t.Fatalf("%s run want 200, got %d (%s)", org, code, body)
|
||||
}
|
||||
}
|
||||
|
||||
// Every span belongs to exactly one of the two tenants, and the user subject it
|
||||
// names is that tenant's own. A span filed under the platform default is
|
||||
// counted as neither and fails the total below.
|
||||
seen := map[string]int{}
|
||||
for _, name := range []string{"agent.run a", "agent.step", "agent.tool post_v1_search_query", "chat gpt-4o-mini"} {
|
||||
for _, sp := range sink.all(name) {
|
||||
org := attr(sp, "hanzo.org")
|
||||
seen[org]++
|
||||
if org != "acme" && org != "globex" {
|
||||
t.Fatalf("%s is filed under %q — neither tenant ran it", name, org)
|
||||
}
|
||||
// The person is scoped to the same tenant. "acme" carrying globex's
|
||||
// subject would be a cross-tenant disclosure inside a correctly
|
||||
// stamped row, which the org assertion alone cannot catch.
|
||||
if sub := attr(sp, "hanzo.user"); sub != "" && sub != "u-"+org {
|
||||
t.Fatalf("%s is filed under org %q but names user %q", name, org, sub)
|
||||
}
|
||||
}
|
||||
}
|
||||
if seen["acme"] == 0 || seen["globex"] == 0 {
|
||||
t.Fatalf("want spans from both tenants, got acme=%d globex=%d", seen["acme"], seen["globex"])
|
||||
}
|
||||
}
|
||||
+126
-2
@@ -2,11 +2,14 @@ package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/apps/principal"
|
||||
"github.com/hanzoai/cloud/fleet"
|
||||
)
|
||||
|
||||
// RunOnBehalf runs agent `ref` for `org` ON BEHALF OF `userSub`, IN-PROCESS —
|
||||
@@ -34,6 +37,18 @@ func RunOnBehalf(ctx context.Context, org, userSub, ref, input string) (Run, err
|
||||
}
|
||||
|
||||
func runOnBehalf(s *cloud.Service[state], ctx context.Context, org, userSub, ref, input string) (Run, error) {
|
||||
return runOnBehalfModel(s, ctx, org, userSub, ref, input, "")
|
||||
}
|
||||
|
||||
// runOnBehalfModel is runOnBehalf with the ASKER's model preference.
|
||||
//
|
||||
// It overrides the agent's own Model only when the caller named one AND the
|
||||
// agent is the built-in default — a person's Slack preference must not silently
|
||||
// re-point an agent their org deliberately configured. An unrecognised value is
|
||||
// ignored rather than forwarded: the menu came from us, so anything else is a
|
||||
// stale client or a forged payload, and it would bill this org for a model it
|
||||
// never offered.
|
||||
func runOnBehalfModel(s *cloud.Service[state], ctx context.Context, org, userSub, ref, input, model string) (Run, error) {
|
||||
org = strings.TrimSpace(org)
|
||||
if org == "" || len(org) > principal.MaxOrgLen {
|
||||
return Run{}, fmt.Errorf("agents: invalid org")
|
||||
@@ -49,14 +64,123 @@ func runOnBehalf(s *cloud.Service[state], ctx context.Context, org, userSub, ref
|
||||
return Run{}, err
|
||||
}
|
||||
a, err := sto.Resolve(ctx, org, strings.TrimSpace(ref))
|
||||
if err != nil {
|
||||
return Run{}, err // errNotFound or a real DB error — caller replies generically
|
||||
if errors.Is(err, errNotFound) {
|
||||
// An org that has never opened the agents UI has NO rows, and the chat
|
||||
// bridges ask for the conventional ref ("hanzo") — so @hanzo answered
|
||||
// "the agent hit an error handling that" in every workspace that connected
|
||||
// Slack and did nothing else. Measured: `agents: agent not found`, for the
|
||||
// org that had just linked successfully.
|
||||
//
|
||||
// The conventional ref therefore resolves to a BUILT-IN default rather than
|
||||
// requiring an org to create a row before the front door works. It is not
|
||||
// persisted: writing a row here would fork the definition per org and make
|
||||
// a later product change unable to reach the orgs that had already been
|
||||
// seeded. A row the org DOES create wins, because Resolve is tried first.
|
||||
if def, ok := builtinAgent(org, ref, cloud.ChatModel); ok {
|
||||
a = def
|
||||
} else {
|
||||
return Run{}, err
|
||||
}
|
||||
} else if err != nil {
|
||||
return Run{}, err // a real DB error — caller replies generically
|
||||
}
|
||||
// The actor attributes the spend to the acting principal (org/userSub) for the
|
||||
// audit trail; the BALANCE gated + debited is always a.Org (== org), never the
|
||||
// caller. Synthetic request id: in-process, there is no HTTP X-Request-Id; the
|
||||
// client IP is empty (no socket).
|
||||
if m := strings.TrimSpace(model); m != "" && strings.HasPrefix(a.ID, "builtin-") && knownChatModel(m) {
|
||||
a.Model = m
|
||||
}
|
||||
actor := billingActor(org, userSub)
|
||||
reqID, _ := genID("obh")
|
||||
return runAgent(s, ctx, a, input, actor, reqID, "")
|
||||
}
|
||||
|
||||
// builtinAgent is the definition the conventional chat ref resolves to when an
|
||||
// org has not defined its own.
|
||||
//
|
||||
// ONE name, the convention the bridges already default to (bridgeAgentRef →
|
||||
// "hanzo"). Anything else is a real miss and stays a miss: an unknown ref must
|
||||
// not silently become the default agent, or a typo in `code: repo` would run the
|
||||
// chat agent and look like it worked.
|
||||
//
|
||||
// The model is cloud.ChatModel, which is where the tier and the evidence for it
|
||||
// now live — one constant in the file that owns model policy, instead of a literal
|
||||
// here behind a BRIDGE_AGENT_MODEL knob no deployment ever set.
|
||||
//
|
||||
// The tier did not change. Its JUSTIFICATION did, because the old one was false:
|
||||
// this called `enso` "the auto-routing SKU that selects per query in the gateway's
|
||||
// own catalog", and enso does no such thing — one fixed route entry
|
||||
// (deepseek-v4-pro, reasoning: medium), no ladder, no escalation. Measurement says
|
||||
// it is nonetheless the right tier for a tool-driving turn, and cloud.ChatModel
|
||||
// carries those numbers.
|
||||
//
|
||||
// It is also NOT cloud.FallbackModel ("best"): that constant's own doc says it
|
||||
// "keeps a bot's reply landing when the flash tier is saturated; the interactive
|
||||
// chat path never uses it" — it is the degraded path, and a Slack turn IS the
|
||||
// interactive chat path.
|
||||
//
|
||||
// A person who wants a different tier pins one in the App Home menu, and that pin
|
||||
// still wins below.
|
||||
//
|
||||
// Tools is the fleet's whole door (ToolsAll), not empty: the tool-calling loop
|
||||
// decides what may be OFFERED, but an agent that declares nothing is offered
|
||||
// nothing, which is how the default assistant came to report it could not reach a
|
||||
// cloud that was one socket away.
|
||||
func builtinAgent(org, ref, model string) (Agent, bool) {
|
||||
if !strings.EqualFold(strings.TrimSpace(ref), builtinAgentName) {
|
||||
return Agent{}, false
|
||||
}
|
||||
if strings.TrimSpace(model) == "" {
|
||||
return Agent{}, false // no model configured: an honest miss, not a broken run
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
return Agent{
|
||||
ID: "builtin-" + builtinAgentName, Org: org, Name: builtinAgentName, Model: model,
|
||||
Instructions: builtinAgentInstructions,
|
||||
Description: "The default Hanzo assistant that answers in chat.",
|
||||
Status: "ready", ExecutionMode: ModeOneShot,
|
||||
// The default assistant is offered the fleet's whole door. Its instructions
|
||||
// tell it the tools exist and how to call them; without this it was handed
|
||||
// an empty offer and correctly reported it could not reach the cloud, while
|
||||
// the door served 88 tools one socket away.
|
||||
Tools: []string{ToolsAll},
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}, true
|
||||
}
|
||||
|
||||
const builtinAgentName = "hanzo"
|
||||
|
||||
// builtinAgentInstructions is what the default assistant is TOLD it is. Kept
|
||||
// short on purpose: a long persona spends context a user's actual question needs,
|
||||
// and every sentence here is one the model reads on every turn.
|
||||
const builtinAgentInstructions = "You are Hanzo, the assistant for the Hanzo cloud. " +
|
||||
"Answer in Slack: be brief, concrete, and say plainly when you do not know or " +
|
||||
"cannot reach something rather than guessing.\n\n" +
|
||||
// THE TOOL PROTOCOL. Without this the tools are unusable, and the failure is
|
||||
// silent: the model sees 88 tools whose only argument is an `op` enum of bare
|
||||
// names with no schemas, cannot tell what any of them take, and answers from
|
||||
// memory instead — which reads as "the assistant is stupid" rather than as a
|
||||
// missing sentence. The surface was collapsed from 1,189 flat tools (977 KB,
|
||||
// ~244k tokens just to list) to 88 grouped ones precisely so the schemas could
|
||||
// be fetched on demand; the fetch has to be described or the trade is a loss.
|
||||
"Your tools are grouped one per subsystem, and a tool IS its subsystem's name. " +
|
||||
"Each takes an `op` (choose from its enum) and an `input` object. The enum lists " +
|
||||
"operation names only — to see what an operation accepts or returns, call `" +
|
||||
fleet.Describe + "` with that op name first, then call it. " +
|
||||
"Prefer looking something up with a tool " +
|
||||
"over answering from memory: you are answering about THIS organization's live " +
|
||||
"cloud, and your training data does not contain it."
|
||||
|
||||
// knownChatModel accepts only a model this deployment offers for chat.
|
||||
//
|
||||
// The enso family is the SKU set the App Home menu is built from. Anything else
|
||||
// is refused rather than forwarded — an arbitrary string from a client would let
|
||||
// a caller pick what their org pays for.
|
||||
func knownChatModel(m string) bool {
|
||||
switch m {
|
||||
case "enso", "enso-flash", "enso-ultra":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -58,7 +58,25 @@ func planeRunOnBehalf(ctx context.Context, in *plane.RunOnBehalfIn) (*plane.RunO
|
||||
if strings.TrimSpace(in.Subject) == "" {
|
||||
return nil, fmt.Errorf("agents: run-on-behalf requires a linked subject")
|
||||
}
|
||||
run, err := runOnBehalf(mounted, ctx, in.Org, in.Subject, in.Ref, in.Input)
|
||||
// The tenant this run bills is NOT stated here, and the reason is worth writing
|
||||
// down because the obvious fix is wrong and was shipped once.
|
||||
//
|
||||
// A run bills: the balance gate is a plane call to commerce, which takes the org
|
||||
// from the CALLER's identity and never from an argument (balance_rpc.go:36), so
|
||||
// no caller can name the books it charges. It is tempting to satisfy that with
|
||||
// cloud.For(ctx, in.Org) right here. That is a NO-OP. This op is reached over the
|
||||
// plane, which is a real request, and zip reads a STATED caller only where there
|
||||
// is NO request (caller.go:352-356) — otherwise CallerOf reads the request's own
|
||||
// headers. The statement is silently discarded and the gate still answers
|
||||
// `authorize: no org on the call`. That is exactly what production did.
|
||||
//
|
||||
// The org must therefore be on the WIRE, stated by the dispatcher on a detached
|
||||
// context before the hop (Caller.headers renders it, caller.go:302). The bridge
|
||||
// does that — see the cloud.For(context.Background(), org) at the plane.Ask in
|
||||
// apps/integrations/bridge.go. By the time we are here it has already arrived as
|
||||
// a header and rides onward for free. in.Org remains in the payload because the
|
||||
// run RECORD needs it; it is not what authorizes the spend.
|
||||
run, err := runOnBehalfModel(mounted, ctx, in.Org, in.Subject, in.Ref, in.Input, in.Model)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+86
-12
@@ -55,6 +55,13 @@ type Agent struct {
|
||||
// Execution modes. One-shot agents run only on an explicit POST; long-running
|
||||
// agents are additionally invoked by the scheduler on their Schedule.
|
||||
const (
|
||||
// ToolsAll in an agent's Tools means "whatever the fleet's door serves",
|
||||
// resolved per run rather than enumerated. It exists because the default
|
||||
// assistant cannot list a surface that is discovered at runtime and changes
|
||||
// whenever a subsystem ships. An agent that declares nothing still gets
|
||||
// nothing — that default is its authority, and it is unchanged.
|
||||
ToolsAll = "*"
|
||||
|
||||
ModeOneShot = "one-shot"
|
||||
ModeLongRunning = "long-running"
|
||||
)
|
||||
@@ -73,6 +80,32 @@ type Run struct {
|
||||
Error string
|
||||
DurationMs int64
|
||||
CreatedAt int64
|
||||
|
||||
// Actor is the "org/sub" identity the run was executed and billed AS. The row
|
||||
// already recorded which tenant paid; it never recorded which person asked,
|
||||
// so "who ran this" was answerable only from an HTTP audit line that a
|
||||
// scheduled or on-behalf run never produces. Empty means there was no person
|
||||
// — a schedule or a service token — which is a different fact from unknown.
|
||||
Actor string
|
||||
|
||||
// TraceID is the trace this run IS, so the record and its spans are one thing
|
||||
// an operator can move between. Without it the run history and the trace store
|
||||
// hold two accounts of the same event with no key in common: you can see that
|
||||
// a run took nine seconds but not which call spent them.
|
||||
//
|
||||
// Empty when the process had no tracer installed — an honest "not recorded",
|
||||
// never a fabricated id.
|
||||
TraceID string
|
||||
|
||||
// PromptTokens/CompletionTokens are what the gateway reported for the run's
|
||||
// FINAL completion, and ToolCalls is how many tool dispatches it made. They
|
||||
// are what the run itself knows. The per-round token spend of a tool loop is
|
||||
// the metering ledger's account, joined by this run's id (see
|
||||
// types.ChatRequest.RunID) — recorded there once, rather than re-totalled here
|
||||
// into a second number that could disagree with the money.
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
ToolCalls int
|
||||
}
|
||||
|
||||
// Store is ONE ORG's agents database — the file at
|
||||
@@ -133,9 +166,18 @@ CREATE TABLE IF NOT EXISTS agent_runs (
|
||||
output TEXT NOT NULL DEFAULT '',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
created_at INTEGER NOT NULL,
|
||||
actor TEXT NOT NULL DEFAULT '',
|
||||
trace_id TEXT NOT NULL DEFAULT '',
|
||||
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
tool_calls INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_runs_org_agent_created ON agent_runs(org, agent_name, created_at);
|
||||
-- The org-wide feed: "what ran here lately", across every agent. The per-agent
|
||||
-- index cannot serve it — its leading column after org is agent_name, so an
|
||||
-- org-wide scan by recency would sort every row the org ever produced.
|
||||
CREATE INDEX IF NOT EXISTS ix_runs_org_created ON agent_runs(org, created_at);
|
||||
`
|
||||
if _, err := s.db.Exec(ddl); err != nil {
|
||||
return fmt.Errorf("migrate: %w", err)
|
||||
@@ -154,6 +196,24 @@ CREATE INDEX IF NOT EXISTS ix_runs_org_agent_created ON agent_runs(org, agent_na
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
// Same forward, idempotent upgrade for the run attribution columns, so a
|
||||
// deployment's existing history keeps working and every run recorded from
|
||||
// here on can name its actor, its trace and its token account.
|
||||
if err := s.addColumns("agent_runs", map[string]string{
|
||||
"actor": "TEXT NOT NULL DEFAULT ''",
|
||||
"trace_id": "TEXT NOT NULL DEFAULT ''",
|
||||
"prompt_tokens": "INTEGER NOT NULL DEFAULT 0",
|
||||
"completion_tokens": "INTEGER NOT NULL DEFAULT 0",
|
||||
"tool_calls": "INTEGER NOT NULL DEFAULT 0",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
// The org-wide recency index, created AFTER the columns above for the same
|
||||
// reason the scheduler's partial index is: a legacy DB gains them just now.
|
||||
if _, err := s.db.Exec(`CREATE INDEX IF NOT EXISTS ix_runs_org_created
|
||||
ON agent_runs(org, created_at)`); err != nil {
|
||||
return fmt.Errorf("migrate: org runs index: %w", err)
|
||||
}
|
||||
// Partial index for the once-a-minute scheduler scan — created AFTER the
|
||||
// lifecycle columns exist (a legacy DB gains them just above), so it selects
|
||||
// only the (typically few) scheduled long-running agents instead of
|
||||
@@ -316,7 +376,7 @@ const agentCols = `id,org,name,model,instructions,description,tools,status,execu
|
||||
|
||||
// runCols is the run projection, named ONCE so the insert, the two reads and the
|
||||
// legacy fan-out cannot drift apart on a column added to only some of them.
|
||||
const runCols = `id,org,agent_name,status,model,input,output,error,duration_ms,created_at`
|
||||
const runCols = `id,org,agent_name,status,model,input,output,error,duration_ms,created_at,actor,trace_id,prompt_tokens,completion_tokens,tool_calls`
|
||||
|
||||
func scanAgent(sc interface{ Scan(...any) error }) (Agent, error) {
|
||||
var a Agent
|
||||
@@ -492,14 +552,30 @@ func (s *Store) Delete(ctx context.Context, org, name string) (bool, error) {
|
||||
// InsertRun records one agent execution.
|
||||
func (s *Store) InsertRun(ctx context.Context, r Run) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO agent_runs (`+runCols+`) VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
||||
r.ID, r.Org, r.AgentName, r.Status, r.Model, r.Input, r.Output, r.Error, r.DurationMs, r.CreatedAt)
|
||||
`INSERT INTO agent_runs (`+runCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
r.ID, r.Org, r.AgentName, r.Status, r.Model, r.Input, r.Output, r.Error, r.DurationMs, r.CreatedAt,
|
||||
r.Actor, r.TraceID, r.PromptTokens, r.CompletionTokens, r.ToolCalls)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert run: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanRun reads one row of the runCols projection. It exists because there are
|
||||
// two readers of that projection and they were each spelling the column order out
|
||||
// by hand — which is the drift runCols was named once to prevent, reintroduced one
|
||||
// layer down. One scanner means a column added to the projection is added to every
|
||||
// read of it, or to none.
|
||||
func scanRun(sc interface{ Scan(...any) error }) (Run, error) {
|
||||
var r Run
|
||||
if err := sc.Scan(&r.ID, &r.Org, &r.AgentName, &r.Status, &r.Model, &r.Input,
|
||||
&r.Output, &r.Error, &r.DurationMs, &r.CreatedAt,
|
||||
&r.Actor, &r.TraceID, &r.PromptTokens, &r.CompletionTokens, &r.ToolCalls); err != nil {
|
||||
return Run{}, fmt.Errorf("scan run: %w", err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// ListRuns returns the run history for (org,agent), newest first, capped.
|
||||
func (s *Store) ListRuns(ctx context.Context, org, agent string, limit int) ([]Run, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
@@ -513,10 +589,9 @@ func (s *Store) ListRuns(ctx context.Context, org, agent string, limit int) ([]R
|
||||
defer func() { _ = rows.Close() }()
|
||||
var out []Run
|
||||
for rows.Next() {
|
||||
var r Run
|
||||
if err := rows.Scan(&r.ID, &r.Org, &r.AgentName, &r.Status, &r.Model, &r.Input,
|
||||
&r.Output, &r.Error, &r.DurationMs, &r.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan run: %w", err)
|
||||
r, err := scanRun(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
@@ -541,10 +616,9 @@ func (s *Store) RunsSince(ctx context.Context, org string, since int64, limit in
|
||||
defer func() { _ = rows.Close() }()
|
||||
var out []Run
|
||||
for rows.Next() {
|
||||
var r Run
|
||||
if err := rows.Scan(&r.ID, &r.Org, &r.AgentName, &r.Status, &r.Model, &r.Input,
|
||||
&r.Output, &r.Error, &r.DurationMs, &r.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan run: %w", err)
|
||||
r, err := scanRun(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
package agents
|
||||
|
||||
// tools.go is the part of a run that was missing: Agent.Tools was stored,
|
||||
// updated and shown, and the run never read it. An agent with "slack_post_message"
|
||||
// in its tool list ran one chat completion against a model that had never been
|
||||
// told the tool exists, so every @hanzo turn was a chatbot with no hands.
|
||||
//
|
||||
// A tool call is a CONVERSATION, not a call: the model asks for a tool, something
|
||||
// runs it, the result goes back, and the model decides again. Three things make
|
||||
// that loop safe to run on someone else's money —
|
||||
//
|
||||
// BOUNDED maxToolRounds model turns and one wall-clock budget for the whole
|
||||
// run. The last turn is offered NO tools, so the loop cannot end in
|
||||
// anything but words.
|
||||
// ATTRIBUTED every dispatch carries the run's own (org, actor) — the same pair
|
||||
// the run's fee is billed under — so a tool runs as the principal
|
||||
// that asked for it and the tool plane meters it there.
|
||||
// RECOVERABLE a tool that fails is reported TO THE MODEL as a tool result, not
|
||||
// raised. A broken connector makes the agent explain itself; it does
|
||||
// not kill the turn.
|
||||
//
|
||||
// An agent with an empty Tools list never enters any of this: executeRun takes
|
||||
// the same single completion it always did.
|
||||
//
|
||||
// ── WHERE THE TOOLS COME FROM ─────────────────────────────────────────────────
|
||||
//
|
||||
// A PLUGIN IS A PROCESS. `agents` ships as its own binary (plugin/agents/main.go)
|
||||
// and `tools` as another (manifest/apps.go), so tools.Default() HERE holds only
|
||||
// what agents itself registered — the agentToolProvider at agents.go:399 — and
|
||||
// its activation store is nil, which makes ActivationStore.IsActivated report
|
||||
// false for everything (apps/tools/activation.go:83) and Registry.Dispatch
|
||||
// refuse every name. In the split fleet this process's own registry is not an
|
||||
// answer; it is a fact about this process.
|
||||
//
|
||||
// The thing that CAN answer already exists, and it was already deployed: the
|
||||
// fleet's composed agent door (fleet/mcp.go), which asks every app what it
|
||||
// serves right now, merges the union, and forwards a call to the app that listed
|
||||
// the name. It is what api.hanzo.ai/v1/mcp is. So there is one tool surface in
|
||||
// this fleet and an agent reads THAT one — door.go is the client, over the
|
||||
// host's own socket, and it is the plane a real deployment uses.
|
||||
//
|
||||
// registryTools stays as what this process's own registry says, which is the
|
||||
// whole answer exactly where this process is the whole fleet: a single-app
|
||||
// binary, a dev box, a test. doorTools falls back to it there and nowhere else,
|
||||
// on the one signal that means it — nothing listening on the router's socket.
|
||||
//
|
||||
// The degradation that remains is an OUTAGE, and it is visible: every run's step
|
||||
// span carries both hanzo.agent.tools_declared and hanzo.agent.tools, so
|
||||
// "declared 3, offered 0" is a number in o11y rather than a silence, and the
|
||||
// door's own error is recorded beside it.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/hanzoai/cloud/apps/tools"
|
||||
"github.com/hanzoai/cloud/audit"
|
||||
"github.com/hanzoai/cloud/types"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxToolRounds is how many times the model may ask for tools in one run.
|
||||
// A loop is only as safe as its bound: each round is a completion the org
|
||||
// pays for, and a model that has decided to call the same tool forever will
|
||||
// do exactly that. Eight is deep enough for read-then-act-then-confirm and
|
||||
// shallow enough that a wedged agent costs a known amount.
|
||||
maxToolRounds = 8
|
||||
// toolRunBudget is the wall clock for the WHOLE loop, tools included. A Slack
|
||||
// turn is waiting on this, and a caller carrying a tighter deadline still
|
||||
// wins — this is a ceiling, never an extension.
|
||||
toolRunBudget = 90 * time.Second
|
||||
// toolCallTimeout bounds ONE dispatch, so a single hung connector cannot eat
|
||||
// the whole run's budget and starve the turn of its answer.
|
||||
toolCallTimeout = 30 * time.Second
|
||||
// maxToolResult bounds what one tool may put back into the transcript. A tool
|
||||
// that returns a megabyte would be paid for as prompt tokens on every
|
||||
// remaining round; the model is told the result was truncated.
|
||||
maxToolResult = 16 * 1024
|
||||
// maxToolArgs bounds the arguments a model may emit for one call, before they
|
||||
// are ever parsed.
|
||||
maxToolArgs = 32 * 1024
|
||||
// maxRecorded bounds what ONE tool call may put on its span — its arguments or
|
||||
// its result. A trace is for READING, not for replay, and this is deliberately
|
||||
// far tighter than maxToolResult: that bound is paid once as prompt tokens,
|
||||
// this one is stored for every call of every run and kept for the life of the
|
||||
// trace. 4 KiB is a page of evidence. What exceeds it is cut and SAID to be
|
||||
// cut — a silently clipped value reads as the whole argument, which is how an
|
||||
// operator concludes a tool was called with something it never saw.
|
||||
maxRecorded = 4 * 1024
|
||||
// maxAgentDepth bounds how deep AGENTS may nest, which is a different bound
|
||||
// from maxToolRounds and is not covered by it: an agent is itself a tool
|
||||
// (agentToolProvider, agents.go:399), so A calling B calling A is a cycle in
|
||||
// which every level gets a FRESH round cap and a fresh fee. Three levels is
|
||||
// an agent delegating to a specialist that delegates once more; deeper than
|
||||
// that is a loop, and at the bottom an agent is simply offered no tools and
|
||||
// has to answer for itself.
|
||||
maxAgentDepth = 3
|
||||
)
|
||||
|
||||
// depthKey carries how many agents deep this run is. Unexported zero-size type,
|
||||
// so nothing outside this package can forge a shallower depth.
|
||||
type depthKey struct{}
|
||||
|
||||
// agentDepth reads the nesting depth off the context; a top-level run is 0.
|
||||
func agentDepth(ctx context.Context) int {
|
||||
d, _ := ctx.Value(depthKey{}).(int)
|
||||
return d
|
||||
}
|
||||
|
||||
// deeper marks the context one agent deeper. It is applied at the DISPATCH, so
|
||||
// the depth travels with the call that creates the nesting — a nested run reads
|
||||
// it from the context its parent's tool call handed it.
|
||||
func deeper(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, depthKey{}, agentDepth(ctx)+1)
|
||||
}
|
||||
|
||||
// callableTools is what an agent may actually be offered: its declared names,
|
||||
// minus the one that would call the agent ITSELF. A self-call is a recursion no
|
||||
// round cap bounds, because each level starts its cap over.
|
||||
func callableTools(a Agent) []string {
|
||||
self := "agent_" + a.Name
|
||||
out := make([]string, 0, len(a.Tools))
|
||||
for _, n := range a.Tools {
|
||||
if strings.TrimSpace(n) == self {
|
||||
continue
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// toolPlane is where a run's callable tools come from: what may be offered to
|
||||
// the model, and what happens when it asks for one.
|
||||
//
|
||||
// It is an interface for the reason the package comment gives — the answer is
|
||||
// per-DEPLOYMENT, not per-run — and it is deliberately narrow: names, prose,
|
||||
// schemas, and one call that takes raw JSON in and returns text out. Nothing in
|
||||
// it is a map, which is what let the same shape cross a process boundary
|
||||
// unchanged (door.go) rather than being redesigned at the seam.
|
||||
type toolPlane interface {
|
||||
// catalog resolves the tool NAMES an agent declares into definitions the
|
||||
// model can be offered. A name that resolves to nothing is simply absent —
|
||||
// offering a tool that would be refused at dispatch teaches the model a lie.
|
||||
catalog(ctx context.Context, org, actor string, want []string) []types.ToolDef
|
||||
// call runs one tool as (org, actor) and returns its result as text. args is
|
||||
// the raw JSON object the model emitted, verbatim.
|
||||
call(ctx context.Context, org, actor, name, args string) (string, error)
|
||||
}
|
||||
|
||||
// runTools is the tool plane a run uses: the fleet's own agent door, which
|
||||
// answers with this process's registry wherever this process IS the fleet
|
||||
// (door.go). A package var so a test can substitute a deterministic one; there
|
||||
// is no exported setter, because which plane answers is a property of the
|
||||
// deployment and not something a caller may choose.
|
||||
var runTools toolPlane = doorTools{}
|
||||
|
||||
// registryTools is the tool plane read IN THIS PROCESS: tools.Default(), the same
|
||||
// registry POST /v1/tools/call dispatches through, with the same activation gate,
|
||||
// the same source precedence and the same x402 settlement. It is the whole answer
|
||||
// where the tool plane is co-resident, and it is honest where it is not — the
|
||||
// registry simply offers nothing.
|
||||
type registryTools struct{}
|
||||
|
||||
// catalog keeps a declared name only when the plane offers it to this org AND it
|
||||
// is dispatchable AND it is activated. All three are the conditions dispatch
|
||||
// itself enforces (apps/tools/registry.go:231), so a tool that survives this
|
||||
// filter is one the model can actually call — which is the only kind worth
|
||||
// spending a prompt on.
|
||||
func (registryTools) catalog(ctx context.Context, org, _ string, want []string) []types.ToolDef {
|
||||
if org == "" || len(want) == 0 {
|
||||
return nil
|
||||
}
|
||||
wanted := make(map[string]bool, len(want))
|
||||
for _, n := range want {
|
||||
if n = strings.TrimSpace(n); n != "" {
|
||||
wanted[n] = true
|
||||
}
|
||||
}
|
||||
if len(wanted) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]types.ToolDef, 0, len(wanted))
|
||||
for _, t := range tools.Default().List(ctx, tools.Scope{Org: org}) {
|
||||
if !wanted[t.Name] || !t.Dispatchable || !t.Activated {
|
||||
continue
|
||||
}
|
||||
out = append(out, types.ToolDef{Name: t.Name, Description: t.Description, Schema: t.Schema})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// call dispatches through the registry's ONE policy path, bound to the run's own
|
||||
// principal. The arguments are decoded into a map HERE, at the in-process seam
|
||||
// that requires one, and nowhere else — the map never appears on a type that has
|
||||
// to cross a process boundary.
|
||||
func (registryTools) call(ctx context.Context, org, actor, name, args string) (string, error) {
|
||||
var decoded map[string]any
|
||||
if s := strings.TrimSpace(args); s != "" && s != "null" {
|
||||
if err := json.Unmarshal([]byte(s), &decoded); err != nil {
|
||||
return "", fmt.Errorf("arguments are not a JSON object: %w", err)
|
||||
}
|
||||
}
|
||||
out, err := tools.Default().Dispatch(ctx, tools.Principal{Org: org, User: actorSub(org, actor)}, name, decoded)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return renderToolResult(out), nil
|
||||
}
|
||||
|
||||
// toolSubsystem names the app that answers for a tool, read out of the tool's OWN
|
||||
// name rather than looked up anywhere.
|
||||
//
|
||||
// The fleet door groups one tool per subsystem and carries the operation names in
|
||||
// its `op` enum (fleet/grouped.go), and those names are spelled
|
||||
// <method>_v1_<subsystem>_<rest> — so the owner is a fact the name already
|
||||
// states. Deriving it here keeps this a pure function of the value: it answers
|
||||
// the same way in the fused binary and in a single-app plugin process, whereas
|
||||
// cloud.SubsystemOf reads a boot-time mount index that in a plugin knows only
|
||||
// that plugin's own routes and would answer "" for every sibling's tool.
|
||||
//
|
||||
// A name that is not in that shape (a registry-local tool like "http") owns no
|
||||
// subsystem and says so with "", rather than with a guess.
|
||||
func toolSubsystem(op string) string {
|
||||
parts := strings.Split(op, "_")
|
||||
for i, p := range parts {
|
||||
if p == "v1" && i+1 < len(parts) {
|
||||
return parts[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// actorSub reads the user subject back out of the run's "org/sub" billing actor,
|
||||
// so a dispatch runs as the person the run is billed to. A bare org (a scheduled
|
||||
// run, a service token) has no subject and lends none: the org is the authority
|
||||
// either way, and inventing a user would be attributing the call to nobody.
|
||||
func actorSub(org, actor string) string {
|
||||
actor = strings.TrimSpace(actor)
|
||||
if actor == "" || actor == org {
|
||||
return ""
|
||||
}
|
||||
if sub, ok := strings.CutPrefix(actor, org+"/"); ok {
|
||||
return sub
|
||||
}
|
||||
return actor
|
||||
}
|
||||
|
||||
// recordable is what a tool call may put on a span: credentials stripped, size
|
||||
// bounded, and the bound stated.
|
||||
//
|
||||
// Both halves are load-bearing. A tool ARGUMENT routinely carries a live
|
||||
// credential — a clone URL with a token in the userinfo is the ordinary shape
|
||||
// (apps/coding builds exactly that) — and a trace that records one is worse than
|
||||
// no trace at all, because the token outlives the sandbox that used it and sits
|
||||
// in a store built to be queried. audit.RedactText is the fleet's ONE redactor;
|
||||
// this adds no second policy, it just applies it at the moment of recording.
|
||||
//
|
||||
// The order matters: redact FIRST, then cut. Cutting first can split a credential
|
||||
// and leave the front half of it on the span, which is both a leak and unreadable.
|
||||
func recordable(s string) string {
|
||||
s = audit.RedactText(s)
|
||||
if len(s) <= maxRecorded {
|
||||
return s
|
||||
}
|
||||
// Cut on a rune boundary so the value stays valid UTF-8; a span store that
|
||||
// rejects or mangles invalid UTF-8 would lose the whole attribute over the
|
||||
// last byte of a multi-byte character.
|
||||
cut := maxRecorded
|
||||
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||||
cut--
|
||||
}
|
||||
return s[:cut] + "\u2026[cut: longer than a span records]"
|
||||
}
|
||||
|
||||
// renderToolResult turns whatever a tool returned into the text the model reads.
|
||||
// A string is already text; anything else is its JSON, which is the shape the
|
||||
// tool's own schema describes. A value that will not marshal is reported as that
|
||||
// fact rather than as an empty result the model would read as success.
|
||||
func renderToolResult(v any) string {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return truncateToolResult(t)
|
||||
case []byte:
|
||||
return truncateToolResult(string(t))
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "the tool returned a value that could not be encoded"
|
||||
}
|
||||
return truncateToolResult(string(b))
|
||||
}
|
||||
|
||||
// truncateToolResult bounds one tool result and SAYS SO. A silently clipped result is a
|
||||
// result the model believes it read in full.
|
||||
func truncateToolResult(s string) string {
|
||||
if len(s) <= maxToolResult {
|
||||
return s
|
||||
}
|
||||
return s[:maxToolResult] + "\n…[truncated: the result was longer than this agent may read]"
|
||||
}
|
||||
|
||||
// completeWithTools is the loop.
|
||||
//
|
||||
// It runs the conversation forward: complete, run whatever the model asked for,
|
||||
// append the results, complete again — until the model answers in words, the
|
||||
// round budget runs out, or the deadline does. Every completion goes through
|
||||
// completeWithFailover, so the retry-and-fail-over reliability policy the run
|
||||
// path already had applies to EVERY round rather than only the first, and the
|
||||
// model reported back is the one that produced the final answer.
|
||||
//
|
||||
// The last round is offered no tools at all. A cap that simply stopped would
|
||||
// return the model's last tool REQUEST as if it were an answer; offering nothing
|
||||
// forces the model to say what it has, which is a real reply to the person
|
||||
// waiting on it.
|
||||
func completeWithTools(ctx context.Context, ai types.AIClient, org, actor, prompt, model, fallback string, defs []types.ToolDef, runID string) (*types.ChatResponse, string, error, int) {
|
||||
ctx, cancel := context.WithTimeout(ctx, toolRunBudget)
|
||||
defer cancel()
|
||||
|
||||
msgs := []types.ChatMessage{{Role: types.RoleUser, Content: prompt}}
|
||||
used := model
|
||||
calls := 0
|
||||
for round := 0; round <= maxToolRounds; round++ {
|
||||
offer := defs
|
||||
if round == maxToolRounds {
|
||||
offer = nil // budget spent — answer in words
|
||||
}
|
||||
resp, m, err := completeWithFailover(ctx, ai,
|
||||
&types.ChatRequest{Model: model, Org: org, Messages: msgs, Tools: offer, RunID: runID}, fallback)
|
||||
used = m
|
||||
if err != nil {
|
||||
return nil, used, err, calls
|
||||
}
|
||||
if resp == nil || len(resp.ToolCalls) == 0 || offer == nil {
|
||||
return resp, used, nil, calls
|
||||
}
|
||||
msgs = append(msgs, types.ChatMessage{
|
||||
Role: types.RoleAssistant,
|
||||
Content: resp.Content,
|
||||
ToolCalls: resp.ToolCalls,
|
||||
})
|
||||
for _, tc := range resp.ToolCalls {
|
||||
calls++
|
||||
msgs = append(msgs, types.ChatMessage{
|
||||
Role: types.RoleTool,
|
||||
ToolCallID: tc.ID,
|
||||
Name: tc.Name,
|
||||
Content: dispatchOne(ctx, org, actor, tc, runID, round),
|
||||
})
|
||||
}
|
||||
}
|
||||
// Unreachable: the round==maxToolRounds pass returns above whatever the model
|
||||
// does. Stated rather than assumed, so the loop has one exit per outcome.
|
||||
return nil, used, errors.New("agents: tool loop ended without an answer"), calls
|
||||
}
|
||||
|
||||
// dispatchOne runs one tool call and returns the text the model is handed —
|
||||
// SUCCESS OR FAILURE, always as a tool result. A tool that fails is a fact the
|
||||
// model can act on (try another one, or explain), and raising it instead would
|
||||
// throw away a turn the org has already paid for.
|
||||
//
|
||||
// It carries no credential. Tool credentials live in KMS behind the tool plane
|
||||
// and are resolved by the source that owns them, so nothing secret is in scope
|
||||
// here to leak into a transcript: what goes back is the tool's own output or our
|
||||
// own sentence about why there is none.
|
||||
func dispatchOne(ctx context.Context, org, actor string, tc types.ToolCall, runID string, round int) string {
|
||||
ctx, span := agentTracer.Start(ctx, "agent.tool "+tc.Name, trace.WithSpanKind(trace.SpanKindInternal))
|
||||
defer span.End()
|
||||
// Everything an operator needs to read one dispatch out of a run: which run,
|
||||
// which tenant, which person, which tool, which subsystem answers for it, and
|
||||
// where in the loop it happened. The round is what makes "it called six tools
|
||||
// and failed on the fourth" a readable fact rather than an ordering guess
|
||||
// across spans that may be exported out of order.
|
||||
span.SetAttributes(
|
||||
attribute.String("gen_ai.tool.name", tc.Name),
|
||||
attribute.String("gen_ai.tool.call.id", tc.ID),
|
||||
// The tenant under the key the trace plane files rows by (planeOrg,
|
||||
// apps/o11y/planesink.go) — see the note on the run span in agents.go.
|
||||
attribute.String("hanzo.org", org),
|
||||
attribute.String("hanzo.agent.run_id", runID),
|
||||
attribute.String("hanzo.agent.tool_subsystem", toolSubsystem(tc.Name)),
|
||||
attribute.Int("hanzo.agent.tool_round", round),
|
||||
// WHAT it was called with. Without this a trace says an agent called
|
||||
// "post_v1_exec_run" six times and cannot say what it ran — which is the
|
||||
// difference between knowing a run touched a tool and knowing what it did.
|
||||
// The convention's own name for this (opt-in precisely because it can carry
|
||||
// user data), redacted and bounded by recordable.
|
||||
attribute.String("gen_ai.tool.call.arguments", recordable(tc.Arguments)),
|
||||
)
|
||||
if sub := actorSub(org, actor); sub != "" {
|
||||
span.SetAttributes(attribute.String("hanzo.user", sub))
|
||||
}
|
||||
// The outcome is SET on every exit, including the happy one. A span whose
|
||||
// status is only ever written on failure cannot distinguish "succeeded" from
|
||||
// "never finished" — and a tool that hangs until the run's budget expires is
|
||||
// exactly the case an operator is looking for.
|
||||
outcome := "ok"
|
||||
defer func() { span.SetAttributes(attribute.String("hanzo.agent.tool_outcome", outcome)) }()
|
||||
|
||||
if len(tc.Arguments) > maxToolArgs {
|
||||
outcome = "rejected"
|
||||
span.SetStatus(codes.Error, "arguments too large")
|
||||
return "error: the arguments for this call were too large to run"
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(deeper(ctx), toolCallTimeout)
|
||||
defer cancel()
|
||||
|
||||
out, err := runTools.call(ctx, org, actor, tc.Name, tc.Arguments)
|
||||
if err != nil {
|
||||
outcome = "error"
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, "tool call failed")
|
||||
return "error: " + err.Error()
|
||||
}
|
||||
// WHAT came back — the other half of the dispatch, and the half that explains
|
||||
// what the model did next. A tool that "succeeded" while returning an error
|
||||
// document is invisible without it.
|
||||
span.SetAttributes(attribute.String("gen_ai.tool.call.result", recordable(out)))
|
||||
span.SetStatus(codes.Ok, "")
|
||||
if strings.TrimSpace(out) == "" {
|
||||
// An empty result and a failure look identical to a model reading a blank
|
||||
// string, and only one of them means "it worked".
|
||||
return "(the tool ran and returned nothing)"
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud/types"
|
||||
)
|
||||
|
||||
// fakePlane is a deterministic tool plane: it offers exactly what it is given and
|
||||
// records every dispatch, so a test can assert WHAT ran and WHO it ran as.
|
||||
type fakePlane struct {
|
||||
offer []types.ToolDef
|
||||
// calls records (name, args, org, actor) in order.
|
||||
calls []planeCall
|
||||
err error
|
||||
out string
|
||||
}
|
||||
|
||||
type planeCall struct{ name, args, org, actor string }
|
||||
|
||||
func (f *fakePlane) catalog(_ context.Context, org, _ string, want []string) []types.ToolDef {
|
||||
if org == "" || len(want) == 0 {
|
||||
return nil
|
||||
}
|
||||
wanted := map[string]bool{}
|
||||
for _, n := range want {
|
||||
wanted[n] = true
|
||||
}
|
||||
var out []types.ToolDef
|
||||
for _, d := range f.offer {
|
||||
if wanted[d.Name] {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (f *fakePlane) call(_ context.Context, org, actor, name, args string) (string, error) {
|
||||
f.calls = append(f.calls, planeCall{name: name, args: args, org: org, actor: actor})
|
||||
if f.err != nil {
|
||||
return "", f.err
|
||||
}
|
||||
return f.out, nil
|
||||
}
|
||||
|
||||
// withPlane swaps the process tool plane for the duration of one test.
|
||||
func withPlane(t *testing.T, p toolPlane) {
|
||||
t.Helper()
|
||||
prev := runTools
|
||||
runTools = p
|
||||
t.Cleanup(func() { runTools = prev })
|
||||
}
|
||||
|
||||
// scriptAI answers from a script, one entry per completion, and records every
|
||||
// request it was given — which is how a test proves the tools were OFFERED and
|
||||
// the results were fed back.
|
||||
type scriptAI struct {
|
||||
replies []types.ChatResponse
|
||||
seen []types.ChatRequest
|
||||
n int
|
||||
}
|
||||
|
||||
func (s *scriptAI) ChatCompletion(_ context.Context, req *types.ChatRequest) (*types.ChatResponse, error) {
|
||||
cp := *req
|
||||
cp.Messages = append([]types.ChatMessage(nil), req.Messages...)
|
||||
cp.Tools = append([]types.ToolDef(nil), req.Tools...)
|
||||
s.seen = append(s.seen, cp)
|
||||
if s.n >= len(s.replies) {
|
||||
return nil, errors.New("scriptAI: no reply scripted")
|
||||
}
|
||||
r := s.replies[s.n]
|
||||
s.n++
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (s *scriptAI) Embed(_ context.Context, _ *types.EmbedRequest) ([][]float32, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func toolDef(name string) types.ToolDef {
|
||||
return types.ToolDef{Name: name, Description: "d", Schema: json.RawMessage(`{"type":"object"}`)}
|
||||
}
|
||||
|
||||
// A run whose agent declares a tool the plane offers must OFFER it to the model,
|
||||
// EXECUTE what the model asks for, feed the result back, and answer from the
|
||||
// second completion. This is the whole point of the change: before it, Agent.Tools
|
||||
// was never read by the run.
|
||||
func TestRunCallsTools(t *testing.T) {
|
||||
plane := &fakePlane{offer: []types.ToolDef{toolDef("weather")}, out: `{"temp":21}`}
|
||||
withPlane(t, plane)
|
||||
ai := &scriptAI{replies: []types.ChatResponse{
|
||||
{ToolCalls: []types.ToolCall{{ID: "c1", Name: "weather", Arguments: `{"city":"Tokyo"}`}}, FinishReason: "tool_calls"},
|
||||
{Content: "It is 21 degrees in Tokyo."},
|
||||
}}
|
||||
|
||||
a := mk("maxpower", "greeter")
|
||||
a.Tools = []string{"weather"}
|
||||
r := executeRun(context.Background(), ai, "maxpower", "maxpower/u1", a, "weather in Tokyo?", "", "run_test")
|
||||
|
||||
if r.Status != "ok" {
|
||||
t.Fatalf("want ok, got %q err=%q", r.Status, r.Error)
|
||||
}
|
||||
if r.Output != "It is 21 degrees in Tokyo." {
|
||||
t.Fatalf("output must be the model's answer AFTER the tool ran, got %q", r.Output)
|
||||
}
|
||||
if len(plane.calls) != 1 {
|
||||
t.Fatalf("want exactly one dispatch, got %d (%+v)", len(plane.calls), plane.calls)
|
||||
}
|
||||
got := plane.calls[0]
|
||||
if got.name != "weather" || got.args != `{"city":"Tokyo"}` {
|
||||
t.Fatalf("dispatch must carry the model's own call, got %+v", got)
|
||||
}
|
||||
if got.org != "maxpower" || got.actor != "maxpower/u1" {
|
||||
t.Fatalf("dispatch must be attributable to the run's org+actor, got %+v", got)
|
||||
}
|
||||
if len(ai.seen) != 2 {
|
||||
t.Fatalf("want two completions (ask, then answer), got %d", len(ai.seen))
|
||||
}
|
||||
if len(ai.seen[0].Tools) != 1 || ai.seen[0].Tools[0].Name != "weather" {
|
||||
t.Fatalf("the first completion must OFFER the declared tool, got %+v", ai.seen[0].Tools)
|
||||
}
|
||||
// The second completion must carry the whole transcript: the user turn, the
|
||||
// assistant's tool call, and the tool result linked back by id.
|
||||
msgs := ai.seen[1].Messages
|
||||
if len(msgs) != 3 {
|
||||
t.Fatalf("want user+assistant+tool in the second turn, got %d: %+v", len(msgs), msgs)
|
||||
}
|
||||
if msgs[1].Role != types.RoleAssistant || len(msgs[1].ToolCalls) != 1 {
|
||||
t.Fatalf("assistant turn must carry its tool calls, got %+v", msgs[1])
|
||||
}
|
||||
if msgs[2].Role != types.RoleTool || msgs[2].ToolCallID != "c1" || msgs[2].Content != `{"temp":21}` {
|
||||
t.Fatalf("tool result must be linked to the call by id, got %+v", msgs[2])
|
||||
}
|
||||
}
|
||||
|
||||
// An agent with no tools — or one whose declared names the plane does not offer —
|
||||
// must behave EXACTLY as before: one completion, a flat prompt, no tools field.
|
||||
func TestRunWithoutToolsIsUnchanged(t *testing.T) {
|
||||
plane := &fakePlane{} // offers nothing
|
||||
withPlane(t, plane)
|
||||
ai := &scriptAI{replies: []types.ChatResponse{{Content: "hi there"}}}
|
||||
|
||||
a := mk("maxpower", "greeter")
|
||||
a.Instructions = "You are a greeter."
|
||||
a.Tools = []string{"weather"} // declared, but the plane offers nothing
|
||||
r := executeRun(context.Background(), ai, "maxpower", "maxpower/u1", a, "say hi", "", "run_test")
|
||||
|
||||
if r.Status != "ok" || r.Output != "hi there" {
|
||||
t.Fatalf("want the plain completion, got %+v", r)
|
||||
}
|
||||
if len(ai.seen) != 1 {
|
||||
t.Fatalf("want exactly one completion, got %d", len(ai.seen))
|
||||
}
|
||||
if len(ai.seen[0].Tools) != 0 || len(ai.seen[0].Messages) != 0 {
|
||||
t.Fatalf("no-tools path must send the flat prompt and no tools, got %+v", ai.seen[0])
|
||||
}
|
||||
if ai.seen[0].Prompt != "You are a greeter.\n\nsay hi" {
|
||||
t.Fatalf("prompt must compose instructions + input, got %q", ai.seen[0].Prompt)
|
||||
}
|
||||
if len(plane.calls) != 0 {
|
||||
t.Fatalf("nothing may be dispatched, got %+v", plane.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// A tool that fails must NOT kill the turn: the error goes back to the model as
|
||||
// the tool's result, and the model still answers.
|
||||
func TestToolFailureReachesTheModel(t *testing.T) {
|
||||
plane := &fakePlane{offer: []types.ToolDef{toolDef("weather")}, err: errors.New("connector offline")}
|
||||
withPlane(t, plane)
|
||||
ai := &scriptAI{replies: []types.ChatResponse{
|
||||
{ToolCalls: []types.ToolCall{{ID: "c1", Name: "weather", Arguments: `{}`}}},
|
||||
{Content: "I could not reach the weather service."},
|
||||
}}
|
||||
|
||||
a := mk("maxpower", "greeter")
|
||||
a.Tools = []string{"weather"}
|
||||
r := executeRun(context.Background(), ai, "maxpower", "maxpower/u1", a, "weather?", "", "run_test")
|
||||
|
||||
if r.Status != "ok" {
|
||||
t.Fatalf("a failed tool must not fail the run, got %q err=%q", r.Status, r.Error)
|
||||
}
|
||||
if r.Output != "I could not reach the weather service." {
|
||||
t.Fatalf("the model must get to answer, got %q", r.Output)
|
||||
}
|
||||
result := ai.seen[1].Messages[2]
|
||||
if result.Role != types.RoleTool || !strings.Contains(result.Content, "connector offline") {
|
||||
t.Fatalf("the failure must be handed back as the tool result, got %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// The loop is BOUNDED. A model that only ever asks for tools gets maxToolRounds
|
||||
// tool-bearing turns and then one final turn with NO tools, which is what forces
|
||||
// an answer instead of an unbounded spend.
|
||||
func TestToolLoopIsBounded(t *testing.T) {
|
||||
plane := &fakePlane{offer: []types.ToolDef{toolDef("weather")}, out: "ok"}
|
||||
withPlane(t, plane)
|
||||
always := types.ChatResponse{ToolCalls: []types.ToolCall{{ID: "c", Name: "weather", Arguments: `{}`}}}
|
||||
replies := make([]types.ChatResponse, maxToolRounds)
|
||||
for i := range replies {
|
||||
replies[i] = always
|
||||
}
|
||||
// The final, tool-less turn answers in words.
|
||||
replies = append(replies, types.ChatResponse{Content: "done"})
|
||||
ai := &scriptAI{replies: replies}
|
||||
|
||||
a := mk("maxpower", "greeter")
|
||||
a.Tools = []string{"weather"}
|
||||
r := executeRun(context.Background(), ai, "maxpower", "maxpower/u1", a, "go", "", "run_test")
|
||||
|
||||
if r.Status != "ok" || r.Output != "done" {
|
||||
t.Fatalf("bounded loop must still answer, got %+v", r)
|
||||
}
|
||||
if len(ai.seen) != maxToolRounds+1 {
|
||||
t.Fatalf("want %d completions, got %d", maxToolRounds+1, len(ai.seen))
|
||||
}
|
||||
if len(plane.calls) != maxToolRounds {
|
||||
t.Fatalf("want %d dispatches, got %d", maxToolRounds, len(plane.calls))
|
||||
}
|
||||
if len(ai.seen[maxToolRounds].Tools) != 0 {
|
||||
t.Fatalf("the last turn must be offered NO tools so it has to answer in words")
|
||||
}
|
||||
}
|
||||
|
||||
// The dispatch principal is the run's own actor, and a run with no user subject
|
||||
// (a scheduled run) lends none rather than inventing one.
|
||||
func TestActorSub(t *testing.T) {
|
||||
for _, c := range []struct{ org, actor, want string }{
|
||||
{"acme", "acme/U123", "U123"},
|
||||
{"acme", "acme", ""},
|
||||
{"acme", "", ""},
|
||||
{"acme", "scheduler", "scheduler"},
|
||||
} {
|
||||
if got := actorSub(c.org, c.actor); got != c.want {
|
||||
t.Fatalf("actorSub(%q,%q) = %q, want %q", c.org, c.actor, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An agent is itself a tool, so an agent that declares ITSELF would recurse with
|
||||
// a fresh round cap at every level. It is never offered to itself.
|
||||
func TestAgentIsNeverOfferedItself(t *testing.T) {
|
||||
a := mk("maxpower", "greeter")
|
||||
a.Tools = []string{"agent_greeter", "weather"}
|
||||
got := callableTools(a)
|
||||
if len(got) != 1 || got[0] != "weather" {
|
||||
t.Fatalf("an agent must not be offered itself, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A cycle of agents-as-tools (A → B → A) is bounded by DEPTH, which the round cap
|
||||
// cannot bound: each nested run starts its own. At the limit an agent is offered
|
||||
// no tools at all and has to answer for itself.
|
||||
func TestNestedAgentsAreBoundedByDepth(t *testing.T) {
|
||||
plane := &fakePlane{offer: []types.ToolDef{toolDef("weather")}, out: "ok"}
|
||||
withPlane(t, plane)
|
||||
ai := &scriptAI{replies: []types.ChatResponse{{Content: "at the bottom"}}}
|
||||
|
||||
ctx := context.Background()
|
||||
for i := 0; i < maxAgentDepth; i++ {
|
||||
ctx = deeper(ctx)
|
||||
}
|
||||
a := mk("maxpower", "greeter")
|
||||
a.Tools = []string{"weather"}
|
||||
r := executeRun(ctx, ai, "maxpower", "maxpower/u1", a, "go", "", "run_test")
|
||||
|
||||
if r.Status != "ok" || r.Output != "at the bottom" {
|
||||
t.Fatalf("a run at the depth limit must still answer, got %+v", r)
|
||||
}
|
||||
if len(ai.seen) != 1 || len(ai.seen[0].Tools) != 0 {
|
||||
t.Fatalf("at the depth limit no tools may be offered, got %d completions %+v", len(ai.seen), ai.seen[0].Tools)
|
||||
}
|
||||
if len(plane.calls) != 0 {
|
||||
t.Fatalf("nothing may be dispatched at the depth limit, got %+v", plane.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// A dispatch carries the run one level deeper, which is what makes the depth
|
||||
// bound reachable at all: the nested run reads it off the context it was handed.
|
||||
func TestDispatchDeepensTheContext(t *testing.T) {
|
||||
var saw int
|
||||
withPlane(t, &depthProbe{seen: &saw, offer: []types.ToolDef{toolDef("weather")}})
|
||||
ai := &scriptAI{replies: []types.ChatResponse{
|
||||
{ToolCalls: []types.ToolCall{{ID: "c1", Name: "weather", Arguments: `{}`}}},
|
||||
{Content: "done"},
|
||||
}}
|
||||
a := mk("maxpower", "greeter")
|
||||
a.Tools = []string{"weather"}
|
||||
if r := executeRun(context.Background(), ai, "maxpower", "maxpower/u1", a, "go", "", "run_test"); r.Status != "ok" {
|
||||
t.Fatalf("run failed: %+v", r)
|
||||
}
|
||||
if saw != 1 {
|
||||
t.Fatalf("a dispatch from a top-level run must be at depth 1, got %d", saw)
|
||||
}
|
||||
}
|
||||
|
||||
// depthProbe records the nesting depth the dispatch context carries.
|
||||
type depthProbe struct {
|
||||
seen *int
|
||||
offer []types.ToolDef
|
||||
}
|
||||
|
||||
func (d *depthProbe) catalog(_ context.Context, org, _ string, want []string) []types.ToolDef {
|
||||
if org == "" || len(want) == 0 {
|
||||
return nil
|
||||
}
|
||||
return d.offer
|
||||
}
|
||||
|
||||
func (d *depthProbe) call(ctx context.Context, _, _, _, _ string) (string, error) {
|
||||
*d.seen = agentDepth(ctx)
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
// A tool result longer than the transcript budget is clipped AND SAID to be
|
||||
// clipped — a silently truncated result is one the model believes it read whole.
|
||||
func TestToolResultTruncationIsStated(t *testing.T) {
|
||||
long := strings.Repeat("x", maxToolResult+100)
|
||||
got := renderToolResult(long)
|
||||
if len(got) <= maxToolResult || !strings.Contains(got, "truncated") {
|
||||
t.Fatalf("a clipped result must say so, got %d bytes", len(got))
|
||||
}
|
||||
if s := renderToolResult(map[string]any{"a": 1}); s != `{"a":1}` {
|
||||
t.Fatalf("a structured result must reach the model as its JSON, got %q", s)
|
||||
}
|
||||
}
|
||||
@@ -34,16 +34,18 @@ func init() {
|
||||
zip.Describe("GET /v1/agents/:ref", zip.Doc{
|
||||
Description: "Returns one agent with its system prompt and its 20 most recent runs.\nThe ref is the agent's public id or its org-unique name — a created agent is\nimmediately gettable by whatever create handed back.",
|
||||
Fields: map[string]string{
|
||||
"agentRef.ref": "Ref is the agent's public id (the agent_… handle create and list return) or\nits org-unique name, from the path. Either resolves the same agent.",
|
||||
"agentRef.ref": "Ref is the agent's public id (the agent_… handle create and list return) or\nits org-unique name, from the path. Either resolves the same agent.",
|
||||
"agentRunView.agent": "What an operator needs to answer \"what ran, for whom, and what did it do\" —\nand, through traceId, to leave this record for the waterfall of the very\nsame run rather than a search that hopefully lands near it.\n\nAgent is on the row because the org-wide feed lists runs across agents, and\na run that cannot name its agent is an orphan in exactly the view built to\nmake sense of many of them. Every field is omitempty: a run recorded before\nthese columns existed reports absence rather than a zero it never measured.",
|
||||
},
|
||||
Example: json.RawMessage(`{"ref":"helper"}`),
|
||||
})
|
||||
zip.Describe("GET /v1/agents/:ref/runs", zip.Doc{
|
||||
Description: "Returns one agent's execution history, newest first — each run's\ninput, its output or its error, and how long it took. Every row is a run that\nactually happened.",
|
||||
Fields: map[string]string{
|
||||
"runList.runs": "Runs is the agent's executions, newest first.",
|
||||
"runsQuery.limit": "Limit caps how many runs come back, newest first. Absent, zero or out of\nrange (1..200) reads as 50.",
|
||||
"runsQuery.ref": "Ref is the agent's public id or its org-unique name, from the path.",
|
||||
"agentRunView.agent": "What an operator needs to answer \"what ran, for whom, and what did it do\" —\nand, through traceId, to leave this record for the waterfall of the very\nsame run rather than a search that hopefully lands near it.\n\nAgent is on the row because the org-wide feed lists runs across agents, and\na run that cannot name its agent is an orphan in exactly the view built to\nmake sense of many of them. Every field is omitempty: a run recorded before\nthese columns existed reports absence rather than a zero it never measured.",
|
||||
"runList.runs": "Runs is the agent's executions, newest first.",
|
||||
"runsQuery.limit": "Limit caps how many runs come back, newest first. Absent, zero or out of\nrange (1..200) reads as 50.",
|
||||
"runsQuery.ref": "Ref is the agent's public id or its org-unique name, from the path.",
|
||||
},
|
||||
Example: json.RawMessage(`{"ref":"helper","limit":20}`),
|
||||
})
|
||||
@@ -84,6 +86,16 @@ func init() {
|
||||
},
|
||||
Example: json.RawMessage(`{"range":"7D"}`),
|
||||
})
|
||||
zip.Describe("GET /v1/agents/runs", zip.Doc{
|
||||
Description: "Returns the org's agent runs across EVERY agent, newest first —\nwhat ran here, for whom, on which model, how long it took, and why it failed.\n\nIt is the feed the per-agent history could not be: an operator asking \"what is\nthis tenant's agent plane doing\" does not start out knowing an agent ref, and\nanswering by listing the agents and then paging each one's history is N+1 round\ntrips to reconstruct one ordering the database already has (RunsSince, ordered\nby created_at over the org index).\n\nThe org is the CALLER's, resolved from identity by tenantStore — never a\nparameter. There is deliberately no org field on orgRunsQuery to forge: run\nhistory is the tenant's own record, and the only tenant this can answer for is\nthe one asking.",
|
||||
Fields: map[string]string{
|
||||
"agentRunView.agent": "What an operator needs to answer \"what ran, for whom, and what did it do\" —\nand, through traceId, to leave this record for the waterfall of the very\nsame run rather than a search that hopefully lands near it.\n\nAgent is on the row because the org-wide feed lists runs across agents, and\na run that cannot name its agent is an orphan in exactly the view built to\nmake sense of many of them. Every field is omitempty: a run recorded before\nthese columns existed reports absence rather than a zero it never measured.",
|
||||
"orgRunsQuery.limit": "Limit caps how many runs come back, newest first. Absent, zero or out of\nrange (1..200) reads as 50.",
|
||||
"orgRunsQuery.status": "Status keeps only runs with this outcome (\"ok\" or \"error\"). Empty keeps\nboth. It is the filter an operator reaches for first — \"show me what broke\"\n— and answering it here rather than by paging the whole history client-side\nis the difference between a usable feed and a download.",
|
||||
"runList.runs": "Runs is the agent's executions, newest first.",
|
||||
},
|
||||
Example: json.RawMessage(`{"limit":20,"status":"error"}`),
|
||||
})
|
||||
zip.Describe("GET /v1/agents/sessions", zip.Doc{
|
||||
Description: "Returns the caller org's live sessions, newest first — each with\nits event count, its direct-child count and a one-line preview of its latest\nevent. With no filter it returns ROOT sessions only, so a dashboard shows one\nrow per flow rather than one per subagent; ?root= or ?parent= descends.",
|
||||
Fields: map[string]string{
|
||||
@@ -219,6 +231,7 @@ func init() {
|
||||
Description: "Answers a bridge's turn.\n\nUnlike the session ops, the org travels IN the request rather than being taken\nfrom the caller's plane identity: the tenant here is the one that connected the\nSlack workspace, resolved by the bridge from the signed team_id, and the bridge\nplugin's own identity is not it. That is safe because this op only SPENDS the\nnamed org's own balance under its own agent — it reads nothing across tenants —\nand because the subject must be a link the bridge already proved.\n\nAn empty subject is refused rather than defaulted. A turn that lost its caller\nmust not run AS THE ORG: that would bill the tenant for an unattributable act\nand hand an unlinked user the org's agent.",
|
||||
Fields: map[string]string{
|
||||
"RunOnBehalfIn.input": "Input is the user's message, already stripped of the leading @mention.",
|
||||
"RunOnBehalfIn.model": "Model is the ASKER's own choice, empty when they have not made one. It is a\npreference of the person, not a property of the agent, which is why it rides\nthe turn instead of being written into an agent row: two people in one\nworkspace can prefer different models of the same assistant.",
|
||||
"RunOnBehalfIn.org": "Org is the isolation gate, the tenant, and the balance the run bills.",
|
||||
"RunOnBehalfIn.ref": "Ref names the agent to run.",
|
||||
"RunOnBehalfIn.subject": "Subject is the caller's LINKED Hanzo identity, unqualified. Attribution and\nauthorization both hang off it, so a turn can never run as nobody: the\nanswering side refuses an empty subject rather than falling back to the org.",
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package ai
|
||||
|
||||
// THE PUBLIC CONTRACT, v1: inference.
|
||||
//
|
||||
// Every line below puts one operation into the published SDKs, the public CLI,
|
||||
// the MCP tool list and docs.hanzo.ai. Nothing else in the fleet's 1782 paths is
|
||||
// there, because the projection is default-deny and this is the only file in the
|
||||
// repository that has said anything (openapi/public.go).
|
||||
//
|
||||
// # The rule that earns a line
|
||||
//
|
||||
// AN OPERATION IS IN v1 IF IT IS A MODEL CALL, OR IF IT IS THE CATALOG OF MODELS
|
||||
// TO CALL. That is the whole of it, and everything below is one of the two. It is
|
||||
// NOT "everything hanzoai/ai serves": that module also answers 109 paths under
|
||||
// /v1/ai/* — articles, assets, deployments, connections, dashboards — which are
|
||||
// the app builder's CRUD and not inference, and which the rule therefore leaves
|
||||
// out without anyone having to name them.
|
||||
//
|
||||
// # What is deliberately NOT here
|
||||
//
|
||||
// Each of these was measured and left out, and each is one line away if that is
|
||||
// wrong. Default-deny means the cost of leaving something out is that somebody
|
||||
// asks for it, and the cost of leaving something in is that we support it
|
||||
// forever.
|
||||
//
|
||||
// POST /v1/chat a second spelling of /v1/chat/completions,
|
||||
// down to the same summary. One operation
|
||||
// has one address in a published contract;
|
||||
// two would be two SDK methods and two CLI
|
||||
// verbs for one call. The right fix is for
|
||||
// hanzoai/ai to tag it openapi.Compat.
|
||||
// POST /v1/generate-text-to-speech-audio the inherited casdoor-shaped
|
||||
// GET /v1/generate-text-to-speech-audio-stream spellings of /v1/audio/speech.
|
||||
// Same call, older name; same answer.
|
||||
// GET /v1/models/{model}/access entitlement, not inference — the waitlist
|
||||
// POST /v1/models/{model}/access standing for a gated model. It is console
|
||||
// workflow, and a customer reaches it in the
|
||||
// console. Add it the day an SDK needs it.
|
||||
//
|
||||
// # Adding the next product
|
||||
//
|
||||
// One line per operation, in the app that serves it, in a file named public.go
|
||||
// beside the routes. Then `make describe`, which regenerates public.yaml, and the
|
||||
// diff shows exactly what was published next to the line that published it.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/hanzoai/cloud/openapi"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// THE CATALOG — what can be called, and by whom. Both are unauthenticated and
|
||||
// secret-free by their own declaration; a client that cannot read the catalog
|
||||
// before it holds a credential cannot show a model picker.
|
||||
openapi.Public("/v1/models", http.MethodGet)
|
||||
openapi.Public("/v1/models/providers", http.MethodGet)
|
||||
|
||||
// TEXT. Four wire formats over one router: OpenAI's chat and legacy
|
||||
// completions, OpenAI's Responses, and Anthropic's Messages. They are not
|
||||
// duplicates of each other — a caller picks the one its SDK already speaks,
|
||||
// which is the entire reason all four are served — and count_tokens is part of
|
||||
// the Messages contract rather than an operation of its own (Claude Code calls
|
||||
// it before every request).
|
||||
openapi.Public("/v1/chat/completions", http.MethodPost)
|
||||
openapi.Public("/v1/completions", http.MethodPost)
|
||||
openapi.Public("/v1/responses", http.MethodPost)
|
||||
openapi.Public("/v1/messages", http.MethodPost)
|
||||
openapi.Public("/v1/messages/count_tokens", http.MethodPost)
|
||||
|
||||
// VECTORS — the two operations that turn text into numbers and numbers back
|
||||
// into an order. zen-rerank is a first-party model and /v1/rerank is its door.
|
||||
openapi.Public("/v1/embeddings", http.MethodPost)
|
||||
openapi.Public("/v1/rerank", http.MethodPost)
|
||||
|
||||
// MEDIA. zen-image, zen-video and zen-music are first-party models on the live
|
||||
// catalog, and each has its own door — they are NOT reachable through
|
||||
// /v1/chat/completions, so publishing the text surface alone would ship a
|
||||
// catalog listing models no generated client can call.
|
||||
openapi.Public("/v1/images/generations", http.MethodPost)
|
||||
openapi.Public("/v1/audio/speech", http.MethodPost)
|
||||
openapi.Public("/v1/audio/transcriptions", http.MethodPost)
|
||||
openapi.Public("/v1/audio/voice", http.MethodPost)
|
||||
openapi.Public("/v1/audio/music", http.MethodPost)
|
||||
openapi.Public("/v1/audio/foley", http.MethodPost)
|
||||
|
||||
// Video generation is ASYNC — create returns a job, and the client polls it and
|
||||
// then downloads the result. All three are the one operation from a caller's
|
||||
// side, so publishing the create alone would publish a call whose answer
|
||||
// nothing in the SDK can resolve.
|
||||
openapi.Public("/v1/videos/generations", http.MethodPost)
|
||||
openapi.Public("/v1/videos/{id}", http.MethodGet)
|
||||
openapi.Public("/v1/videos/{id}/content", http.MethodGet)
|
||||
}
|
||||
+321
-18
@@ -17,8 +17,8 @@
|
||||
// lands in event.fact under its own signal; nothing here writes storage.
|
||||
// This file used to sit beside a second storage write (the wide hanzo.events INSERT)
|
||||
// and hand the plane its only copy of each batch; that double-write is gone — the
|
||||
// fact publish IS the commit — and what remains here are the two SUBSCRIBER
|
||||
// hand-offs an accepted batch still owes:
|
||||
// fact publish IS the commit — and what remains here are the SUBSCRIBER hand-offs an
|
||||
// accepted batch still owes:
|
||||
//
|
||||
// - the ENVELOPE onto the plane (PublishEvents, bus.go) — the webhook-delivery
|
||||
// contract (apps/webhooks): orgs subscribe to event.<folded name> subjects and
|
||||
@@ -28,23 +28,43 @@
|
||||
// - a COPY-taking downstream SINK — the destinations subsystem — which translates
|
||||
// and forwards each event to the org's connected ad/analytics platforms (GA4, Meta
|
||||
// CAPI, …).
|
||||
// - the ERROR sinks: the error-signal slice of the batch, handed to consumers that
|
||||
// project a failure onto another surface. apps/o11y installs one (errorsink.go)
|
||||
// that lands each error on the embedded Sentry plane, so a /v1/event error
|
||||
// surfaces on sentry.hanzo.ai beside the errors a Sentry SDK posts directly.
|
||||
// - the SPAN sinks: the span-signal slice, on identical terms. apps/o11y installs one
|
||||
// (spansink.go) that lands each LLM-shaped span on event.span, so a /v1/event span
|
||||
// surfaces in the LLM views (GET /v1/o11y/llm/observations, /llm/traces, and the
|
||||
// eval board) beside the gen_ai spans the ai emit path sends over the ZAP wire.
|
||||
//
|
||||
// The seam is:
|
||||
//
|
||||
// - ONE-WAY. analytics never imports its consumers; a sink (destinations) calls
|
||||
// AddSink from its own Mount. No sinks means no sink fan-out, so this file changes
|
||||
// nothing about ingest when they are absent.
|
||||
// - RAW. The sink receives the event BEFORE the warehouse privacy scrub, because a
|
||||
// server-side Conversions-API forwarder must hash the match keys (email/phone/
|
||||
// click ids) the warehouse deliberately drops. The org connected the destination
|
||||
// and owns that consent; the destination adapters SHA-256 every PII field before
|
||||
// it leaves the process.
|
||||
// - FAIL-SOFT. The sink runs detached (a panic-guarded goroutine) so a slow or
|
||||
// broken destination can never block, fail, or crash an ingest.
|
||||
// - ONE-WAY. analytics never imports its consumers; a sink (destinations, o11y)
|
||||
// calls AddSink / AddErrorSink / AddSpanSink from its own Mount. No sinks means no
|
||||
// sink fan-out, so this file changes nothing about ingest when they are absent.
|
||||
// - RAW, WHERE THE CONSUMER FORWARDS. The destination sink receives the event BEFORE
|
||||
// the warehouse privacy scrub, because a server-side Conversions-API forwarder must
|
||||
// hash the match keys (email/phone/click ids) the warehouse deliberately drops. The
|
||||
// org connected the destination and owns that consent; the destination adapters
|
||||
// SHA-256 every PII field before it leaves the process. The exception text an error
|
||||
// sink reads is already the folded copy foldException scrubbed, and the Sentry
|
||||
// normalizer scrubs again on its side.
|
||||
// - SCRUBBED, WHERE THE CONSUMER STORES. The span slice carries the same scrubMap copy
|
||||
// the write core stores in the fact's attributes, because its consumer writes a ROW:
|
||||
// scrubText's whole contract is that a token in a property is redacted before
|
||||
// storage, and a projection that stored more than the plane stores would be a second
|
||||
// copy of the batch under a weaker rule.
|
||||
// - FAIL-SOFT. Each sink runs detached (a panic-guarded goroutine) so a slow or
|
||||
// broken consumer can never block, fail, or crash an ingest.
|
||||
// - ADDITIVE. A projection failure is invisible to the ingest — the fact publish
|
||||
// already committed and the honest receipt already returned.
|
||||
|
||||
package analytics
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SinkEvent is one accepted event handed to the downstream fan-out. It carries the
|
||||
// resolved canonical name plus the commerce + identity fields a conversion needs;
|
||||
@@ -83,15 +103,100 @@ func AddSink(fn func(org string, evs []SinkEvent)) (remove func()) {
|
||||
return func() { sinks[i] = nil }
|
||||
}
|
||||
|
||||
// fanOut hands the accepted batch to the sink, detached and fail-soft. org is the
|
||||
// SERVER-resolved tenant (already an owned copy from principal.Org). It builds
|
||||
// SinkEvents from the RAW events (skipping unroutable ones, mirroring the write
|
||||
// core's drop rule) and, if any remain and a sink is installed, dispatches them on a
|
||||
// panic-guarded goroutine so ingest is never blocked or failed by a destination.
|
||||
// ErrorEvent is one accepted error occurrence handed to the error fan-out. It is a
|
||||
// carrier of exactly the fields an error projection needs, so analytics stays
|
||||
// orthogonal to its consumers — apps/o11y builds the Sentry wire event on its side.
|
||||
// The exception text is the folded, scrubbed copy (foldException); the tenant is the
|
||||
// org argument to the sink, never a field here.
|
||||
type ErrorEvent struct {
|
||||
MessageID string // client idempotency id / minted; becomes the projection's event id
|
||||
Time time.Time
|
||||
ExceptionType string // e.g. "TypeError"; "" ⇒ the consumer groups on the message
|
||||
Message string // the exception message (the grouping value)
|
||||
Stack string // raw client stack string, when the wire carried one
|
||||
Handled *bool // whether the app caught it (nil ⇒ unknown)
|
||||
Level string // "error" for these events
|
||||
Platform string // e.g. "javascript" (properties.$platform; descriptive)
|
||||
Release string // build the error fired in
|
||||
Environment string // deployment the error fired in
|
||||
Transaction string // the route the error fired on (path, else url)
|
||||
URL string
|
||||
Path string
|
||||
DistinctID string // the reporting visitor (user id — never PII)
|
||||
SessionID string
|
||||
Product string // emitting surface: console|chat|app|site|admin
|
||||
Site string // deployed property the error came from
|
||||
Service string // emitting service, when the wire named one
|
||||
Library string
|
||||
TraceID string // trace linkage
|
||||
SpanID string
|
||||
}
|
||||
|
||||
// errorSinks are the error fan-out consumers, on the same terms as sinks: registered
|
||||
// at Mount, package-global, each dispatch detached and panic-guarded.
|
||||
var errorSinks []func(org string, errs []ErrorEvent)
|
||||
|
||||
// AddErrorSink registers an error fan-out consumer and returns its remover.
|
||||
func AddErrorSink(fn func(org string, errs []ErrorEvent)) (remove func()) {
|
||||
i := len(errorSinks)
|
||||
errorSinks = append(errorSinks, fn)
|
||||
return func() { errorSinks[i] = nil }
|
||||
}
|
||||
|
||||
// SpanEvent is one accepted span handed to the span fan-out. It is a carrier of exactly
|
||||
// the fields a span projection needs, so analytics stays orthogonal to its consumers —
|
||||
// apps/o11y decides on its side which of these spans are LLM calls and what a row of
|
||||
// event.span looks like. Properties is where a span states its OTel semantic attributes
|
||||
// (gen_ai.*), carried as the SCRUBBED copy the fact row stores (see the header); the
|
||||
// tenant is the org argument to the sink, never a field here.
|
||||
type SpanEvent struct {
|
||||
MessageID string // client idempotency id / minted; the fallback row identity
|
||||
Time time.Time
|
||||
Name string // the span's name, resolved by the plane's own rule
|
||||
Kind string // client|server|producer|consumer|internal
|
||||
Status string // how the span ended: ok|error|unset, lowercased
|
||||
Duration uint64 // elapsed nanoseconds
|
||||
TraceID string // the trace this span belongs to
|
||||
SpanID string // this span's own id
|
||||
Parent string // the enclosing span's id, empty for a root span
|
||||
Service string // emitting service, when the wire named one
|
||||
Product string // emitting surface: console|chat|app|site|admin
|
||||
Site string // deployed property the span came from
|
||||
Release string // build the span was recorded in
|
||||
Environment string // deployment the span was recorded in
|
||||
DistinctID string // the reporting visitor (user id — never PII)
|
||||
SessionID string
|
||||
Properties map[string]any // RAW; where gen_ai.* semantic attributes travel
|
||||
}
|
||||
|
||||
// spanSinks are the span fan-out consumers, on the same terms as sinks and errorSinks:
|
||||
// registered at Mount, package-global, each dispatch detached and panic-guarded.
|
||||
var spanSinks []func(org string, spans []SpanEvent)
|
||||
|
||||
// AddSpanSink registers a span fan-out consumer and returns its remover.
|
||||
func AddSpanSink(fn func(org string, spans []SpanEvent)) (remove func()) {
|
||||
i := len(spanSinks)
|
||||
spanSinks = append(spanSinks, fn)
|
||||
return func() { spanSinks[i] = nil }
|
||||
}
|
||||
|
||||
// fanOut hands the accepted batch to the plane envelope and to every installed sink,
|
||||
// each detached and fail-soft. org is the SERVER-resolved tenant (already an owned
|
||||
// copy from principal.Org). One call site — the ingestEvents tail.
|
||||
func fanOut(org string, evs []CaptureEvent) {
|
||||
if len(evs) == 0 {
|
||||
return
|
||||
}
|
||||
fanOutEvents(org, evs)
|
||||
fanOutErrors(org, evs)
|
||||
fanOutSpans(org, evs)
|
||||
}
|
||||
|
||||
// fanOutEvents builds SinkEvents from the RAW events (skipping unroutable ones,
|
||||
// mirroring the write core's drop rule), publishes the envelope onto the plane, and
|
||||
// dispatches each installed sink on a panic-guarded goroutine so ingest is never
|
||||
// blocked or failed by a consumer.
|
||||
func fanOutEvents(org string, evs []CaptureEvent) {
|
||||
live := make([]func(string, []SinkEvent), 0, len(sinks))
|
||||
for _, fn := range sinks {
|
||||
if fn != nil {
|
||||
@@ -139,3 +244,201 @@ func fanOut(org string, evs []CaptureEvent) {
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// fanOutErrors filters the batch to the events the plane routes as the error signal —
|
||||
// routeOf is the ONE routing rule, so the projection can never carry a fact the plane
|
||||
// filed as something else — builds ErrorEvents, and dispatches each installed error
|
||||
// sink on a panic-guarded goroutine. Non-error events (the overwhelming majority) are
|
||||
// skipped, so a normal batch never touches this path.
|
||||
func fanOutErrors(org string, evs []CaptureEvent) {
|
||||
live := make([]func(string, []ErrorEvent), 0, len(errorSinks))
|
||||
for _, fn := range errorSinks {
|
||||
if fn != nil {
|
||||
live = append(live, fn)
|
||||
}
|
||||
}
|
||||
if len(live) == 0 {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
out := make([]ErrorEvent, 0)
|
||||
for _, e := range evs {
|
||||
if routeOf(e).signal != signalError {
|
||||
continue
|
||||
}
|
||||
typ, msg, stack, handled := exceptionOf(e)
|
||||
out = append(out, ErrorEvent{
|
||||
MessageID: firstNonEmptyStr(trim(e.MessageID), randID()),
|
||||
Time: clampTS(e.Timestamp, now),
|
||||
ExceptionType: trim(typ),
|
||||
Message: trim(msg),
|
||||
Stack: stack,
|
||||
Handled: handled,
|
||||
Level: "error",
|
||||
Platform: propStr(e.Properties, "$platform"),
|
||||
Release: firstNonEmptyStr(trim(e.Release), propStr(e.Properties, "$release")),
|
||||
Environment: firstNonEmptyStr(trim(e.Environment), propStr(e.Properties, "$environment")),
|
||||
Transaction: firstNonEmptyStr(trim(e.Path), trim(e.URL)),
|
||||
URL: trim(e.URL),
|
||||
Path: trim(e.Path),
|
||||
DistinctID: trim(e.DistinctID),
|
||||
SessionID: trim(e.SessionID),
|
||||
Product: trim(e.Product),
|
||||
Site: trim(e.Site),
|
||||
Service: trim(e.Service),
|
||||
Library: trim(e.Library),
|
||||
TraceID: firstNonEmptyStr(trim(e.TraceID), propStr(e.Properties, "$trace_id")),
|
||||
SpanID: firstNonEmptyStr(trim(e.SpanID), propStr(e.Properties, "$span_id")),
|
||||
})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return
|
||||
}
|
||||
for _, fn := range live {
|
||||
fn := fn
|
||||
go func() {
|
||||
defer func() { _ = recover() }()
|
||||
fn(org, out)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// fanOutSpans filters the batch to the events the plane routes as the span signal —
|
||||
// routeOf is the ONE routing rule, the same pin fanOutErrors holds, so a projection can
|
||||
// never carry a fact the plane filed as something else — builds SpanEvents, and
|
||||
// dispatches each installed span sink on a panic-guarded goroutine. Non-span events
|
||||
// (the overwhelming majority) are skipped, so a normal batch never touches this path.
|
||||
func fanOutSpans(org string, evs []CaptureEvent) {
|
||||
live := make([]func(string, []SpanEvent), 0, len(spanSinks))
|
||||
for _, fn := range spanSinks {
|
||||
if fn != nil {
|
||||
live = append(live, fn)
|
||||
}
|
||||
}
|
||||
if len(live) == 0 {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
out := make([]SpanEvent, 0)
|
||||
for _, e := range evs {
|
||||
r := routeOf(e)
|
||||
if r.signal != signalSpan {
|
||||
continue
|
||||
}
|
||||
b := spanBodyOf(e)
|
||||
// The SCRUB the write core applies before it stores a fact's attributes, applied
|
||||
// once here: the projection reads its property fallbacks out of the very map it
|
||||
// hands the consumer, so what a row carries and what a fallback saw are the same
|
||||
// values.
|
||||
props := scrubMap(e.Properties)
|
||||
// The plane's OWN precedence, restated (applySpan, fact.go): the route's default
|
||||
// kind stands unless the envelope names one, and the span BODY refines both —
|
||||
// a client emitting a span knows its role precisely.
|
||||
kind := firstNonEmptyStr(trim(e.Kind), r.kind)
|
||||
if k := trim(b.Kind); k != "" {
|
||||
kind = k
|
||||
}
|
||||
out = append(out, SpanEvent{
|
||||
MessageID: firstNonEmptyStr(trim(e.MessageID), randID()),
|
||||
Time: clampTS(e.Timestamp, now),
|
||||
Name: resolveName(r, e),
|
||||
Kind: strings.ToLower(kind),
|
||||
Status: strings.ToLower(trim(b.Status)),
|
||||
Duration: b.Duration,
|
||||
// The first-class envelope ids win over the body's — the SAME order the fact
|
||||
// row resolves them in (the envelope fills trace/span at build, applySpan
|
||||
// fills only what is still empty), so the projected span and the fact carry
|
||||
// one identity rather than two. The legacy $ spellings are the last resort
|
||||
// for a client that has not moved to the first-class field, exactly as the
|
||||
// error slice above reads them.
|
||||
TraceID: firstNonEmptyStr(firstNonEmptyStr(trim(e.TraceID), trim(b.Trace)), propStr(props, "$trace_id")),
|
||||
SpanID: firstNonEmptyStr(firstNonEmptyStr(trim(e.SpanID), trim(b.ID)), propStr(props, "$span_id")),
|
||||
Parent: trim(b.Parent),
|
||||
Service: trim(e.Service),
|
||||
Product: trim(e.Product),
|
||||
Site: trim(e.Site),
|
||||
Release: firstNonEmptyStr(trim(e.Release), propStr(props, "$release")),
|
||||
Environment: firstNonEmptyStr(trim(e.Environment), propStr(props, "$environment")),
|
||||
DistinctID: trim(e.DistinctID),
|
||||
SessionID: trim(e.SessionID),
|
||||
Properties: props,
|
||||
})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return
|
||||
}
|
||||
for _, fn := range live {
|
||||
fn := fn
|
||||
go func() {
|
||||
defer func() { _ = recover() }()
|
||||
fn(org, out)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// spanBodyOf returns the event's span body, or the ZERO body when the wire carried
|
||||
// none. A span-typed event without a body is still a span — applySpan stores it and the
|
||||
// fact lands — and the zero value states exactly the "nothing further known" that row
|
||||
// records, so the projection reads one shape instead of branching on a pointer.
|
||||
func spanBodyOf(e CaptureEvent) SpanBody {
|
||||
if e.Span == nil {
|
||||
return SpanBody{}
|
||||
}
|
||||
return *e.Span
|
||||
}
|
||||
|
||||
// exceptionOf extracts the exception's (type, message, stack, handled) from whichever
|
||||
// carrier holds it: the typed Error (folded events carry it), or properties.$exception
|
||||
// as either the typed *Exception (post-fold, same process) or a decoded map (from the
|
||||
// JSON wire). Returns zero values for an error event that carries no exception — a
|
||||
// bare error-typed event, which the consumer then groups on its message/transaction.
|
||||
func exceptionOf(e CaptureEvent) (typ, message, stack string, handled *bool) {
|
||||
if e.Error != nil {
|
||||
return e.Error.Type, e.Error.Message, e.Error.Stack, e.Error.Handled
|
||||
}
|
||||
raw, ok := e.Properties["$exception"]
|
||||
if !ok {
|
||||
return "", "", "", nil
|
||||
}
|
||||
switch x := raw.(type) {
|
||||
case *Exception:
|
||||
if x == nil {
|
||||
return "", "", "", nil
|
||||
}
|
||||
return x.Type, x.Message, x.Stack, x.Handled
|
||||
case Exception:
|
||||
return x.Type, x.Message, x.Stack, x.Handled
|
||||
case map[string]any:
|
||||
return mapStr(x, "type"), mapStr(x, "message"), mapStr(x, "stack"), mapBool(x, "handled")
|
||||
default:
|
||||
return "", "", "", nil
|
||||
}
|
||||
}
|
||||
|
||||
// propStr reads a string-valued property, "" when absent or non-string. The ingest
|
||||
// never trusts these for tenancy — they are descriptive only.
|
||||
func propStr(p map[string]any, key string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := p[key].(string); ok {
|
||||
return trim(v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// mapStr reads a string value from a decoded exception map.
|
||||
func mapStr(m map[string]any, key string) string {
|
||||
if v, ok := m[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// mapBool reads a *bool from a decoded exception map (JSON bools decode to bool).
|
||||
func mapBool(m map[string]any, key string) *bool {
|
||||
if v, ok := m[key].(bool); ok {
|
||||
return &v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// collectErrors installs an error sink that forwards the batch for one org onto a
|
||||
// channel, and returns the channel + the sink's remover. Mirrors forward_test's
|
||||
// SinkEvent probe.
|
||||
func collectErrors(t *testing.T, wantOrg string) (<-chan []ErrorEvent, func()) {
|
||||
t.Helper()
|
||||
got := make(chan []ErrorEvent, 1)
|
||||
remove := AddErrorSink(func(org string, errs []ErrorEvent) {
|
||||
if org == wantOrg {
|
||||
got <- errs
|
||||
}
|
||||
})
|
||||
return got, remove
|
||||
}
|
||||
|
||||
// TestFanOutErrorsFoldedException verifies the primary /v1/event path: an event whose
|
||||
// top-level error was folded (foldException — Type defaulted to "error", the scrubbed
|
||||
// exception copied into properties.$exception) is routed to the error sink with its
|
||||
// exception fields + identity/context carried.
|
||||
func TestFanOutErrorsFoldedException(t *testing.T) {
|
||||
got, done := collectErrors(t, "acme")
|
||||
defer done()
|
||||
|
||||
handled := false
|
||||
// foldException runs in ingestDecoded before the write core; replicate it here so
|
||||
// the fan-out sees exactly what production hands it.
|
||||
folded := foldException(CaptureEvent{
|
||||
Error: &Exception{Type: "TypeError", Message: "x is not a function", Stack: "at f (app.js:1:1)", Handled: &handled},
|
||||
DistinctID: "u1", SessionID: "s1", Path: "/checkout", URL: "https://acme.ai/checkout",
|
||||
Product: "app", Library: "@hanzo/event",
|
||||
Properties: map[string]any{"$release": "v2", "$trace_id": "abc", "keep": "me"},
|
||||
})
|
||||
|
||||
fanOut("acme", []CaptureEvent{
|
||||
folded,
|
||||
{Type: "pageview"}, // not an error — must be skipped
|
||||
{Type: "event", Event: "click"}, // not an error — must be skipped
|
||||
})
|
||||
|
||||
select {
|
||||
case errs := <-got:
|
||||
if len(errs) != 1 {
|
||||
t.Fatalf("want 1 error event (pageview+click skipped), got %d", len(errs))
|
||||
}
|
||||
e := errs[0]
|
||||
if e.ExceptionType != "TypeError" || e.Message != "x is not a function" {
|
||||
t.Errorf("exception not carried: %+v", e)
|
||||
}
|
||||
if e.Stack != "at f (app.js:1:1)" {
|
||||
t.Errorf("stack not carried: %q", e.Stack)
|
||||
}
|
||||
if e.Handled == nil || *e.Handled != false {
|
||||
t.Errorf("handled flag not carried: %v", e.Handled)
|
||||
}
|
||||
if e.DistinctID != "u1" || e.SessionID != "s1" {
|
||||
t.Errorf("identity not carried: %+v", e)
|
||||
}
|
||||
if e.Transaction != "/checkout" || e.Path != "/checkout" || e.URL != "https://acme.ai/checkout" {
|
||||
t.Errorf("route not carried: %+v", e)
|
||||
}
|
||||
if e.Product != "app" || e.Library != "@hanzo/event" {
|
||||
t.Errorf("surface not carried: %+v", e)
|
||||
}
|
||||
if e.Release != "v2" || e.TraceID != "abc" {
|
||||
t.Errorf("property fallbacks not carried: release=%q trace=%q", e.Release, e.TraceID)
|
||||
}
|
||||
if e.Level != "error" {
|
||||
t.Errorf("level = %q, want error", e.Level)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("error sink was not called")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFanOutErrorsEnvelopeFields verifies the first-class envelope qualifiers win over
|
||||
// the property spellings: a wire that states release/environment/service/site/trace on
|
||||
// the envelope hands exactly those to the sink.
|
||||
func TestFanOutErrorsEnvelopeFields(t *testing.T) {
|
||||
got, done := collectErrors(t, "acme")
|
||||
defer done()
|
||||
|
||||
fanOut("acme", []CaptureEvent{{
|
||||
Type: "error", Error: &Exception{Message: "boom"},
|
||||
Release: "v3", Environment: "prod", Service: "gateway", Site: "acme.ai",
|
||||
TraceID: "t-1", SpanID: "s-1",
|
||||
Properties: map[string]any{"$release": "stale", "$trace_id": "stale"},
|
||||
}})
|
||||
|
||||
select {
|
||||
case errs := <-got:
|
||||
e := errs[0]
|
||||
if e.Release != "v3" || e.Environment != "prod" {
|
||||
t.Errorf("envelope release/environment must win: %+v", e)
|
||||
}
|
||||
if e.Service != "gateway" || e.Site != "acme.ai" {
|
||||
t.Errorf("service/site not carried: %+v", e)
|
||||
}
|
||||
if e.TraceID != "t-1" || e.SpanID != "s-1" {
|
||||
t.Errorf("envelope trace linkage must win: %+v", e)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("error sink was not called")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFanOutErrorsNativeExceptionMap verifies the JSON-wire path: an error-typed event
|
||||
// carrying $exception as a decoded map (not the typed *Exception). exceptionOf must
|
||||
// read type/message/stack/handled out of the map.
|
||||
func TestFanOutErrorsNativeExceptionMap(t *testing.T) {
|
||||
got, done := collectErrors(t, "acme")
|
||||
defer done()
|
||||
|
||||
fanOut("acme", []CaptureEvent{{
|
||||
Type: "error", Event: "$error",
|
||||
Properties: map[string]any{
|
||||
"$exception": map[string]any{
|
||||
"type": "RangeError", "message": "out of range", "stack": "at g()", "handled": true,
|
||||
},
|
||||
},
|
||||
}})
|
||||
|
||||
select {
|
||||
case errs := <-got:
|
||||
if len(errs) != 1 {
|
||||
t.Fatalf("want 1, got %d", len(errs))
|
||||
}
|
||||
e := errs[0]
|
||||
if e.ExceptionType != "RangeError" || e.Message != "out of range" || e.Stack != "at g()" {
|
||||
t.Errorf("map exception not read: %+v", e)
|
||||
}
|
||||
if e.Handled == nil || *e.Handled != true {
|
||||
t.Errorf("handled from map not read: %v", e.Handled)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("error sink was not called")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFanOutErrorsTypedErrorNoException verifies a bare type:'error' event with NO
|
||||
// exception is still carried (the consumer groups it on message/transaction).
|
||||
func TestFanOutErrorsTypedErrorNoException(t *testing.T) {
|
||||
got, done := collectErrors(t, "acme")
|
||||
defer done()
|
||||
|
||||
fanOut("acme", []CaptureEvent{{Type: "error", Event: "boom", Path: "/x"}})
|
||||
|
||||
select {
|
||||
case errs := <-got:
|
||||
if len(errs) != 1 {
|
||||
t.Fatalf("want 1, got %d", len(errs))
|
||||
}
|
||||
if errs[0].ExceptionType != "" || errs[0].Message != "" {
|
||||
t.Errorf("bare error should carry no exception: %+v", errs[0])
|
||||
}
|
||||
if errs[0].Transaction != "/x" {
|
||||
t.Errorf("transaction = %q, want /x", errs[0].Transaction)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("error sink was not called for a bare error-typed event")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFanOutErrorsIsThePlanesRoute pins the filter to routeOf — the projection carries
|
||||
// exactly the facts the plane files under the error signal, so sentry.hanzo.ai and
|
||||
// event.fact can never disagree about what an error is. In particular a $exception
|
||||
// property on an event the plane routes as an act does NOT reach the sink.
|
||||
func TestFanOutErrorsIsThePlanesRoute(t *testing.T) {
|
||||
got, done := collectErrors(t, "acme")
|
||||
defer done()
|
||||
|
||||
fanOut("acme", []CaptureEvent{
|
||||
{Type: "ERROR", Event: "case-folded"}, // routed error: type folds lower
|
||||
{Error: &Exception{Message: "typeless"}}, // routed error: typeless + exception
|
||||
{Type: "event", Event: "decorated", Properties: map[string]any{ // routed act: the plane's word wins
|
||||
"$exception": map[string]any{"message": "not an error fact"},
|
||||
}},
|
||||
})
|
||||
|
||||
select {
|
||||
case errs := <-got:
|
||||
if len(errs) != 2 {
|
||||
t.Fatalf("want the 2 error-routed events, got %d: %+v", len(errs), errs)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("error sink was not called")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFanOutErrorsNoErrorsNoDispatch verifies a batch with zero error events never
|
||||
// dispatches to the error sink (the common case — a normal pageview/event batch).
|
||||
func TestFanOutErrorsNoErrorsNoDispatch(t *testing.T) {
|
||||
fired := make(chan struct{}, 1)
|
||||
remove := AddErrorSink(func(org string, errs []ErrorEvent) { fired <- struct{}{} })
|
||||
defer remove()
|
||||
|
||||
fanOut("acme", []CaptureEvent{
|
||||
{Type: "pageview"},
|
||||
{Type: "event", Event: "order_completed", Revenue: 10},
|
||||
})
|
||||
|
||||
select {
|
||||
case <-fired:
|
||||
t.Fatal("error sink fired for a batch with no error events")
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
// expected: no dispatch
|
||||
}
|
||||
}
|
||||
|
||||
// TestFanOutErrorsNoSinksIsNoOp verifies fan-out is inert when no error sink is
|
||||
// installed — the default when the o11y embed is off. A removed sink counts as absent.
|
||||
func TestFanOutErrorsNoSinksIsNoOp(t *testing.T) {
|
||||
remove := AddErrorSink(func(string, []ErrorEvent) { t.Error("removed sink must not fire") })
|
||||
remove()
|
||||
fanOut("acme", []CaptureEvent{{Type: "error", Event: "boom"}})
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
// TestExceptionOf covers extraction from each carrier: typed Error, typed post-fold
|
||||
// $exception, and decoded map.
|
||||
func TestExceptionOf(t *testing.T) {
|
||||
h := true
|
||||
// typed Error field
|
||||
if typ, msg, stack, handled := exceptionOf(CaptureEvent{Error: &Exception{Type: "E", Message: "m", Stack: "s", Handled: &h}}); typ != "E" || msg != "m" || stack != "s" || handled == nil || !*handled {
|
||||
t.Errorf("typed Error: %q %q %q %v", typ, msg, stack, handled)
|
||||
}
|
||||
// post-fold typed (*Exception in properties)
|
||||
if typ, msg, _, _ := exceptionOf(CaptureEvent{Properties: map[string]any{"$exception": &Exception{Type: "E2", Message: "m2"}}}); typ != "E2" || msg != "m2" {
|
||||
t.Errorf("post-fold typed: %q %q", typ, msg)
|
||||
}
|
||||
// decoded map
|
||||
if typ, msg, stack, _ := exceptionOf(CaptureEvent{Properties: map[string]any{"$exception": map[string]any{"type": "E3", "message": "m3", "stack": "s3"}}}); typ != "E3" || msg != "m3" || stack != "s3" {
|
||||
t.Errorf("decoded map: %q %q %q", typ, msg, stack)
|
||||
}
|
||||
// none
|
||||
if typ, msg, _, _ := exceptionOf(CaptureEvent{Type: "error"}); typ != "" || msg != "" {
|
||||
t.Errorf("no exception should be empty: %q %q", typ, msg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// collectSpans installs a span sink that forwards the batch for one org onto a channel,
|
||||
// and returns the channel + the sink's remover. Mirrors collectErrors next door.
|
||||
func collectSpans(t *testing.T, wantOrg string) (<-chan []SpanEvent, func()) {
|
||||
t.Helper()
|
||||
got := make(chan []SpanEvent, 1)
|
||||
remove := AddSpanSink(func(org string, spans []SpanEvent) {
|
||||
if org == wantOrg {
|
||||
got <- spans
|
||||
}
|
||||
})
|
||||
return got, remove
|
||||
}
|
||||
|
||||
// TestFanOutSpansIsThePlanesRoute pins the filter to routeOf — the projection carries
|
||||
// exactly the facts the plane files under the span signal, so the LLM views and
|
||||
// event.fact can never disagree about what a span is. In particular an event carrying a
|
||||
// span BODY that the plane routes as an act does NOT reach the sink: the wire's `type`
|
||||
// picks the route, and a body cannot promote itself past it.
|
||||
func TestFanOutSpansIsThePlanesRoute(t *testing.T) {
|
||||
got, done := collectSpans(t, "acme")
|
||||
defer done()
|
||||
|
||||
fanOut("acme", []CaptureEvent{
|
||||
{Type: "span", Span: &SpanBody{ID: "s-1", Trace: "t-1"}}, // routed span
|
||||
{Type: "SPAN", Span: &SpanBody{ID: "s-2", Trace: "t-1"}}, // routed span: type folds lower
|
||||
{Type: "event", Event: "click", Span: &SpanBody{ID: "x"}}, // routed act: the plane's word wins
|
||||
{Type: "error", Event: "boom"}, // routed error
|
||||
{Type: "pageview"}, // routed act
|
||||
})
|
||||
|
||||
select {
|
||||
case spans := <-got:
|
||||
if len(spans) != 2 {
|
||||
t.Fatalf("want the 2 span-routed events, got %d: %+v", len(spans), spans)
|
||||
}
|
||||
if spans[0].SpanID != "s-1" || spans[1].SpanID != "s-2" {
|
||||
t.Errorf("wrong events carried: %+v", spans)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("span sink was not called")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFanOutSpansEnvelopeFields verifies the first-class envelope fields win over both
|
||||
// the span body and the legacy $ property spellings — the same order the fact row
|
||||
// resolves its identity in.
|
||||
func TestFanOutSpansEnvelopeFields(t *testing.T) {
|
||||
got, done := collectSpans(t, "acme")
|
||||
defer done()
|
||||
|
||||
fanOut("acme", []CaptureEvent{{
|
||||
Type: "span", MessageID: "m-1",
|
||||
TraceID: "t-envelope", SpanID: "s-envelope",
|
||||
Release: "v3", Environment: "prod", Service: "gateway", Site: "acme.ai",
|
||||
Product: "chat", DistinctID: "u1", SessionID: "sess-1",
|
||||
Span: &SpanBody{ID: "s-body", Trace: "t-body", Parent: "p-1"},
|
||||
Properties: map[string]any{"$trace_id": "t-stale", "$span_id": "s-stale", "$release": "stale", "$environment": "stale"},
|
||||
}})
|
||||
|
||||
select {
|
||||
case spans := <-got:
|
||||
s := spans[0]
|
||||
if s.TraceID != "t-envelope" || s.SpanID != "s-envelope" {
|
||||
t.Errorf("envelope ids must win over body and properties: %+v", s)
|
||||
}
|
||||
if s.Release != "v3" || s.Environment != "prod" {
|
||||
t.Errorf("envelope release/environment must win: %+v", s)
|
||||
}
|
||||
if s.Service != "gateway" || s.Site != "acme.ai" || s.Product != "chat" {
|
||||
t.Errorf("origin not carried: %+v", s)
|
||||
}
|
||||
if s.Parent != "p-1" {
|
||||
t.Errorf("parent comes from the body: %q", s.Parent)
|
||||
}
|
||||
if s.MessageID != "m-1" || s.DistinctID != "u1" || s.SessionID != "sess-1" {
|
||||
t.Errorf("identity not carried: %+v", s)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("span sink was not called")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFanOutSpansBodyRefinesTheRoute verifies the body fills what the envelope omits and
|
||||
// refines the kind — applySpan's own precedence, restated on the projection: the route's
|
||||
// default kind (internal) stands unless the envelope names one, and the body wins over
|
||||
// both. The $ spellings are the last resort for a client with no first-class field.
|
||||
func TestFanOutSpansBodyRefinesTheRoute(t *testing.T) {
|
||||
got, done := collectSpans(t, "acme")
|
||||
defer done()
|
||||
|
||||
fanOut("acme", []CaptureEvent{
|
||||
{Type: "span", Kind: "SERVER", Span: &SpanBody{ID: "a", Trace: "t", Kind: "Client", Status: "ERROR", Duration: 42}},
|
||||
{Type: "span", Kind: "server", Span: &SpanBody{ID: "b", Trace: "t"}},
|
||||
{Type: "span", Span: &SpanBody{ID: "c", Trace: "t"}},
|
||||
{Type: "span", Properties: map[string]any{"$trace_id": "t-legacy", "$span_id": "s-legacy"}},
|
||||
})
|
||||
|
||||
select {
|
||||
case spans := <-got:
|
||||
if len(spans) != 4 {
|
||||
t.Fatalf("want 4 spans, got %d", len(spans))
|
||||
}
|
||||
if spans[0].Kind != "client" || spans[0].Status != "error" || spans[0].Duration != 42 {
|
||||
t.Errorf("body must refine kind/status/duration, lowercased: %+v", spans[0])
|
||||
}
|
||||
if spans[1].Kind != "server" {
|
||||
t.Errorf("envelope kind stands when the body names none: %q", spans[1].Kind)
|
||||
}
|
||||
if spans[2].Kind != kindInternal {
|
||||
t.Errorf("the route's default kind stands when nobody names one: %q", spans[2].Kind)
|
||||
}
|
||||
if spans[3].TraceID != "t-legacy" || spans[3].SpanID != "s-legacy" {
|
||||
t.Errorf("legacy $ spellings are the last resort: %+v", spans[3])
|
||||
}
|
||||
// Every span is named, so the projection can never write an unnamed row.
|
||||
for i, s := range spans {
|
||||
if s.Name != nameSpan {
|
||||
t.Errorf("span %d name = %q, want the route's default %q", i, s.Name, nameSpan)
|
||||
}
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("span sink was not called")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFanOutSpansStoresTheScrubbedProperties pins the rule that separates this slice from
|
||||
// the destinations slice: a span sink WRITES A ROW, so it receives the same scrubMap copy
|
||||
// the write core stores — the credential key is dropped and the token-shaped value is
|
||||
// redacted before it can reach a stored attribute. The gen_ai marker survives untouched.
|
||||
func TestFanOutSpansStoresTheScrubbedProperties(t *testing.T) {
|
||||
got, done := collectSpans(t, "acme")
|
||||
defer done()
|
||||
|
||||
fanOut("acme", []CaptureEvent{{
|
||||
Type: "span", Span: &SpanBody{ID: "s", Trace: "t"},
|
||||
Properties: map[string]any{
|
||||
"gen_ai.system": "openai",
|
||||
"gen_ai.request.model": "zen-1",
|
||||
"gen_ai.usage.input_tokens": 128,
|
||||
"authorization": "Bearer abcdefghijklmnop",
|
||||
"gen_ai.prompt": "mail me at user@acme.ai",
|
||||
},
|
||||
}})
|
||||
|
||||
select {
|
||||
case spans := <-got:
|
||||
p := spans[0].Properties
|
||||
if p["gen_ai.system"] != "openai" || p["gen_ai.request.model"] != "zen-1" {
|
||||
t.Errorf("the gen_ai marker and model must survive the scrub: %+v", p)
|
||||
}
|
||||
if _, ok := p["authorization"]; ok {
|
||||
t.Errorf("credential-shaped key must be dropped before storage: %+v", p)
|
||||
}
|
||||
if v, _ := p["gen_ai.prompt"].(string); strings.Contains(v, "user@acme.ai") {
|
||||
t.Errorf("email must be redacted before storage: %q", v)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("span sink was not called")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFanOutSpansNoSpansNoDispatch verifies a batch with zero span events never
|
||||
// dispatches to the span sink (the common case — a normal pageview/event batch).
|
||||
func TestFanOutSpansNoSpansNoDispatch(t *testing.T) {
|
||||
fired := make(chan struct{}, 1)
|
||||
remove := AddSpanSink(func(org string, spans []SpanEvent) { fired <- struct{}{} })
|
||||
defer remove()
|
||||
|
||||
fanOut("acme", []CaptureEvent{
|
||||
{Type: "pageview"},
|
||||
{Type: "event", Event: "order_completed", Revenue: 10},
|
||||
{Type: "error", Event: "boom"},
|
||||
})
|
||||
|
||||
select {
|
||||
case <-fired:
|
||||
t.Fatal("span sink fired for a batch with no span events")
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
// expected: no dispatch
|
||||
}
|
||||
}
|
||||
|
||||
// TestFanOutSpansNoSinksIsNoOp verifies fan-out is inert when no span sink is installed —
|
||||
// the default when the o11y plane sink is off. A removed sink counts as absent, which is
|
||||
// what makes ShutdownO11y's detach real.
|
||||
func TestFanOutSpansNoSinksIsNoOp(t *testing.T) {
|
||||
remove := AddSpanSink(func(string, []SpanEvent) { t.Error("removed sink must not fire") })
|
||||
remove()
|
||||
fanOut("acme", []CaptureEvent{{Type: "span", Span: &SpanBody{ID: "s", Trace: "t"}}})
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
// TestSpanBodyOf covers both carriers: a span that stated a body, and a span-typed event
|
||||
// that stated none (the zero body, which reads as "nothing further known").
|
||||
func TestSpanBodyOf(t *testing.T) {
|
||||
if b := spanBodyOf(CaptureEvent{Span: &SpanBody{ID: "s", Duration: 7}}); b.ID != "s" || b.Duration != 7 {
|
||||
t.Errorf("stated body not returned: %+v", b)
|
||||
}
|
||||
if b := spanBodyOf(CaptureEvent{Type: "span"}); b != (SpanBody{}) {
|
||||
t.Errorf("absent body must read as the zero body: %+v", b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build datastore_live
|
||||
|
||||
// EVERY SIGNAL, ONE DOOR — the live proof that each kind of fact is reachable from
|
||||
// the one ingest contract and lands as exactly ONE row under its own signal.
|
||||
//
|
||||
// capture_live_test.go proves the act leg end-to-end (emit → fact → row → read
|
||||
// lens). This file closes the set: error, log and span driven through the SAME
|
||||
// door in one batch. The signal is `type` on the wire; routeOf (fact.go) is the
|
||||
// whole routing rule; the writers table (warehouse.go) is the whole storage rule.
|
||||
// Nothing in between gets an opinion, which is why one door serves every signal.
|
||||
//
|
||||
// It also pins what the per-signal reads depend on: a log is read by
|
||||
// (org, service, time) and a span assembled by trace, so the row carries service
|
||||
// and severity for the log and trace/span/parent/kind/status/duration for the
|
||||
// span — and the two share a trace_id, the correlation the identical envelope
|
||||
// buys. One fact never lands twice: the batch's four events produce exactly four
|
||||
// rows, one per signal.
|
||||
//
|
||||
// Run:
|
||||
//
|
||||
// DATASTORE_ADDR=127.0.0.1:9000 DATASTORE_DB=hanzo \
|
||||
// go test -tags datastore_live -run TestLiveEverySignal -v ./apps/analytics/
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud/apps/datastore"
|
||||
)
|
||||
|
||||
// TestLiveEverySignalLandsItsOwnRow drives one fact of EACH landable signal
|
||||
// through the one door and asserts each landed as one row under its own signal.
|
||||
func TestLiveEverySignalLandsItsOwnRow(t *testing.T) {
|
||||
ready, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
if err := datastore.Wait(ready); err != nil {
|
||||
t.Fatalf("datastore did not connect (set DATASTORE_ADDR=127.0.0.1:9000 with a live Datastore): %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
requirePlane(t, ctx)
|
||||
landDirect(t)
|
||||
|
||||
org := "acme-sig-" + strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||
app := liveApp(t)
|
||||
|
||||
// One batch, four signals. The wire says which; nothing else does.
|
||||
body := `{"batch":[
|
||||
{"type":"page","event":"page_viewed","distinctId":"u-1","product":"console","path":"/pricing"},
|
||||
{"type":"error","distinctId":"u-1","product":"console","service":"web",
|
||||
"error":{"type":"RangeError","message":"out of range","stack":"at f (https://app.test/a.js:2:1)"}},
|
||||
{"type":"log","event":"request.served","distinctId":"u-1","product":"console",
|
||||
"service":"gateway","traceId":"trace-abc","spanId":"span-1",
|
||||
"log":{"severity":"info","number":9,"body":"served 200 in 12ms"}},
|
||||
{"type":"span","event":"GET /v1/event","distinctId":"u-1","product":"console",
|
||||
"service":"gateway",
|
||||
"span":{"trace":"trace-abc","id":"span-2","parent":"span-1",
|
||||
"kind":"server","duration":12000000,"status":"OK"}}
|
||||
]}`
|
||||
code, resp := livePost(t, app, canonDoor, "u-1", org, body)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("POST %s = %d (%s)", canonDoor, code, resp)
|
||||
}
|
||||
t.Logf("ingest receipt: %s", strings.TrimSpace(string(resp)))
|
||||
|
||||
row := func(signal string) map[string]any {
|
||||
t.Helper()
|
||||
rows, err := datastore.Query(ctx,
|
||||
"SELECT name, kind, message, severity, duration, service, trace_id, span_id, parent, status, class, issue "+
|
||||
"FROM "+factTable+" WHERE org = ? AND signal = ?", org, signal)
|
||||
if err != nil {
|
||||
t.Fatalf("readback %s: %v", signal, err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("signal %q has %d rows for org %s, want exactly 1 — a fact did not land, or landed twice", signal, len(rows), org)
|
||||
}
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
act := row("act")
|
||||
t.Logf("── act landed ── name=%q kind=%q", aString(act["name"]), aString(act["kind"]))
|
||||
if aString(act["name"]) != "page_viewed" || aString(act["kind"]) != "page" {
|
||||
t.Fatalf("act row mismatch: name=%q kind=%q", aString(act["name"]), aString(act["kind"]))
|
||||
}
|
||||
|
||||
er := row("error")
|
||||
t.Logf("── error landed ── class=%q message=%q issue=%q", aString(er["class"]), aString(er["message"]), aString(er["issue"]))
|
||||
if aString(er["class"]) != "RangeError" || aString(er["message"]) != "out of range" {
|
||||
t.Fatalf("error columns did not land: class=%q message=%q", aString(er["class"]), aString(er["message"]))
|
||||
}
|
||||
if aString(er["issue"]) == "" {
|
||||
t.Fatal("no issue fingerprint — the error cannot be grouped, and grouping is the read")
|
||||
}
|
||||
|
||||
lr := row("log")
|
||||
t.Logf("── log landed ── service=%q severity=%v message=%q trace=%q",
|
||||
aString(lr["service"]), lr["severity"], aString(lr["message"]), aString(lr["trace_id"]))
|
||||
if aString(lr["service"]) != "gateway" {
|
||||
t.Fatalf("service = %q, want gateway — the (org, service, time) log read depends on it", aString(lr["service"]))
|
||||
}
|
||||
if aInt64(lr["severity"]) != 9 {
|
||||
t.Fatalf("severity did not land: %v", lr["severity"])
|
||||
}
|
||||
if aString(lr["message"]) != "served 200 in 12ms" {
|
||||
t.Fatalf("log body did not land: %q", aString(lr["message"]))
|
||||
}
|
||||
|
||||
sr := row("span")
|
||||
t.Logf("── span landed ── kind=%q trace=%q span=%q parent=%q duration=%v status=%q",
|
||||
aString(sr["kind"]), aString(sr["trace_id"]), aString(sr["span_id"]),
|
||||
aString(sr["parent"]), sr["duration"], aString(sr["status"]))
|
||||
if aString(sr["trace_id"]) == "" {
|
||||
t.Fatal("no trace_id — assembling the trace is the read")
|
||||
}
|
||||
// kind is the discriminator WITHIN a signal: track|page|identify|group on an
|
||||
// act, the OTel span kind here. One column, not two.
|
||||
if aString(sr["kind"]) != "server" {
|
||||
t.Fatalf("kind = %q, want server — the span kind IS the envelope's kind column", aString(sr["kind"]))
|
||||
}
|
||||
if aString(sr["span_id"]) != "span-2" || aString(sr["parent"]) != "span-1" {
|
||||
t.Fatalf("span identity did not land: id=%q parent=%q", aString(sr["span_id"]), aString(sr["parent"]))
|
||||
}
|
||||
// status is a small named set, normalized to lower case, so `status = 'error'`
|
||||
// says what it means where a status_code = 2 needed a comment.
|
||||
if aInt64(sr["duration"]) != 12000000 || aString(sr["status"]) != "ok" {
|
||||
t.Fatalf("span outcome did not land: duration=%v status=%q", sr["duration"], aString(sr["status"]))
|
||||
}
|
||||
|
||||
// The log and the span share a trace: the correlation the identical envelope buys.
|
||||
if aString(lr["trace_id"]) != aString(sr["trace_id"]) {
|
||||
t.Fatalf("log trace %q != span trace %q", aString(lr["trace_id"]), aString(sr["trace_id"]))
|
||||
}
|
||||
|
||||
// One fact, one row: four signals in, exactly four rows out.
|
||||
total, err := datastore.Query(ctx, "SELECT count() AS n FROM "+factTable+" WHERE org = ?", org)
|
||||
if err != nil || len(total) == 0 {
|
||||
t.Fatalf("count readback: %v", err)
|
||||
}
|
||||
if n := aInt64(total[0]["n"]); n != 4 {
|
||||
t.Fatalf("%s has %d rows for org %s, want exactly 4 — a signal landed twice or not at all", factTable, n, org)
|
||||
}
|
||||
t.Logf("LIVE OK: one door, four signals — act/error/log/span each landed exactly one row (org=%s)", org)
|
||||
}
|
||||
@@ -181,5 +181,69 @@
|
||||
window.__hanzoEvent = true
|
||||
window.hanzo = { track: track, identify: identify, page: page, error: error, flush: flush }
|
||||
|
||||
// ── config-driven tag injection (the hosted twin of track.js) ─────────────
|
||||
// Fetch the SITE's connected browser pixels (/v1/tags, dual-resolved per site by
|
||||
// this key or the host) and inject them first-party. Every event then also fires
|
||||
// the native pixel — translated through the SAME taxonomy the server-side CAPI uses
|
||||
// (destinations/translate.go), stamped with a shared event_id carried on the
|
||||
// /v1/event row too — so the browser pixel and the server CAPI DEDUPLICATE instead
|
||||
// of double-counting. Keyless already returned above, so this only runs when keyed.
|
||||
var STD = {
|
||||
$pageview: 'page_view', pricing_viewed: 'view_content', signup_viewed: 'view_content',
|
||||
product_viewed: 'view_content', plan_clicked: 'lead', signup_submitted: 'lead',
|
||||
waitlist_joined: 'lead', referral_used: 'lead', signup_completed: 'signup',
|
||||
product_added: 'add_to_cart', add_to_cart: 'add_to_cart', checkout_started: 'start_checkout',
|
||||
begin_checkout: 'start_checkout', order_completed: 'purchase', purchase: 'purchase'
|
||||
}
|
||||
var NATIVE = {
|
||||
ga: { page_view: 'page_view', view_content: 'view_item', add_to_cart: 'add_to_cart', lead: 'generate_lead', signup: 'sign_up', start_checkout: 'begin_checkout', purchase: 'purchase' },
|
||||
meta: { page_view: 'PageView', view_content: 'ViewContent', add_to_cart: 'AddToCart', lead: 'Lead', signup: 'CompleteRegistration', start_checkout: 'InitiateCheckout', purchase: 'Purchase' },
|
||||
tiktok: { page_view: 'Pageview', view_content: 'ViewContent', add_to_cart: 'AddToCart', lead: 'SubmitForm', signup: 'CompleteRegistration', start_checkout: 'InitiateCheckout', purchase: 'CompletePayment' },
|
||||
x: { page_view: 'PageView', view_content: 'ViewContent', add_to_cart: 'AddToCart', lead: 'Lead', signup: 'SignUp', start_checkout: 'InitiateCheckout', purchase: 'Purchase' }
|
||||
}
|
||||
function nativeName(t, n) { var s = STD[n]; return s ? (NATIVE[t] || {})[s] || null : null }
|
||||
function loadJS(u) { var e = document.createElement('script'); e.async = true; e.src = u; document.head.appendChild(e) }
|
||||
function eid() { try { return crypto.randomUUID() } catch (e) { return 'e-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 10) } }
|
||||
var INJECT = {
|
||||
ga: {
|
||||
load: function (id) { loadJS('https://www.googletagmanager.com/gtag/js?id=' + encodeURIComponent(id)); window.dataLayer = window.dataLayer || []; window.gtag = window.gtag || function () { dataLayer.push(arguments) }; gtag('js', new Date()); gtag('config', id) },
|
||||
fire: function (n, p, id) { if (!window.gtag) return; var name = nativeName('ga', n) || n; var o = {}; for (var k in p) o[k] = p[k]; o.event_id = id; if (STD[n] === 'purchase') o.transaction_id = id; gtag('event', name, o) }
|
||||
},
|
||||
meta: {
|
||||
load: function (id) { if (!window.fbq) { var n = window.fbq = function () { n.callMethod ? n.callMethod.apply(n, arguments) : n.queue.push(arguments) }; if (!window._fbq) window._fbq = n; n.push = n; n.loaded = true; n.version = '2.0'; n.queue = []; loadJS('https://connect.facebook.net/en_US/fbevents.js') } fbq('init', id); fbq('track', 'PageView') },
|
||||
fire: function (n, p, id) { if (!window.fbq) return; var name = nativeName('meta', n); if (name) fbq('track', name, p || {}, { eventID: id }); else fbq('trackCustom', n, p || {}) }
|
||||
},
|
||||
tiktok: {
|
||||
load: function (id) { var q = window.ttq = window.ttq || []; if (!q.methods) { q.methods = ['page', 'track', 'identify', 'instances', 'debug', 'on', 'off', 'once', 'ready', 'alias', 'group', 'enableCookie', 'disableCookie']; q.setAndDefer = function (t, e) { t[e] = function () { t.push([e].concat(Array.prototype.slice.call(arguments, 0))) } }; for (var i = 0; i < q.methods.length; i++) q.setAndDefer(q, q.methods[i]); loadJS('https://analytics.tiktok.com/i18n/pixel/events.js?sdkid=' + encodeURIComponent(id) + '&lib=ttq') } if (q.load) q.load(id); if (q.page) q.page() },
|
||||
fire: function (n, p, id) { if (!window.ttq || !window.ttq.track) return; var name = nativeName('tiktok', n) || n; window.ttq.track(name, p || {}, { event_id: id }) }
|
||||
},
|
||||
x: {
|
||||
load: function (id) { if (!window.twq) { var s = window.twq = function () { s.exe ? s.exe.apply(s, arguments) : s.queue.push(arguments) }; s.version = '1.1'; s.queue = []; loadJS('https://static.ads-twitter.com/uwt.js') } twq('config', id) },
|
||||
fire: function (n, p, id) { if (!window.twq) return; var name = nativeName('x', n) || n; var o = {}; for (var k in p) o[k] = p[k]; o.conversion_id = id; twq('event', name, o) }
|
||||
}
|
||||
}
|
||||
var active = []
|
||||
try {
|
||||
fetch(src.origin + '/v1/tags?key=' + encodeURIComponent(key))
|
||||
.then(function (r) { return r.ok ? r.json() : { tags: [] } })
|
||||
.then(function (cfg) {
|
||||
var tags = (cfg && cfg.tags) || []
|
||||
for (var i = 0; i < tags.length; i++) {
|
||||
var t = tags[i]
|
||||
if (INJECT[t.type]) { try { INJECT[t.type].load(t.id); active.push(t) } catch (e) {} }
|
||||
}
|
||||
if (active.length) {
|
||||
var base = window.hanzo.track
|
||||
window.hanzo.track = function (n, p) {
|
||||
var id = eid()
|
||||
for (var j = 0; j < active.length; j++) { try { INJECT[active[j].type].fire(n, p, id) } catch (e) {} }
|
||||
var o = {}; if (p) for (var k in p) o[k] = p[k]; o.event_id = id
|
||||
base(n, o)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(function () {})
|
||||
} catch (e) {}
|
||||
|
||||
page()
|
||||
})()
|
||||
|
||||
@@ -118,12 +118,14 @@ function anonOf(r) {
|
||||
assert.strictEqual(ev.event, undefined, 'naming is resolveEventName server-side')
|
||||
}
|
||||
|
||||
// 4. Without sendBeacon the fetch path carries the key as a bearer.
|
||||
// 4. Without sendBeacon the fetch path carries the key as a bearer. (A keyed tag also
|
||||
// fetches /v1/tags for its browser pixels; the /v1/event POST is the one asserted.)
|
||||
{
|
||||
const r = run({ 'data-key': 'pk-live-abc' }, { noBeacon: true })
|
||||
r.fire('pagehide')
|
||||
assert.strictEqual(r.sent.fetch.length, 1)
|
||||
const init = r.sent.fetch[0].init
|
||||
const posts = r.sent.fetch.filter((f) => f.url.indexOf('/v1/event') !== -1)
|
||||
assert.strictEqual(posts.length, 1)
|
||||
const init = posts[0].init
|
||||
assert.strictEqual(init.headers.authorization, 'Bearer pk-live-abc')
|
||||
assert.strictEqual(init.keepalive, true)
|
||||
assert.ok(JSON.parse(init.body).batch.length === 1)
|
||||
@@ -199,4 +201,22 @@ function anonOf(r) {
|
||||
assert.strictEqual(err.error.handled, false)
|
||||
}
|
||||
|
||||
console.log('tag.js: 10/10 behavioral checks passed')
|
||||
// 11. Config-driven injection: a keyed tag fetches its site's browser pixels from
|
||||
// /v1/tags (dual-resolved per site by this key or the host), so the hosted
|
||||
// one-liner injects GA/Meta/TikTok/X first-party. The pixels + the deduping
|
||||
// event_id are unit-tested in track.js; here we prove the hosted tag makes the
|
||||
// request — and that a keyless tag never does.
|
||||
{
|
||||
const r = run({ 'data-key': 'pk-live-abc' })
|
||||
assert.ok(
|
||||
r.sent.fetch.some((f) => f.url.indexOf('/v1/tags?key=pk-live-abc') !== -1),
|
||||
'keyed tag fetches /v1/tags for its browser pixels'
|
||||
)
|
||||
const keyless = run({})
|
||||
assert.ok(
|
||||
!keyless.sent.fetch.some((f) => f.url.indexOf('/v1/tags') !== -1),
|
||||
'keyless tag never fetches /v1/tags'
|
||||
)
|
||||
}
|
||||
|
||||
console.log('tag.js: 11/11 behavioral checks passed')
|
||||
|
||||
@@ -202,6 +202,93 @@ func (e Engine) Serve(c *zip.Ctx, in Request, q string) error {
|
||||
})
|
||||
}
|
||||
|
||||
// Report is one researched answer held as a VALUE: the grounded prose and the
|
||||
// sources it cites. It carries what Serve writes as JSON minus the two keys that
|
||||
// only mean anything to the advisor contract — `figures`, which the web domain
|
||||
// leaves empty by construction, and `domain`, which is always "web" here.
|
||||
type Report struct {
|
||||
// Answer is the grounded prose, with inline markdown citations. Every link in
|
||||
// it points at a page in Sources: the citation check runs on the text before
|
||||
// it leaves the engine, so a cited URL is one THIS call fetched.
|
||||
Answer string `json:"answer"`
|
||||
// Sources are the pages the answer was written from, deduplicated and ranked.
|
||||
// Always an array, never null.
|
||||
Sources []Source `json:"sources"`
|
||||
// FollowUps are the questions worth asking next. Best-effort — an empty list
|
||||
// is a normal outcome, not a fault.
|
||||
FollowUps []string `json:"follow_ups"`
|
||||
// Mode is the profile that ran: search, news, research or deep.
|
||||
Mode string `json:"mode"`
|
||||
// Model is the model that synthesized the answer.
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// Answer runs the SAME bounded loop Serve runs and hands back its outcome as a
|
||||
// value instead of writing it to a response.
|
||||
//
|
||||
// It exists because the loop's only door was an HTTP handler, and a handler is
|
||||
// the one shape an agent cannot reach: a typed op — and so an MCP tool, a CLI
|
||||
// command and an SDK method — is invoked with a context and no request at all.
|
||||
// So the engine that already separated Run (transport-free) from Serve (HTTP)
|
||||
// gains its second transport-free door rather than a second engine. Serve keeps
|
||||
// streaming; this returns one value; Run is still the only loop.
|
||||
//
|
||||
// The identity facts Serve reads off the request are read off the CONTEXT here,
|
||||
// which is where cloud.Bridge parks the server-minted ones. There is no ledger
|
||||
// claim on a context, so the caller's own org pays — the same fallback Serve
|
||||
// takes when no billing org was minted.
|
||||
func (e Engine) Answer(ctx context.Context, in Request, q string) (*Report, error) {
|
||||
org, _ := principal.OrgFrom(ctx)
|
||||
project := principal.ProjectFrom(ctx)
|
||||
|
||||
m := resolveMode(in.Mode)
|
||||
fee := feeCents(m.name, m.feeCents)
|
||||
|
||||
// MONEY GATE — the same refusal Serve makes, before any work, so an
|
||||
// out-of-funds caller is told so rather than handed a half answer.
|
||||
capValidated := principal.ValidatedFrom(ctx) && !principal.IsDefaultProject(project)
|
||||
if err := e.Bill.Gate(ctx, org, project, capValidated, "web", fee); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
models := synthModels(in.Model, m, e.Model)
|
||||
p := Params{
|
||||
q: q,
|
||||
webQuery: buildQuery(q, m, in.Sources),
|
||||
mode: m,
|
||||
model: models[0],
|
||||
fallbacks: models[1:],
|
||||
language: strings.TrimSpace(in.Language),
|
||||
maxSources: clampPositive(in.MaxSources, m.maxSources),
|
||||
maxQueries: clampPositive(in.MaxQueries, m.maxQueries),
|
||||
readTop: m.readTop,
|
||||
rounds: min(m.rounds, maxRounds),
|
||||
hostCap: m.hostCap,
|
||||
deadline: m.deadline,
|
||||
tokenCeiling: m.tokenCeiling,
|
||||
followUps: in.FollowUps == nil || *in.FollowUps,
|
||||
system: pickSystem(in.System, m.system),
|
||||
dataOrg: org,
|
||||
payer: org,
|
||||
project: project,
|
||||
projectScope: project,
|
||||
fee: fee,
|
||||
}
|
||||
|
||||
rctx, cancel := context.WithTimeout(ctx, p.deadline)
|
||||
defer cancel()
|
||||
buf := &bufferSink{}
|
||||
e.Run(rctx, p, buf)
|
||||
|
||||
return &Report{
|
||||
Answer: buf.answer,
|
||||
Sources: nonNilSrc(buf.srcs),
|
||||
FollowUps: nonNilStr(buf.follow),
|
||||
Mode: p.mode.name,
|
||||
Model: p.model,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Run is the bounded loop, parameterized by mode — the ONE code path for
|
||||
// search/news/research/deep. It emits the SearchEvent envelope through out, then
|
||||
// meters the caller ONCE. A failed step degrades (fewer sources, snippets instead
|
||||
|
||||
@@ -777,3 +777,28 @@ func TestSynthesisPromptFencesCrawledPages(t *testing.T) {
|
||||
t.Fatal("the page must still be present — fencing contains it, it does not drop it")
|
||||
}
|
||||
}
|
||||
|
||||
// A source hint must SCOPE the search, not decorate it. The hints were appended
|
||||
// as bare words — `x` turned "openai news" into "openai news x", which is the
|
||||
// original query plus a noise token and reaches X/Twitter not at all. Mojeek
|
||||
// honours `site:` (measured: site:x.com → 10 results; Bing → 0), so a hint that
|
||||
// names a site has to become one.
|
||||
func TestSourceHintsScopeToTheirSite(t *testing.T) {
|
||||
q := buildQuery("openai", modes["search"], []string{"x"})
|
||||
if !strings.Contains(q, "site:x.com") {
|
||||
t.Fatalf("buildQuery with the x hint = %q, want it scoped to site:x.com", q)
|
||||
}
|
||||
multi := buildQuery("openai", modes["search"], []string{"github", "reddit"})
|
||||
if !strings.Contains(multi, "site:github.com") || !strings.Contains(multi, "site:reddit.com") {
|
||||
t.Fatalf("two site hints = %q, want both sites", multi)
|
||||
}
|
||||
// Two sites are alternatives, never both-at-once: `site:a site:b` is an AND
|
||||
// no page can satisfy, and would answer zero.
|
||||
if !strings.Contains(multi, " OR ") {
|
||||
t.Fatalf("two site hints = %q, want them joined as alternatives", multi)
|
||||
}
|
||||
// A TOPICAL hint names no site and must stay a plain word.
|
||||
if s := buildQuery("openai", modes["search"], []string{"web"}); strings.Contains(s, "site:") {
|
||||
t.Fatalf("topical hint = %q, want no site scope", s)
|
||||
}
|
||||
}
|
||||
|
||||
+35
-5
@@ -261,15 +261,45 @@ func buildQuery(q string, m mode, sources []string) string {
|
||||
if m.newsBias {
|
||||
wq = q + " latest news " + strconv.Itoa(time.Now().Year())
|
||||
}
|
||||
var hints []string
|
||||
var sites, words []string
|
||||
for _, s := range sources {
|
||||
t := strings.ToLower(strings.TrimSpace(s))
|
||||
if knownSourceHints[t] {
|
||||
hints = append(hints, t)
|
||||
if !knownSourceHints[t] {
|
||||
continue
|
||||
}
|
||||
if host := siteFor[t]; host != "" {
|
||||
sites = append(sites, "site:"+host)
|
||||
continue
|
||||
}
|
||||
words = append(words, t)
|
||||
}
|
||||
if len(hints) > 0 {
|
||||
wq = wq + " " + strings.Join(hints, " ")
|
||||
if len(words) > 0 {
|
||||
wq = wq + " " + strings.Join(words, " ")
|
||||
}
|
||||
switch len(sites) {
|
||||
case 0:
|
||||
case 1:
|
||||
wq = wq + " " + sites[0]
|
||||
default:
|
||||
// ALTERNATIVES, never both at once. `site:a site:b` reads as an AND and no
|
||||
// page is on two hosts, so the naive join answers zero every time.
|
||||
wq = wq + " (" + strings.Join(sites, " OR ") + ")"
|
||||
}
|
||||
return wq
|
||||
}
|
||||
|
||||
// siteFor maps a source hint to the host it scopes the search to. A hint that
|
||||
// names a place becomes a `site:` operator; a hint that names a TOPIC (web,
|
||||
// news) has no host and stays a plain word, because there is no one site that
|
||||
// is "the news".
|
||||
//
|
||||
// This is what makes the `x` hint reach X at all. It was appended as the bare
|
||||
// word "x", so asking for X sources turned "openai" into "openai x" — the same
|
||||
// open-web query plus a token that matches nothing in particular. The scoped
|
||||
// form lands on Mojeek, which honours `site:` where Bing does not.
|
||||
var siteFor = map[string]string{
|
||||
"academic": "arxiv.org",
|
||||
"github": "github.com",
|
||||
"reddit": "reddit.com",
|
||||
"x": "x.com",
|
||||
}
|
||||
|
||||
+43
-16
@@ -139,23 +139,35 @@ func init() {
|
||||
"under the caller's own credentials, and only then is the result narrated. So the "+
|
||||
"figures and their sources are the domain's, resolved before any model call and never "+
|
||||
"altered by one — a wrong answer is a wrong query, never an invention.\n\n"+
|
||||
"Domains: books (the org's ledger) and web (search, news, research, deep). A validated "+
|
||||
"principal is required; the answer is scoped to that principal's org and nothing else.")
|
||||
"Domains: books (the org's ledger), projects (what is built and what of it is deployed), "+
|
||||
"git (the org's repositories and what changed in them), and web (search, news, research, "+
|
||||
"deep). A validated principal is required; the answer is scoped to that principal's org "+
|
||||
"and nothing else.")
|
||||
}
|
||||
|
||||
// Mount wires POST /v1/ask into cloud, building the contributor registry (books today) over the
|
||||
// SAME app so a contributor's Gather replays a domain's grounded read in-process. The narration
|
||||
// model comes from deps.AI. Mount is a distinct route, so it wins Fiber's first-match over the
|
||||
// ai /v1/* catch-all.
|
||||
// Mount wires POST /v1/ask into cloud, building the contributor registry from domains() —
|
||||
// every domain a PEER asked over the internal plane, because this app ships as its own
|
||||
// process and the domains it grounds in do not run in it. The narration model comes from
|
||||
// deps.AI. Mount is a distinct route, so it wins Fiber's first-match over the ai /v1/*
|
||||
// catch-all.
|
||||
//
|
||||
// app is not handed to the registry: a contributor reaches its domain by NAME over the
|
||||
// plane, so there is nothing for it to do with this process's router. Keeping the router
|
||||
// out of the seam is what makes "which process owns that data" stop being the advisor's
|
||||
// problem.
|
||||
func Mount(app cloud.Router, deps cloud.Deps) error {
|
||||
b := cloud.NewBase(deps, "ask")
|
||||
svc := &cloud.Service[*state]{Base: b, State: &state{
|
||||
registry: NewRegistry(newBooksContributor(app)),
|
||||
registry: NewRegistry(domains()...),
|
||||
ai: deps.AI,
|
||||
model: cloud.DefaultModel,
|
||||
}}
|
||||
app.Post("/v1/ask", cloud.Handle(svc, askHandler))
|
||||
b.Log.Info("ask mounted", "prefix", "/v1/ask", "domains", "books,web", "web_modes", "search,news,research,deep")
|
||||
// The SAME answer engine, at an address an agent can speak. See web.go.
|
||||
if err := mountWeb(app, svc.State, b); err != nil {
|
||||
return err
|
||||
}
|
||||
b.Log.Info("ask mounted", "prefix", "/v1/ask", "domains", "books,projects,git,web", "web_modes", "search,news,research,deep")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -196,10 +208,19 @@ func askHandler(s *cloud.Service[*state], c *zip.Ctx) error {
|
||||
return askJSON(c, honestFallback())
|
||||
}
|
||||
|
||||
// Gather the REAL figures in-process, under the caller's OWN credentials (so the read is
|
||||
// scoped to the caller's org and no other). A gather error degrades to the honest fallback —
|
||||
// never a guessed number.
|
||||
facts, sources, err := domain.Gather(c.Context(), credential(c))
|
||||
// Gather the REAL figures from the domain, AS THIS CALLER — so the read is scoped to the
|
||||
// caller's own org and no other. A gather error degrades to the honest fallback — never a
|
||||
// guessed number.
|
||||
//
|
||||
// cloud.As(c, "") and not c.Context(), and the difference is the whole tenancy story on
|
||||
// this path. This is an UNTYPED handler: zip attaches the in-flight request to the context
|
||||
// it hands a TYPED op, not to this one, so c.Context() answers "nobody is calling" and a
|
||||
// domain that correctly refuses an anonymous read would refuse every question ever asked.
|
||||
// As carries THIS request's principal — read off the headers the edge already validated,
|
||||
// which is also what the gate above just checked — onto a context the peer can read it
|
||||
// from. The empty org argument is "keep the caller's own tenant": there is no widening
|
||||
// here, and no place for one, because a question is only ever asked about the asker.
|
||||
facts, sources, err := domain.Gather(cloud.As(c, ""), credential(c))
|
||||
if err != nil {
|
||||
s.Log.Warn("ask gather failed", "domain", domain.Name(), "err", err)
|
||||
return askJSON(c, honestFallback())
|
||||
@@ -305,22 +326,28 @@ func templateAnswer(facts []Fact) string {
|
||||
// figure the advisor cannot ground is a figure it must not state.
|
||||
func honestFallback() askAnswer {
|
||||
return askAnswer{
|
||||
Answer: "I can answer questions about your finances today — MRR, revenue, burn, runway, margin, cash, and P&L. Infra and usage advisors are coming.",
|
||||
Answer: "I can answer questions about your finances (MRR, revenue, burn, runway, margin, cash, P&L), " +
|
||||
"your projects and what of them is deployed, and your repositories and what changed in them.",
|
||||
Figures: []Fact{},
|
||||
Followups: []string{"What's my MRR?", "How long is my runway?", "What is my gross margin?"},
|
||||
Followups: []string{"What's my MRR?", "What have I deployed?", "How many repositories do I have?"},
|
||||
Sources: []string{},
|
||||
Domain: "",
|
||||
}
|
||||
}
|
||||
|
||||
// followups returns sharp next questions for a domain — deterministic, so the advisor always
|
||||
// offers a path forward. Extended per domain as new contributors join.
|
||||
// offers a path forward. One case per contributor; the default is the cross-domain menu, which
|
||||
// is also what a caller sees when no domain matched.
|
||||
func followups(domain string) []string {
|
||||
switch domain {
|
||||
case "books":
|
||||
return []string{"How long is my runway?", "What is my gross margin?", "How much of revenue is recurring?"}
|
||||
case "projects":
|
||||
return []string{"Which projects are live?", "What did I deploy most recently?", "How many repositories do I have?"}
|
||||
case "git":
|
||||
return []string{"Which repositories changed recently?", "What have I deployed?", "How much code do I have?"}
|
||||
default:
|
||||
return []string{"What's my MRR?", "How long is my runway?"}
|
||||
return []string{"What's my MRR?", "What have I deployed?", "How many repositories do I have?"}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+188
-63
@@ -1,17 +1,26 @@
|
||||
package ask
|
||||
|
||||
// ask_test.go — proofs for the unified grounded advisor. The whole point is GROUNDING: every
|
||||
// figure the advisor states is a REAL value read from a domain endpoint in-process; the model
|
||||
// only narrates the figures it is handed and can NEVER override one. These tests stand up a fake
|
||||
// in-process app whose /v1/books/metrics returns known figures scoped to the org the replay
|
||||
// carries, plus a recording fakeAI, and assert:
|
||||
// figure the advisor states is a REAL value read from a domain, over the internal plane; the
|
||||
// model only narrates the figures it is handed and can NEVER override one.
|
||||
//
|
||||
// 1. a financial question returns the REAL figure the books read produced, cited in sources;
|
||||
// 2. the model is fed the EXACT figure (the prompt contains it) and a hallucinated number in the
|
||||
// model's reply does NOT override the grounded figure the caller receives;
|
||||
// The domains here are STAND-IN PEERS, not stand-in transports: each test registers a real op
|
||||
// on the real plane under the domain's real app name and operation id, so the advisor reaches
|
||||
// them through the same generated client (plane/books, plane/projects, plane/git) it uses in
|
||||
// production. What the fakes replace is the STORE behind the op, never the path to it — which
|
||||
// is the distinction the previous version of this file got wrong. It faked the transport too,
|
||||
// mounting /v1/books/metrics on the advisor's own router, and so it passed for months while
|
||||
// production answered every question from the fallback: the advisor ships as its own process
|
||||
// and never had that route.
|
||||
//
|
||||
// They assert:
|
||||
//
|
||||
// 1. a financial question returns the REAL figure the books peer produced, cited in sources;
|
||||
// 2. the model is fed the EXACT figure and a hallucinated number does NOT override it;
|
||||
// 3. a non-groundable question returns the honest fallback with ZERO fabricated figures;
|
||||
// 4. org isolation — the replay carries the CALLER's org, so a books read can only ever surface
|
||||
// the caller's own org's data, never another's.
|
||||
// 4. org isolation — the peer is answered for the CALLER's org, so a domain read can only
|
||||
// ever surface the caller's own org's data, never another's;
|
||||
// 5. every wired domain — books, projects, git — actually contributes.
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -19,11 +28,13 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/apps/principal"
|
||||
"github.com/hanzoai/cloud/plane"
|
||||
"github.com/hanzoai/cloud/types"
|
||||
luxlog "github.com/luxfi/log"
|
||||
"github.com/zap-proto/zip"
|
||||
@@ -46,40 +57,89 @@ func (r *recordingAI) Embed(_ context.Context, _ *types.EmbedRequest) ([][]float
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// fakeBooks mounts a stand-in GET /v1/books/metrics that returns figures scoped to the org it
|
||||
// SEES on the request — the same principal.Org gate the real books read uses. A figure is tagged
|
||||
// with the org, so a test can prove the advisor only ever surfaces the caller's own org's data.
|
||||
// This is the grounded read the books contributor replays in-process.
|
||||
func fakeBooks(app *zip.App, mrrByOrg map[string]string) {
|
||||
app.Get("/v1/books/metrics", func(c *zip.Ctx) error {
|
||||
org, ok := principal.Org(c)
|
||||
if !ok {
|
||||
return zip.ErrUnauthorized("sign in")
|
||||
}
|
||||
mrr, seen := mrrByOrg[org]
|
||||
if !seen {
|
||||
mrr = "$0"
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"figures": []Fact{
|
||||
{Label: "MRR", Value: mrr, Period: "2026-07"},
|
||||
{Label: "org-echo", Value: org, Period: "2026-07"},
|
||||
},
|
||||
})
|
||||
})
|
||||
// byOrg is a stand-in domain store: the figures each org holds. A peer built over it answers
|
||||
// for the org the CALLER was, which is what makes the isolation proof mean something.
|
||||
type byOrg map[string][]plane.Figure
|
||||
|
||||
// peer declares one stand-in domain on the real plane, under the real app name and the real
|
||||
// operation id — so plane.Ask resolves it exactly as it resolves the live app.
|
||||
//
|
||||
// The handler derives the org the SAME way every real figures op does (cloud.Who(ctx).Org,
|
||||
// anonymous refused). Nothing in the test hands it an org: it reads the one zip carried from
|
||||
// the advisor's own in-flight request, which is the mechanism under proof.
|
||||
//
|
||||
// Declaring and SERVING are separate steps because the plane app freezes the first time it
|
||||
// listens: every op has to be on it before any socket is bound, which is the same order Serve
|
||||
// uses in production (mount everything, then bind).
|
||||
func peer(app, path, opID string, data byOrg) {
|
||||
zip.Post[plane.FiguresIn, plane.FiguresOut](cloud.Plane(), path,
|
||||
func(ctx context.Context, _ *plane.FiguresIn) (*plane.FiguresOut, error) {
|
||||
org := cloud.Who(ctx).Org
|
||||
if org == "" {
|
||||
return nil, zip.ErrForbidden(app + " figures: org required")
|
||||
}
|
||||
figs := data[org]
|
||||
// An org this store has never heard of holds nothing — an empty slice, never
|
||||
// another org's rows and never an error.
|
||||
return &plane.FiguresOut{Figures: append([]plane.Figure(nil), figs...)}, nil
|
||||
},
|
||||
zip.WithOperationID(opID))
|
||||
}
|
||||
|
||||
// newAskApp stands up an app with the fake books read + the ask advisor over a recording AI.
|
||||
func newAskApp(t *testing.T, ai types.AIClient, mrrByOrg map[string]string) *zip.App {
|
||||
// servePeers binds the plane socket for each named domain, after every op is declared.
|
||||
func servePeers(t *testing.T, names ...string) {
|
||||
t.Helper()
|
||||
for _, n := range names {
|
||||
stop, err := cloud.ServePlane(n, luxlog.New("test"))
|
||||
if err != nil {
|
||||
t.Fatalf("serve plane %s: %v", n, err)
|
||||
}
|
||||
t.Cleanup(func() { _ = stop() })
|
||||
}
|
||||
}
|
||||
|
||||
// newAskApp stands up the advisor over a recording AI, with stand-in books/projects/git peers
|
||||
// on the plane. Each test gets its own runtime dir and its own plane, so no test is ever
|
||||
// answered by a previous test's handler.
|
||||
func newAskApp(t *testing.T, ai types.AIClient, books, projects, git byOrg) *zip.App {
|
||||
t.Helper()
|
||||
// A short run dir: a unix socket path is capped near 104 bytes and t.TempDir() spends
|
||||
// most of that on the test's own name.
|
||||
dir, err := os.MkdirTemp("", "askp")
|
||||
if err != nil {
|
||||
t.Fatalf("tempdir: %v", err)
|
||||
}
|
||||
// ZIP_RUNTIME_DIR and not CLOUD_RUN_DIR: an operator's own runtime dir wins
|
||||
// unconditionally, whereas CLOUD_RUN_DIR is consulted only when nothing has bound
|
||||
// yet — and something always has by the second test, so every test after the first
|
||||
// would keep the first one's sockets and be answered by its handlers.
|
||||
t.Setenv("ZIP_RUNTIME_DIR", dir)
|
||||
plane.Unbind()
|
||||
cloud.ResetPlane()
|
||||
t.Cleanup(func() {
|
||||
cloud.ResetPlane()
|
||||
plane.Unbind()
|
||||
_ = os.RemoveAll(dir)
|
||||
})
|
||||
|
||||
peer("books", "/books/figures", plane.BooksFigures, books)
|
||||
peer("projects", "/projects/figures", plane.ProjectsFigures, projects)
|
||||
peer("git", "/git/figures", plane.GitFigures, git)
|
||||
servePeers(t, "books", "projects", "git")
|
||||
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test"), DisableStartupMessage: true})
|
||||
fakeBooks(app, mrrByOrg)
|
||||
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir(), AI: ai}); err != nil {
|
||||
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test"), DataDir: filepath.Join(dir, "data"), AI: ai}); err != nil {
|
||||
t.Fatalf("Mount: %v", err)
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
// money is the one-line stand-in ledger used where the test is about the ADVISOR rather than
|
||||
// about a particular domain.
|
||||
func money(mrr string) []plane.Figure {
|
||||
return []plane.Figure{{Label: "MRR", Value: mrr, Period: "2026-07"}}
|
||||
}
|
||||
|
||||
// ask POSTs a question as a VALIDATED principal for org (X-User-Id set, exactly as the gateway
|
||||
// mints it — the test app has no sanitizer). Empty org exercises the anonymous 403 path.
|
||||
func ask(t *testing.T, app *zip.App, org, question string) (int, askAnswer) {
|
||||
@@ -124,11 +184,11 @@ func hasSource(r askAnswer, src string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// TestGroundedFinancialAnswer: a financial question returns the REAL figure the books read
|
||||
// TestGroundedFinancialAnswer: a financial question returns the REAL figure the books peer
|
||||
// produced, tagged to the books domain and cited in sources.
|
||||
func TestGroundedFinancialAnswer(t *testing.T) {
|
||||
ai := &recordingAI{reply: "Your MRR is $4,200 for July."}
|
||||
app := newAskApp(t, ai, map[string]string{"acme": "$4,200"})
|
||||
app := newAskApp(t, ai, byOrg{"acme": money("$4,200")}, nil, nil)
|
||||
|
||||
code, r := ask(t, app, "acme", "what is my MRR right now?")
|
||||
if code != http.StatusOK {
|
||||
@@ -140,19 +200,47 @@ func TestGroundedFinancialAnswer(t *testing.T) {
|
||||
if v, ok := figure(r, "MRR"); !ok || v != "$4,200" {
|
||||
t.Fatalf("MRR figure must be the real $4,200 from the books read, got %q (ok=%v)", v, ok)
|
||||
}
|
||||
if !hasSource(r, "books/metrics") {
|
||||
t.Fatalf("answer must cite the books/metrics read, got %v", r.Sources)
|
||||
if !hasSource(r, "books/figures") {
|
||||
t.Fatalf("answer must cite the books/figures read, got %v", r.Sources)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEveryWiredDomainContributes is the anti-regression for the defect this file was rewritten
|
||||
// over: a domain in the registry that cannot actually be REACHED is worse than an absent one,
|
||||
// because it degrades to the fallback and looks like "no domain matched". Each wired domain
|
||||
// must route, return its own figure, and cite its own source.
|
||||
func TestEveryWiredDomainContributes(t *testing.T) {
|
||||
app := newAskApp(t, nil,
|
||||
byOrg{"acme": money("$4,200")},
|
||||
byOrg{"acme": {{Label: "Projects", Value: "7"}, {Label: "Deployed and serving", Value: "3"}}},
|
||||
byOrg{"acme": {{Label: "Repositories", Value: "12"}, {Label: "Code stored", Value: "48.0 MB"}}},
|
||||
)
|
||||
for _, tc := range []struct{ question, domain, source, label, want string }{
|
||||
{"what is my MRR?", "books", "books/figures", "MRR", "$4,200"},
|
||||
{"what have I deployed?", "projects", "projects/figures", "Deployed and serving", "3"},
|
||||
{"how many repositories do I have?", "git", "git/figures", "Repositories", "12"},
|
||||
} {
|
||||
_, r := ask(t, app, "acme", tc.question)
|
||||
if r.Domain != tc.domain {
|
||||
t.Fatalf("%q must route to %q, got %q (answer=%q)", tc.question, tc.domain, r.Domain, r.Answer)
|
||||
}
|
||||
if v, ok := figure(r, tc.label); !ok || v != tc.want {
|
||||
t.Fatalf("%q must state the real %s=%s, got %q (ok=%v)", tc.question, tc.label, tc.want, v, ok)
|
||||
}
|
||||
if !hasSource(r, tc.source) {
|
||||
t.Fatalf("%q must cite %s, got %v", tc.question, tc.source, r.Sources)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelFedExactFigureAndCannotOverride is THE grounding proof: the model is handed the EXACT
|
||||
// figure in its prompt, and even when it replies with a HALLUCINATED number the grounded figure
|
||||
// the caller receives is unchanged. The prose may carry the model's words; the figures array is
|
||||
// the ledger's, never the model's.
|
||||
// the domain's, never the model's.
|
||||
func TestModelFedExactFigureAndCannotOverride(t *testing.T) {
|
||||
// The model hallucinates $9,999 in its narration — a number that is NOT the real figure.
|
||||
ai := &recordingAI{reply: "Your MRR is a whopping $9,999 this month!"}
|
||||
app := newAskApp(t, ai, map[string]string{"acme": "$4,200"})
|
||||
app := newAskApp(t, ai, byOrg{"acme": money("$4,200")}, nil, nil)
|
||||
|
||||
_, r := ask(t, app, "acme", "how's my recurring revenue?")
|
||||
|
||||
@@ -173,7 +261,7 @@ func TestModelFedExactFigureAndCannotOverride(t *testing.T) {
|
||||
// TestNoModelStillGrounded: with no AI wired the advisor still answers with the REAL figures — the
|
||||
// deterministic template states them, so the numbers are identical whether the model is up or down.
|
||||
func TestNoModelStillGrounded(t *testing.T) {
|
||||
app := newAskApp(t, nil, map[string]string{"acme": "$4,200"})
|
||||
app := newAskApp(t, nil, byOrg{"acme": money("$4,200")}, nil, nil)
|
||||
_, r := ask(t, app, "acme", "what's my mrr?")
|
||||
if v, _ := figure(r, "MRR"); v != "$4,200" {
|
||||
t.Fatalf("figure must be the real $4,200 with no model, got %q", v)
|
||||
@@ -187,9 +275,9 @@ func TestNoModelStillGrounded(t *testing.T) {
|
||||
// it names what the advisor CAN answer and carries ZERO figures. It must NEVER invent a number.
|
||||
func TestHonestFallbackNoFabrication(t *testing.T) {
|
||||
ai := &recordingAI{reply: "42 widgets shipped."} // the model would happily make something up
|
||||
app := newAskApp(t, ai, map[string]string{"acme": "$4,200"})
|
||||
app := newAskApp(t, ai, byOrg{"acme": money("$4,200")}, nil, nil)
|
||||
|
||||
code, r := ask(t, app, "acme", "how many widgets did we ship to Mars?")
|
||||
code, r := ask(t, app, "acme", "how many widgets did we sell on Mars?")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d", code)
|
||||
}
|
||||
@@ -202,24 +290,27 @@ func TestHonestFallbackNoFabrication(t *testing.T) {
|
||||
if len(r.Sources) != 0 {
|
||||
t.Fatalf("the fallback must cite no sources, got %v", r.Sources)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(r.Answer), "finance") {
|
||||
if !strings.Contains(strings.ToLower(r.Answer), "financ") {
|
||||
t.Fatalf("the fallback must name what it CAN answer, got %q", r.Answer)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOrgIsolation proves the in-process replay carries the CALLER's org: acme sees acme's figure,
|
||||
// beta sees beta's, and neither can ever surface the other's data — tenant isolation is inherited
|
||||
// from the caller's own creds on the replay, never re-implemented.
|
||||
// TestOrgIsolation proves the domain read is answered for the CALLER's org: acme sees acme's
|
||||
// figures, beta sees beta's, and neither can ever surface the other's. Nothing in the advisor
|
||||
// states a tenant — the identity zip forwards off the caller's own request is the whole of it,
|
||||
// which is why there is no argument here a caller could have supplied instead.
|
||||
func TestOrgIsolation(t *testing.T) {
|
||||
app := newAskApp(t, nil, map[string]string{"acme": "$4,200", "beta": "$77,000"})
|
||||
app := newAskApp(t, nil,
|
||||
byOrg{"acme": money("$4,200"), "beta": money("$77,000")},
|
||||
byOrg{
|
||||
"acme": {{Label: "Projects", Value: "7"}},
|
||||
"beta": {{Label: "Projects", Value: "999"}},
|
||||
}, nil)
|
||||
|
||||
_, a := ask(t, app, "acme", "what's my mrr?")
|
||||
if v, _ := figure(a, "MRR"); v != "$4,200" {
|
||||
t.Fatalf("acme must see its own $4,200, got %q", v)
|
||||
}
|
||||
if echo, _ := figure(a, "org-echo"); echo != "acme" {
|
||||
t.Fatalf("the books read must have been scoped to acme, got org-echo=%q", echo)
|
||||
}
|
||||
|
||||
_, b := ask(t, app, "beta", "what's my mrr?")
|
||||
if v, _ := figure(b, "MRR"); v != "$77,000" {
|
||||
@@ -228,15 +319,35 @@ func TestOrgIsolation(t *testing.T) {
|
||||
if v, _ := figure(b, "MRR"); v == "$4,200" {
|
||||
t.Fatalf("beta must NEVER surface acme's $4,200")
|
||||
}
|
||||
if echo, _ := figure(b, "org-echo"); echo != "beta" {
|
||||
t.Fatalf("the books read must have been scoped to beta, got org-echo=%q", echo)
|
||||
|
||||
// The same rule on a second domain, because tenancy is a property of the SEAM and not of
|
||||
// one contributor that happened to get it right.
|
||||
_, ap := ask(t, app, "acme", "what have I deployed?")
|
||||
if v, _ := figure(ap, "Projects"); v != "7" {
|
||||
t.Fatalf("acme must see its own 7 projects, got %q", v)
|
||||
}
|
||||
_, bp := ask(t, app, "beta", "what have I deployed?")
|
||||
if v, _ := figure(bp, "Projects"); v != "999" {
|
||||
t.Fatalf("beta must see its own 999 projects, got %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownOrgGetsNothingNotSomebodyElses: an org the domain has never heard of is answered
|
||||
// with no figures — never a default, never the first org in the store.
|
||||
func TestUnknownOrgGetsNothingNotSomebodyElses(t *testing.T) {
|
||||
app := newAskApp(t, nil, byOrg{"acme": money("$4,200")}, nil, nil)
|
||||
_, r := ask(t, app, "stranger", "what's my mrr?")
|
||||
for _, f := range r.Figures {
|
||||
if strings.Contains(f.Value, "4,200") {
|
||||
t.Fatalf("an unknown org must never receive acme's figures, got %+v", r.Figures)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnonymousRefused: /v1/ask is a data plane — a request with no validated principal is 401,
|
||||
// so an off-gateway forge can neither probe nor read a ledger through the advisor.
|
||||
func TestAnonymousRefused(t *testing.T) {
|
||||
app := newAskApp(t, nil, map[string]string{"acme": "$4,200"})
|
||||
app := newAskApp(t, nil, byOrg{"acme": money("$4,200")}, nil, nil)
|
||||
// Forged X-Org-Id with NO X-User-Id (no validated principal) — the anonymous forge.
|
||||
body, _ := json.Marshal(askRequest{Question: "what's my mrr?"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/ask", strings.NewReader(string(body)))
|
||||
@@ -252,16 +363,30 @@ func TestAnonymousRefused(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifierMatchesFinancialVocab locks the books classifier: the founder-vocabulary that
|
||||
// grounds against the ledger routes to books, and off-topic questions do not.
|
||||
func TestClassifierMatchesFinancialVocab(t *testing.T) {
|
||||
reg := NewRegistry(newBooksContributor(nil))
|
||||
for _, q := range []string{"what's my MRR?", "how long is my runway", "are we profitable?", "how much cash do we have", "what's my gross margin", "show me the P&L", "how much did we make"} {
|
||||
if reg.Match(q) == nil {
|
||||
t.Fatalf("financial question %q must match the books contributor", q)
|
||||
// TestClassifierRoutesEachDomainsVocab locks the classifiers: each domain's vocabulary routes to
|
||||
// it, and off-topic questions match nothing at all rather than being swept into whichever domain
|
||||
// happens to be first.
|
||||
func TestClassifierRoutesEachDomainsVocab(t *testing.T) {
|
||||
reg := NewRegistry(domains()...)
|
||||
for _, tc := range []struct {
|
||||
want string
|
||||
questions []string
|
||||
}{
|
||||
{"books", []string{"what's my MRR?", "how long is my runway", "are we profitable?", "how much cash do we have", "what's my gross margin", "show me the P&L", "how much did we make"}},
|
||||
{"projects", []string{"what have I deployed?", "which projects are live", "what sites have I published", "what is running in production", "what did we ship"}},
|
||||
{"git", []string{"how many repositories do I have?", "what changed recently", "how much code do we have", "list my repos", "which branches are there"}},
|
||||
} {
|
||||
for _, q := range tc.questions {
|
||||
c := reg.Match(q)
|
||||
if c == nil {
|
||||
t.Fatalf("%q must match the %s contributor, matched nothing", q, tc.want)
|
||||
}
|
||||
if c.Name() != tc.want {
|
||||
t.Fatalf("%q must route to %s, routed to %s", q, tc.want, c.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, q := range []string{"what's the weather", "how many users signed up", "deploy the app"} {
|
||||
for _, q := range []string{"what's the weather", "how many users signed up", "who is the CEO of France"} {
|
||||
if c := reg.Match(q); c != nil {
|
||||
t.Fatalf("off-topic question %q must NOT match any domain, matched %q", q, c.Name())
|
||||
}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
package ask
|
||||
|
||||
// books.go — the FIRST contributor: BOOKS (financial). It grounds MRR/ARR/revenue/burn/runway/
|
||||
// margin/cash/deferred-revenue/P&L/balance questions by replaying the books domain's OWN
|
||||
// grounded read — GET /v1/books/metrics — in-process under the caller's own credentials, then
|
||||
// surfacing the REAL figures it returns. It NEVER recomputes: books stays the single source of
|
||||
// the numbers AND their formatting (the endpoint returns formatUSD-formatted figures). Per-tenant
|
||||
// isolation is inherited from the replay carrying the caller's own X-Org-Id — a books read can
|
||||
// only ever return the caller's own org's ledger.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
fiber "github.com/zap-proto/fiber/v3"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
)
|
||||
|
||||
// booksMetricsPath is the books domain's grounded read the contributor replays. It is the ONE
|
||||
// endpoint that returns the deterministic metric snapshot as formatted figures.
|
||||
const booksMetricsPath = "/v1/books/metrics"
|
||||
|
||||
// maxMetricsResponse bounds the in-process read so a broken upstream cannot balloon memory.
|
||||
const maxMetricsResponse = 1 << 20
|
||||
|
||||
// booksContributor grounds financial questions against the books domain. It holds the app it
|
||||
// replays against (the SAME binary; the read flows the normal middleware chain), exactly as
|
||||
// agent.go's aiCompleter holds the app to replay /v1/chat/completions.
|
||||
type booksContributor struct{ app cloud.Router }
|
||||
|
||||
func newBooksContributor(app cloud.Router) *booksContributor { return &booksContributor{app: app} }
|
||||
|
||||
func (booksContributor) Name() string { return "books" }
|
||||
|
||||
// CanAnswer is the books classifier: does the question ask about money the ledger knows? Keyword
|
||||
// match over the founder-vocabulary the metrics engine grounds. First-match in the registry, so
|
||||
// a books question routes here deterministically. An LLM classifier can replace this body later
|
||||
// without touching the router or the seam.
|
||||
func (booksContributor) CanAnswer(question string) bool {
|
||||
l := strings.ToLower(question)
|
||||
for _, kw := range booksKeywords {
|
||||
if strings.Contains(l, kw) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// booksKeywords is the financial vocabulary that routes a question to the books ledger. It
|
||||
// mirrors the books intent router's keywords so /v1/ask grounds exactly what /v1/books/ask does.
|
||||
var booksKeywords = []string{
|
||||
"mrr", "arr", "recurring", "subscription", "annualized",
|
||||
"revenue", "sales", "top line", "income", "how much did we make", "how much money",
|
||||
"burn", "spend", "spending", "expense", "expenses", "opex", "costs", "cost of",
|
||||
"runway", "how long", "cash last", "out of money", "out of cash",
|
||||
"margin", "profitab", "profit", "net income", "bottom line", "earnings", "break even", "break-even",
|
||||
"cash", "bank", "in the bank", "balance", "cogs",
|
||||
"deferred", "wallet", "prepaid", "liabilit", "owe",
|
||||
"p&l", "pnl", "p and l", "financ",
|
||||
}
|
||||
|
||||
// Gather replays GET /v1/books/metrics in-process under the caller's own credentials and returns
|
||||
// the REAL figures it read, with the domain read that backed them. The figures are books' own —
|
||||
// this contributor formats nothing and invents nothing; an empty ledger yields honest zero
|
||||
// figures. The caller's creds carry the caller's org, so the read is scoped to that org and no
|
||||
// other — tenant isolation is inherited, never re-implemented here.
|
||||
func (b booksContributor) Gather(ctx context.Context, cred map[string]string) ([]Fact, []string, error) {
|
||||
req := httptest.NewRequest(http.MethodGet, booksMetricsPath, nil).WithContext(ctx)
|
||||
for k, v := range cred {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := b.app.Fiber().Test(req, fiber.TestConfig{Timeout: 0})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("books metrics replay: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return nil, nil, fmt.Errorf("books metrics status %d", resp.StatusCode)
|
||||
}
|
||||
var out struct {
|
||||
Figures []Fact `json:"figures"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, maxMetricsResponse)).Decode(&out); err != nil {
|
||||
return nil, nil, fmt.Errorf("books metrics decode: %w", err)
|
||||
}
|
||||
return out.Figures, []string{"books/metrics"}, nil
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package ask
|
||||
|
||||
// domains.go — the domains the advisor can ground a question in.
|
||||
//
|
||||
// There is ONE contributor type here, not one per domain, because the domains
|
||||
// differ in exactly two things: which questions they recognise, and which peer
|
||||
// they ask. Everything else — refuse anonymous, read the figures, name the
|
||||
// source — is the same sentence written once. Adding a domain is a value in the
|
||||
// registry, never a new type and never a router edit.
|
||||
//
|
||||
// EVERY DOMAIN IS A PLANE CALL, and that is the whole correction this file
|
||||
// carries. /v1/ask ships as its own plugin binary (plugin/ask/main.go mounts
|
||||
// ask.Mount and nothing else), so the pod runs the advisor and every domain it
|
||||
// asks as separate pids. The first contributor read its figures by replaying an
|
||||
// HTTP request against the advisor's OWN router — which in production holds one
|
||||
// route, /v1/ask — so the read 404'd, the gather failed, and every money
|
||||
// question in production was answered by the "I can answer questions about your
|
||||
// finances" fallback with an empty figures array, while books sat healthy one
|
||||
// socket away. An in-process replay cannot cross a process boundary. A plane
|
||||
// call is the one thing that can.
|
||||
//
|
||||
// TENANCY. The org rides the CALLER and is never an argument: [plane.FiguresIn]
|
||||
// is an empty struct, and zip forwards the gateway's own assertion off the
|
||||
// in-flight request to the peer (zip caller.go, forwardIdentity). So a
|
||||
// contributor cannot name an org, cannot be handed one, and cannot widen the one
|
||||
// it was called with — the peer answers for whoever the edge said was asking,
|
||||
// and refuses when that is nobody. Nothing here states a tenant, deliberately:
|
||||
// cloud.For on a context with a request behind it is a silent no-op, so code
|
||||
// that appeared to set the org would read as correct and scope nothing.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud/plane"
|
||||
booksplane "github.com/hanzoai/cloud/plane/books"
|
||||
gitplane "github.com/hanzoai/cloud/plane/git"
|
||||
projectsplane "github.com/hanzoai/cloud/plane/projects"
|
||||
)
|
||||
|
||||
// domain is one grounded peer behind the advisor: the name the answer is tagged
|
||||
// with, the vocabulary that routes a question to it, the read it names as its
|
||||
// source, and the typed plane client that performs it.
|
||||
//
|
||||
// ask is the GENERATED client function (plane/<app>), never plane.Ask with loose
|
||||
// strings — the compiler is what checks that an op name belongs to the app it is
|
||||
// sent to and that In and Out are the pair that op declared.
|
||||
type domain struct {
|
||||
name string
|
||||
source string
|
||||
keywords []string
|
||||
ask func(context.Context, *plane.FiguresIn) (*plane.FiguresOut, error)
|
||||
}
|
||||
|
||||
func (d domain) Name() string { return d.name }
|
||||
|
||||
// CanAnswer is the classifier: does this domain's vocabulary appear in the
|
||||
// question? Deterministic keyword match, first-match-wins in registry order. An
|
||||
// LLM classifier can replace this body without touching the seam or the router.
|
||||
func (d domain) CanAnswer(question string) bool {
|
||||
l := strings.ToLower(question)
|
||||
for _, kw := range d.keywords {
|
||||
if strings.Contains(l, kw) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Gather reads the domain's REAL figures over the internal plane, as the caller.
|
||||
//
|
||||
// cred is ignored, and its absence from this body is the point: the identity is
|
||||
// already on the context zip hands the peer, so a credential copied by hand here
|
||||
// would be a second, weaker answer to a question the transport has already
|
||||
// answered. The parameter stays because it is the seam's, not this domain's.
|
||||
//
|
||||
// An empty org answers an empty figures slice — the domain read succeeded and
|
||||
// the org has nothing — which the advisor narrates honestly. A FAILURE returns
|
||||
// an error and the advisor falls back rather than stating a number it could not
|
||||
// read.
|
||||
func (d domain) Gather(ctx context.Context, _ map[string]string) ([]Fact, []string, error) {
|
||||
out, err := d.ask(ctx, &plane.FiguresIn{})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
facts := make([]Fact, 0, len(out.Figures))
|
||||
for _, f := range out.Figures {
|
||||
facts = append(facts, Fact{Label: f.Label, Value: f.Value, Period: f.Period})
|
||||
}
|
||||
return facts, []string{d.source}, nil
|
||||
}
|
||||
|
||||
// domains is the advisor's registry contents, in classification order. Money
|
||||
// first: it is the most-asked question and its vocabulary is the most specific,
|
||||
// so a question that is about money is never taken by a domain that merely
|
||||
// shares a word with it.
|
||||
func domains() []Contributor {
|
||||
return []Contributor{
|
||||
domain{
|
||||
name: "books",
|
||||
source: "books/figures",
|
||||
keywords: booksKeywords,
|
||||
ask: booksplane.BooksFigures,
|
||||
},
|
||||
domain{
|
||||
name: "projects",
|
||||
source: "projects/figures",
|
||||
keywords: projectKeywords,
|
||||
ask: projectsplane.ProjectsFigures,
|
||||
},
|
||||
domain{
|
||||
name: "git",
|
||||
source: "git/figures",
|
||||
keywords: gitKeywords,
|
||||
ask: gitplane.GitFigures,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// booksKeywords is the financial vocabulary that routes a question to the
|
||||
// ledger. It mirrors the books intent router's keywords so /v1/ask grounds
|
||||
// exactly what /v1/books/ask does.
|
||||
var booksKeywords = []string{
|
||||
"mrr", "arr", "recurring", "subscription", "annualized",
|
||||
"revenue", "sales", "top line", "income", "how much did we make", "how much money",
|
||||
"burn", "spend", "spending", "expense", "expenses", "opex", "costs", "cost of",
|
||||
"runway", "how long", "cash last", "out of money", "out of cash",
|
||||
"margin", "profitab", "profit", "net income", "bottom line", "earnings", "break even", "break-even",
|
||||
"cash", "bank", "in the bank", "balance", "cogs",
|
||||
"deferred", "wallet", "prepaid", "liabilit", "owe",
|
||||
"p&l", "pnl", "p and l", "financ",
|
||||
}
|
||||
|
||||
// projectKeywords is the vocabulary of what the org has BUILT and what of it is
|
||||
// serving — the "what have we shipped" question.
|
||||
var projectKeywords = []string{
|
||||
"project", "deploy", "deployed", "deployment", "shipped", "ship",
|
||||
"site", "sites", "website", "web site", "published", "publish",
|
||||
"live", "serving", "in production", "hosting", "hosted",
|
||||
}
|
||||
|
||||
// gitKeywords is the vocabulary of the org's SOURCE — how much there is and what
|
||||
// moved lately.
|
||||
var gitKeywords = []string{
|
||||
"repo", "repos", "repositor", "git", "codebase", "source code",
|
||||
"commit", "commits", "branch", "branches", "merge", "pushed",
|
||||
"how much code", "lines of code", "what changed", "recently updated",
|
||||
}
|
||||
@@ -9,26 +9,18 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/openapi"
|
||||
luxlog "github.com/luxfi/log"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// askApp mounts the door with a stubbed books contributor and a stubbed model, on
|
||||
// a bare app — the same harness the behaviour suite uses.
|
||||
// askApp mounts the door with a stand-in books peer and a stubbed model — the
|
||||
// same harness the behaviour suite uses, which is the point: one harness, so a
|
||||
// wire proof and a behaviour proof are made against the same door.
|
||||
func askApp(t *testing.T) *zip.App {
|
||||
t.Helper()
|
||||
noNetworkSearch(t)
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test"), DisableStartupMessage: true})
|
||||
fakeBooks(app, map[string]string{"acme": "$4,200"})
|
||||
if err := Mount(app, cloud.Deps{
|
||||
Logger: luxlog.New("test"), DataDir: t.TempDir(),
|
||||
AI: &webAI{answer: "Clojure was created by Rich Hickey."},
|
||||
}); err != nil {
|
||||
t.Fatalf("Mount: %v", err)
|
||||
}
|
||||
return app
|
||||
return newAskApp(t, &webAI{answer: "Clojure was created by Rich Hickey."},
|
||||
byOrg{"acme": money("$4,200")}, nil, nil)
|
||||
}
|
||||
|
||||
func askRaw(t *testing.T, app *zip.App, body string, hdr map[string]string) *http.Response {
|
||||
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
// The researched answer, as a typed op — the door an agent can actually reach.
|
||||
//
|
||||
// The loop itself is not new and none of it lives here: apps/answer has run the
|
||||
// bounded plan → search → read → rank → synthesize → cite pass since it was
|
||||
// written, behind POST /v1/ask with a `mode`. What it did not have was a door a
|
||||
// MODEL could open. /v1/ask is untyped for three wire reasons that are all still
|
||||
// true (see the init in ask.go), and an untyped route reaches REST and nothing
|
||||
// else — no MCP tool, no CLI command, no SDK method. So the fleet's deep-research
|
||||
// capability was complete, correct, metered, and invisible to every agent in it.
|
||||
//
|
||||
// This is the same engine offered at an address the agent can speak, which is
|
||||
// exactly what apps/websearch already did for search: a compat door the model
|
||||
// cannot use, and beside it a native typed op running the SAME code. One engine,
|
||||
// two doors, no second implementation to drift.
|
||||
package ask
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/apps/answer"
|
||||
"github.com/hanzoai/cloud/apps/principal"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// Go drops comments at compile time, so cmd/zipdoc is the ONLY path from the
|
||||
// handler's prose to the published document, the SDKs and the MCP tool
|
||||
// description. Its output is committed; `make zipdoc-check` fails on drift.
|
||||
//
|
||||
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
|
||||
|
||||
// webPath is the researched answer's own address, under the prefix this app
|
||||
// already owns so the composed deployment routes it here without a manifest
|
||||
// change. /v1/research is NOT free — it belongs to the grants subsystem.
|
||||
const webPath = "/v1/ask/web"
|
||||
|
||||
// engine holds what the typed op runs on. A typed handler is handed a context
|
||||
// and an In and nothing else, so the engine has to be reachable from package
|
||||
// scope; Mount stores it and the handler loads it. Same shape apps/sandbox uses
|
||||
// for its own typed ops, and for the same reason — a named handler, never a
|
||||
// closure, is what zipdoc can lift prose from.
|
||||
var engine atomic.Pointer[answer.Engine]
|
||||
|
||||
// webQuestion is the POST /v1/ask/web body: the question, and the few knobs that
|
||||
// change how hard the engine looks for the answer.
|
||||
type webQuestion struct {
|
||||
// Q is the question, in plain language. Required.
|
||||
Q string `json:"q"`
|
||||
// Mode is how much work to do: `search` (fast, one pass), `news` (recency
|
||||
// biased), `research` (a plan and several rounds) or `deep` (the widest
|
||||
// survey). Empty means research.
|
||||
Mode string `json:"mode,omitempty"`
|
||||
// Sources narrows where the evidence comes from: any of `web`, `news`,
|
||||
// `academic`, `github`, `reddit`, `x`. Each becomes a site-scoped search, so
|
||||
// `["x"]` researches X/Twitter posts rather than the open web.
|
||||
Sources []string `json:"sources,omitempty"`
|
||||
// Language narrows the search to a locale, BCP-47-ish ("en", "ja"). Empty
|
||||
// means no narrowing.
|
||||
Language string `json:"language,omitempty"`
|
||||
// MaxSources caps how many pages are read. Empty means the mode's own budget.
|
||||
MaxSources int `json:"max_sources,omitempty"`
|
||||
}
|
||||
|
||||
// defaultMode is what an unspecified mode means. `research` and not `search`,
|
||||
// because a caller who reached for THIS op rather than search_web has already
|
||||
// said they want the reading done for them; a one-pass answer is what the other
|
||||
// op is.
|
||||
const defaultMode = "research"
|
||||
|
||||
// researchWeb researches a question on the live web and answers it with its
|
||||
// sources cited.
|
||||
//
|
||||
// This is the DEEP one. It plans the question into topics, runs several web
|
||||
// searches, FETCHES AND READS the pages it finds, ranks them, and writes a
|
||||
// grounded answer with inline markdown citations. Use it for anything that needs
|
||||
// evidence, comparison or current fact — "what changed in X", "compare A and B",
|
||||
// "is this claim true". For a plain list of links, use search_web instead; for
|
||||
// one page you already have the URL of, use read_page.
|
||||
//
|
||||
// `mode` buys depth: `search` is a single fast pass, `news` biases to recency,
|
||||
// `research` plans and iterates, `deep` surveys widest. `sources` narrows the
|
||||
// evidence to `web`, `news`, `academic`, `github`, `reddit` or `x` — each becomes
|
||||
// a site-scoped search, which is how this reaches X/Twitter posts.
|
||||
//
|
||||
// EVERY CITATION IS A PAGE THIS CALL FETCHED. That is a property of the text and
|
||||
// not an instruction to the model: each source is fenced with a per-request nonce
|
||||
// so a crawled page cannot print itself a source number, and every markdown link
|
||||
// in the answer is checked against the gathered set before it is returned. So a
|
||||
// link in `answer` always appears in `sources`, and a page that was not read
|
||||
// cannot be cited.
|
||||
//
|
||||
// It is BOUNDED and it degrades rather than failing: a mode's rounds, wall clock
|
||||
// and token ceiling all cap it, and a search that finds little or a page that
|
||||
// will not load yields a thinner answer, never an error. A validated principal is
|
||||
// required, and the answer is billed once to that principal's org.
|
||||
func researchWeb(ctx context.Context, in *webQuestion) (*answer.Report, error) {
|
||||
if !principal.ValidatedFrom(ctx) {
|
||||
return nil, zip.ErrUnauthorized("sign in to research the web")
|
||||
}
|
||||
e := engine.Load()
|
||||
if e == nil {
|
||||
return nil, zip.Errorf(503, "ask: the answer engine is not mounted")
|
||||
}
|
||||
q := strings.TrimSpace(in.Q)
|
||||
if q == "" {
|
||||
return nil, zip.ErrBadRequest("q required")
|
||||
}
|
||||
if len(q) > maxQuestion {
|
||||
q = q[:maxQuestion]
|
||||
}
|
||||
mode := strings.TrimSpace(in.Mode)
|
||||
if mode == "" {
|
||||
mode = defaultMode
|
||||
}
|
||||
if !answer.IsMode(mode) {
|
||||
return nil, zip.ErrBadRequest("mode must be one of search, news, research, deep")
|
||||
}
|
||||
return e.Answer(ctx, answer.Request{
|
||||
Mode: mode,
|
||||
Sources: in.Sources,
|
||||
Language: in.Language,
|
||||
MaxSources: in.MaxSources,
|
||||
}, q)
|
||||
}
|
||||
|
||||
// mountWeb stores the engine the typed op runs on and registers it.
|
||||
//
|
||||
// The registration is on the *zip.App and with an ABSOLUTE path, because that is
|
||||
// what zipdoc can resolve statically — it cannot follow a cloud.Router interface
|
||||
// to a prefix.
|
||||
func mountWeb(app cloud.Router, s *state, b cloud.Base) error {
|
||||
engine.Store(&answer.Engine{Base: b, AI: s.ai, Model: s.model})
|
||||
reg := cloud.ZipApp(app)
|
||||
if reg == nil {
|
||||
return nil // single-binary hosts without a typed registry keep the REST door
|
||||
}
|
||||
// Named, not derived. A POST to this path derives `create_ask_web`, which
|
||||
// reads as "make an ask web" — a resource nothing here has. `research_web` is
|
||||
// the verb over the noun, and it sits beside `search_web` so the two web verbs
|
||||
// read as the pair they are: search returns links, research returns an answer.
|
||||
zip.Post(reg, webPath, researchWeb,
|
||||
zip.WithOperationID("research_web"),
|
||||
zip.WithSummary("Research a question on the live web and answer it with sources cited"))
|
||||
return nil
|
||||
}
|
||||
@@ -14,10 +14,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/types"
|
||||
luxlog "github.com/luxfi/log"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// webAI answers the engine's synthesis prompt with a fixed string; the plan and
|
||||
@@ -49,11 +46,8 @@ func noNetworkSearch(t *testing.T) {
|
||||
// UNTOUCHED (a no-mode financial question still routes to books).
|
||||
func TestAskWebModeDispatch(t *testing.T) {
|
||||
noNetworkSearch(t)
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test"), DisableStartupMessage: true})
|
||||
fakeBooks(app, map[string]string{"acme": "$4,200"})
|
||||
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir(), AI: &webAI{answer: "Clojure was created by Rich Hickey."}}); err != nil {
|
||||
t.Fatalf("Mount: %v", err)
|
||||
}
|
||||
app := newAskApp(t, &webAI{answer: "Clojure was created by Rich Hickey."},
|
||||
byOrg{"acme": money("$4,200")}, nil, nil)
|
||||
|
||||
// web mode → the answer engine
|
||||
body, _ := json.Marshal(askRequest{Q: "who created clojure and why", Mode: "search"})
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// Code generated by zipdoc; DO NOT EDIT.
|
||||
|
||||
package ask
|
||||
|
||||
import (
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
func init() {
|
||||
zip.Describe("POST /v1/ask/web", zip.Doc{
|
||||
Description: "Researches a question on the live web and answers it with its\nsources cited.\n\nThis is the DEEP one. It plans the question into topics, runs several web\nsearches, FETCHES AND READS the pages it finds, ranks them, and writes a\ngrounded answer with inline markdown citations. Use it for anything that needs\nevidence, comparison or current fact — \"what changed in X\", \"compare A and B\",\n\"is this claim true\". For a plain list of links, use search_web instead; for\none page you already have the URL of, use read_page.\n\n`mode` buys depth: `search` is a single fast pass, `news` biases to recency,\n`research` plans and iterates, `deep` surveys widest. `sources` narrows the\nevidence to `web`, `news`, `academic`, `github`, `reddit` or `x` — each becomes\na site-scoped search, which is how this reaches X/Twitter posts.\n\nEVERY CITATION IS A PAGE THIS CALL FETCHED. That is a property of the text and\nnot an instruction to the model: each source is fenced with a per-request nonce\nso a crawled page cannot print itself a source number, and every markdown link\nin the answer is checked against the gathered set before it is returned. So a\nlink in `answer` always appears in `sources`, and a page that was not read\ncannot be cited.\n\nIt is BOUNDED and it degrades rather than failing: a mode's rounds, wall clock\nand token ceiling all cap it, and a search that finds little or a page that\nwill not load yields a thinner answer, never an error. A validated principal is\nrequired, and the answer is billed once to that principal's org.",
|
||||
Fields: map[string]string{
|
||||
"Report.answer": "Answer is the grounded prose, with inline markdown citations. Every link in\nit points at a page in Sources: the citation check runs on the text before\nit leaves the engine, so a cited URL is one THIS call fetched.",
|
||||
"Report.follow_ups": "FollowUps are the questions worth asking next. Best-effort — an empty list\nis a normal outcome, not a fault.",
|
||||
"Report.mode": "Mode is the profile that ran: search, news, research or deep.",
|
||||
"Report.model": "Model is the model that synthesized the answer.",
|
||||
"Report.sources": "Sources are the pages the answer was written from, deduplicated and ranked.\nAlways an array, never null.",
|
||||
"webQuestion.language": "Language narrows the search to a locale, BCP-47-ish (\"en\", \"ja\"). Empty\nmeans no narrowing.",
|
||||
"webQuestion.max_sources": "MaxSources caps how many pages are read. Empty means the mode's own budget.",
|
||||
"webQuestion.mode": "Mode is how much work to do: `search` (fast, one pass), `news` (recency\nbiased), `research` (a plan and several rounds) or `deep` (the widest\nsurvey). Empty means research.",
|
||||
"webQuestion.q": "Q is the question, in plain language. Required.",
|
||||
"webQuestion.sources": "Sources narrows where the evidence comes from: any of `web`, `news`,\n`academic`, `github`, `reddit`, `x`. Each becomes a site-scoped search, so\n`[\"x\"]` researches X/Twitter posts rather than the open web.",
|
||||
},
|
||||
})
|
||||
}
|
||||
+156
-35
@@ -528,20 +528,66 @@ func emitAudit(s *cloud.Service[state], ctx context.Context, action string, a Au
|
||||
|
||||
// adminAuthorView is one row in the SuperAdmin directory (org exposed).
|
||||
type adminAuthorView struct {
|
||||
ID string `json:"id"`
|
||||
Org string `json:"org"`
|
||||
GithubLogin string `json:"githubLogin"`
|
||||
Status string `json:"status"`
|
||||
Verified bool `json:"verified"`
|
||||
ShareBps int64 `json:"shareBps"`
|
||||
RepoCount int `json:"repoCount"`
|
||||
DeployCount int `json:"deployCount"`
|
||||
AccruedCents int64 `json:"accruedCents"`
|
||||
PendingCents int64 `json:"pendingCents"`
|
||||
PaidCents int64 `json:"paidCents"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
ApprovedAt int64 `json:"approvedAt"`
|
||||
SuspendedAt int64 `json:"suspendedAt"`
|
||||
// ID is the author record's server-minted handle, "aut_"-prefixed. It is the id
|
||||
// the approve, suspend, payout and admin-basis routes address.
|
||||
ID string `json:"id"`
|
||||
// Org is the tenant org that owns this author record — UNIQUE, one author per
|
||||
// org. It is exposed HERE and nowhere else (Author.Org is json:"-" on the tenant
|
||||
// surface), and it is the org excluded from this author's own accrual: deploying
|
||||
// your own repo earns you nothing.
|
||||
Org string `json:"org"`
|
||||
// GithubLogin is the linked forge account, lowercased. It comes from IAM's
|
||||
// linked account when the connect had one — which is also what sets verified —
|
||||
// and otherwise from the login the caller declared. The treasury author carries
|
||||
// "<brand>-maintainers".
|
||||
GithubLogin string `json:"githubLogin"`
|
||||
// Status is connected, approved or suspended. Only an approved author accrues;
|
||||
// a connected one may verify repos and collect deploy edges but earns nothing
|
||||
// until a reviewer admits it.
|
||||
Status string `json:"status"`
|
||||
// Verified is IDENTITY proof of the login, NOT proof of any repository: true
|
||||
// when the connect took the login from IAM's linked forge account (and for the
|
||||
// seeded treasury author), false when the caller merely declared it. A false
|
||||
// here still earns — repository ownership is proven separately, per claim.
|
||||
Verified bool `json:"verified"`
|
||||
// ShareBps is the royalty rate accrual applies, in basis points of a deploying
|
||||
// org's metered spend for the period: 2000 (the platform default) is 20%, 10000
|
||||
// would be the entire spend. The platform keeps 10000 − shareBps. Changing it
|
||||
// never rewrites history — each ledger row keeps the rate it was written with.
|
||||
ShareBps int64 `json:"shareBps"`
|
||||
// RepoCount is how many of this author's repository claims are VERIFIED, counted
|
||||
// for this response in one GROUP BY over the whole table rather than a query per
|
||||
// row. The single-author replies from approve, suspend and payout report 0: they
|
||||
// carry the mutated row, not a re-listing.
|
||||
RepoCount int `json:"repoCount"`
|
||||
// DeployCount is how many attribution edges point at this author — one per
|
||||
// (repository, project, deploying org), so re-deploying the same project adds
|
||||
// none. It includes self-deploys, which are recorded for provenance and excluded
|
||||
// from accrual, so it measures reach, not the earning set.
|
||||
DeployCount int `json:"deployCount"`
|
||||
// AccruedCents is lifetime royalty accrued, in integer USD cents: the sum of
|
||||
// every latched accrual (spend × shareBps / 10000). It only ever rises — a
|
||||
// payout is recorded against paidCents and never reduces this.
|
||||
AccruedCents int64 `json:"accruedCents"`
|
||||
// PendingCents is what a payout may still draw against — accrued − paid, floored
|
||||
// at zero. It is derived for each response, never stored, and it is the exact
|
||||
// figure the atomic payout guard refuses to exceed.
|
||||
PendingCents int64 `json:"pendingCents"`
|
||||
// PaidCents is lifetime royalty RECORDED as paid, in integer USD cents. It rises
|
||||
// the moment a payout reserves against pending — recording, not settling; a human
|
||||
// moves the money out of band — and falls back only when a payout is voided.
|
||||
PaidCents int64 `json:"paidCents"`
|
||||
// CreatedAt is unix seconds at the FIRST connect. Re-connecting re-links the
|
||||
// login and leaves this alone, so it dates the enrolment, not the latest link.
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
// ApprovedAt is unix seconds of the first approval, and 0 means never approved —
|
||||
// which is also "has never been able to accrue". Re-approving to renegotiate the
|
||||
// share leaves it at the original date.
|
||||
ApprovedAt int64 `json:"approvedAt"`
|
||||
// SuspendedAt is unix seconds of the most recent suspension. 0 means the author
|
||||
// is not suspended: either never was, or was and has since been approved again,
|
||||
// which clears this back to 0.
|
||||
SuspendedAt int64 `json:"suspendedAt"`
|
||||
}
|
||||
|
||||
func adminViewOf(a Author, repos, deploys int) adminAuthorView {
|
||||
@@ -556,12 +602,35 @@ func adminViewOf(a Author, repos, deploys int) adminAuthorView {
|
||||
// authorRepo is one row of an author's verified/claimed repos, with the ready-to-paste
|
||||
// Deploy-on-Hanzo markdown snippet.
|
||||
type authorRepo struct {
|
||||
RepoURL string `json:"repoUrl"`
|
||||
Verified bool `json:"verified"`
|
||||
Method string `json:"method,omitempty"`
|
||||
// RepoURL is the claim key in canonical form — lowercased "host/owner/name",
|
||||
// no scheme, no .git, host ∈ {github.com, gitlab.com}. A deploy's source repo is
|
||||
// normalized through the same function before attribution, so the two sides can
|
||||
// never miss on a cosmetic difference. UNIQUE across every author: first proven
|
||||
// claim wins.
|
||||
RepoURL string `json:"repoUrl"`
|
||||
// Verified reports that ownership was proven. Only a proven claim is ever
|
||||
// written, so it is true on every row this surface returns; the deploy path
|
||||
// re-reads it regardless, because an unverified claim attributes nothing.
|
||||
Verified bool `json:"verified"`
|
||||
// Method is HOW ownership was proven: "oauth" — an IAM-linked forge token showed
|
||||
// admin or push on the repository; "file" — a hanzo.json on the default branch
|
||||
// carried this author's verify code; or "maintainer" — the repository sits in a
|
||||
// first-party namespace, where ownership is intrinsic and the treasury author
|
||||
// holds it with no proof step. Omitted on a row written before the method was
|
||||
// recorded.
|
||||
Method string `json:"method,omitempty"`
|
||||
// BadgeMarkdown is the ready-to-paste README snippet, DERIVED for each response
|
||||
// from this deployment's badge host and never stored: a "Deploy on Hanzo" image
|
||||
// linking to the one-click import of this repository. Re-hosting the builder
|
||||
// changes every badge without touching a row.
|
||||
BadgeMarkdown string `json:"badgeMarkdown"`
|
||||
VerifiedAt int64 `json:"verifiedAt"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
// VerifiedAt is unix seconds of the most recent successful proof. Re-verifying
|
||||
// refreshes it, and the method beside it, in place.
|
||||
VerifiedAt int64 `json:"verifiedAt"`
|
||||
// CreatedAt is unix seconds when the claim was first recorded. It equals
|
||||
// verifiedAt on the first proof and then stays put while verifiedAt moves, so the
|
||||
// pair reads as "claimed since / last proven".
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
func authorRepoOf(r AuthorRepo, badgeBase string) authorRepo {
|
||||
@@ -583,12 +652,32 @@ func authorRepos(rs []AuthorRepo, badgeBase string) []authorRepo {
|
||||
// orgView is one row of an author's verified OWNER-WIDE claims: the owner url + a
|
||||
// ready-to-paste badge deep-linking that owner's Hanzo template import.
|
||||
type orgView struct {
|
||||
OwnerURL string `json:"ownerUrl"`
|
||||
Verified bool `json:"verified"`
|
||||
Method string `json:"method,omitempty"`
|
||||
// OwnerURL is the claim key in canonical form — lowercased "host/owner" with NO
|
||||
// repository segment, host ∈ {github.com, gitlab.com}. It covers every repository
|
||||
// under that owner, so code with no claim of its own still earns; a per-repository
|
||||
// claim outranks it. UNIQUE across every author: first proven claim wins.
|
||||
OwnerURL string `json:"ownerUrl"`
|
||||
// Verified reports that ownership of the WHOLE owner was proven — against that
|
||||
// owner's ".github" control repository, which is exactly as strong as a
|
||||
// per-repository claim. Only a proven claim is written, so every row returned
|
||||
// here is true.
|
||||
Verified bool `json:"verified"`
|
||||
// Method is HOW the owner was proven, always against its ".github" control
|
||||
// repository: "oauth" — an IAM-linked forge token showed admin or push on it; or
|
||||
// "file" — a hanzo.json on its default branch carried this author's verify code.
|
||||
// The "maintainer" shortcut is a per-repository attribution and never appears
|
||||
// here. Omitted on a row written before the method was recorded.
|
||||
Method string `json:"method,omitempty"`
|
||||
// BadgeMarkdown is the ready-to-paste README snippet, DERIVED for each response
|
||||
// from this deployment's badge host and never stored — here it deep-links the
|
||||
// OWNER's template import rather than one repository's.
|
||||
BadgeMarkdown string `json:"badgeMarkdown"`
|
||||
VerifiedAt int64 `json:"verifiedAt"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
// VerifiedAt is unix seconds of the most recent successful proof of the owner;
|
||||
// re-verifying refreshes it, and the method beside it, in place.
|
||||
VerifiedAt int64 `json:"verifiedAt"`
|
||||
// CreatedAt is unix seconds when the owner claim was first recorded — equal to
|
||||
// verifiedAt on the first proof, then fixed while verifiedAt moves.
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
func orgViewOf(o AuthorOrg, badgeBase string) orgView {
|
||||
@@ -625,16 +714,34 @@ func deployViews(es []DeployEvent) []deployView {
|
||||
|
||||
// payoutView is one row of an author's payout history.
|
||||
type payoutView struct {
|
||||
ID string `json:"id"`
|
||||
AmountCents int64 `json:"amountCents"`
|
||||
Method string `json:"method"`
|
||||
Reference string `json:"reference,omitempty"`
|
||||
Txn string `json:"txn,omitempty"`
|
||||
// ID is the payout row's server-minted handle, "apo_"-prefixed. A caller never
|
||||
// supplies it; it is what an operator quotes when reconciling a settlement.
|
||||
ID string `json:"id"`
|
||||
// AmountCents is the amount RESERVED against pending royalty, in integer USD
|
||||
// cents, always positive. The reservation is atomic and can never exceed
|
||||
// accrued − paid, so this is owed money moved out of pending — not money moved.
|
||||
AmountCents int64 `json:"amountCents"`
|
||||
// Method is how the operator says this settles, lowercased as recorded.
|
||||
// "credits" is the one method that means the author's own wallet; anything else
|
||||
// — wire, paypal, check — is a cash disbursement a human performs. Recording it
|
||||
// pays nobody either way.
|
||||
Method string `json:"method"`
|
||||
// Reference is the operator's external handle for the settlement: a wire
|
||||
// confirmation, a PayPal transaction id. Absent when none was given.
|
||||
Reference string `json:"reference,omitempty"`
|
||||
// Txn is the commerce ledger transaction id of a SETTLED credits payout, and it
|
||||
// is absent on every payout this service records. Recording moves no money, and
|
||||
// authors asks the money plane exactly one question — what has this org spent? —
|
||||
// with no write to answer it with, so there is no receipt to carry. It fills in
|
||||
// only when a settlement stamps its transaction back onto the row.
|
||||
Txn string `json:"txn,omitempty"`
|
||||
// Settlement discloses treasury-vs-wallet-vs-cash on every payout, to the author
|
||||
// and to the admin mirror alike — the disclosure that keeps a first-party
|
||||
// settlement legible as internal accounting.
|
||||
Settlement string `json:"settlement,omitempty"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
// CreatedAt is unix seconds when the payout was RECORDED — the moment the amount
|
||||
// left pending, not the moment a human moved the money.
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
func payoutViewOf(p Payout) payoutView {
|
||||
@@ -655,13 +762,27 @@ func payoutViews(ps []Payout) []payoutView {
|
||||
// product-qualified because the fleet's schema namespace is FLAT and apps/referrals
|
||||
// already publishes an "adminSummary" of its own.
|
||||
type authorProgramSummary struct {
|
||||
Total int `json:"total"`
|
||||
Connected int `json:"connected"`
|
||||
Approved int `json:"approved"`
|
||||
Suspended int `json:"suspended"`
|
||||
// Total is how many author records this response actually carried. The roll-up
|
||||
// is folded over the SAME page as authors — newest first, bounded by limit
|
||||
// (default 500, ceiling 1000) — so on a program larger than the page it
|
||||
// summarizes that page, not the fleet.
|
||||
Total int `json:"total"`
|
||||
// Connected is how many of those are enrolled but not yet admitted to earning.
|
||||
Connected int `json:"connected"`
|
||||
// Approved is how many are admitted and accruing.
|
||||
Approved int `json:"approved"`
|
||||
// Suspended is how many have been stopped from accruing further. An author holds
|
||||
// exactly one status, so the three buckets never overlap and connected +
|
||||
// approved + suspended = total.
|
||||
Suspended int `json:"suspended"`
|
||||
// AccruedCents is the page's lifetime royalty accrued, in integer USD cents.
|
||||
AccruedCents int64 `json:"accruedCents"`
|
||||
// PendingCents is what the platform still owes across the page, in integer USD
|
||||
// cents — the sum of each author's own accrued − paid, each floored at zero.
|
||||
PendingCents int64 `json:"pendingCents"`
|
||||
PaidCents int64 `json:"paidCents"`
|
||||
// PaidCents is what has been RECORDED as paid across the page, in integer USD
|
||||
// cents. Recorded, not settled: the money leaves in a human's hands.
|
||||
PaidCents int64 `json:"paidCents"`
|
||||
}
|
||||
|
||||
func (s *authorProgramSummary) add(a Author) {
|
||||
|
||||
+112
-31
@@ -12,12 +12,33 @@ func init() {
|
||||
zip.Describe("GET /v1/admin/authors", zip.Doc{
|
||||
Description: "Returns the platform's whole author program — every org's author\nrecord, not the caller's — with each one's repository and deploy counts and a\nfleet roll-up of the money accrued, pending and paid.\n\nIt is a Hanzo platform operation: a caller who is not a SuperAdmin gets 403. It\nexposes the owning org of each author, which no tenant-facing read ever does.",
|
||||
Fields: map[string]string{
|
||||
"adminBook.data": "Data is the book.",
|
||||
"adminBook.msg": "Msg is the envelope's message slot, empty on success.",
|
||||
"adminBook.status": "Status is \"ok\" — the operator console's envelope discriminator.",
|
||||
"adminBookData.authors": "Authors are the author records, with each one's repository and deploy counts.",
|
||||
"adminBookData.summary": "Summary is the fleet roll-up: how many authors at each status and the money\naccrued, pending and paid across all of them.",
|
||||
"adminLimit.limit": "Limit bounds the page. 0 or less means the default of 500; anything above\n1000 is clamped to 1000.",
|
||||
"adminAuthorView.accruedCents": "AccruedCents is lifetime royalty accrued, in integer USD cents: the sum of\nevery latched accrual (spend × shareBps / 10000). It only ever rises — a\npayout is recorded against paidCents and never reduces this.",
|
||||
"adminAuthorView.approvedAt": "ApprovedAt is unix seconds of the first approval, and 0 means never approved —\nwhich is also \"has never been able to accrue\". Re-approving to renegotiate the\nshare leaves it at the original date.",
|
||||
"adminAuthorView.createdAt": "CreatedAt is unix seconds at the FIRST connect. Re-connecting re-links the\nlogin and leaves this alone, so it dates the enrolment, not the latest link.",
|
||||
"adminAuthorView.deployCount": "DeployCount is how many attribution edges point at this author — one per\n(repository, project, deploying org), so re-deploying the same project adds\nnone. It includes self-deploys, which are recorded for provenance and excluded\nfrom accrual, so it measures reach, not the earning set.",
|
||||
"adminAuthorView.githubLogin": "GithubLogin is the linked forge account, lowercased. It comes from IAM's\nlinked account when the connect had one — which is also what sets verified —\nand otherwise from the login the caller declared. The treasury author carries\n\"<brand>-maintainers\".",
|
||||
"adminAuthorView.id": "ID is the author record's server-minted handle, \"aut_\"-prefixed. It is the id\nthe approve, suspend, payout and admin-basis routes address.",
|
||||
"adminAuthorView.org": "Org is the tenant org that owns this author record — UNIQUE, one author per\norg. It is exposed HERE and nowhere else (Author.Org is json:\"-\" on the tenant\nsurface), and it is the org excluded from this author's own accrual: deploying\nyour own repo earns you nothing.",
|
||||
"adminAuthorView.paidCents": "PaidCents is lifetime royalty RECORDED as paid, in integer USD cents. It rises\nthe moment a payout reserves against pending — recording, not settling; a human\nmoves the money out of band — and falls back only when a payout is voided.",
|
||||
"adminAuthorView.pendingCents": "PendingCents is what a payout may still draw against — accrued − paid, floored\nat zero. It is derived for each response, never stored, and it is the exact\nfigure the atomic payout guard refuses to exceed.",
|
||||
"adminAuthorView.repoCount": "RepoCount is how many of this author's repository claims are VERIFIED, counted\nfor this response in one GROUP BY over the whole table rather than a query per\nrow. The single-author replies from approve, suspend and payout report 0: they\ncarry the mutated row, not a re-listing.",
|
||||
"adminAuthorView.shareBps": "ShareBps is the royalty rate accrual applies, in basis points of a deploying\norg's metered spend for the period: 2000 (the platform default) is 20%, 10000\nwould be the entire spend. The platform keeps 10000 − shareBps. Changing it\nnever rewrites history — each ledger row keeps the rate it was written with.",
|
||||
"adminAuthorView.status": "Status is connected, approved or suspended. Only an approved author accrues;\na connected one may verify repos and collect deploy edges but earns nothing\nuntil a reviewer admits it.",
|
||||
"adminAuthorView.suspendedAt": "SuspendedAt is unix seconds of the most recent suspension. 0 means the author\nis not suspended: either never was, or was and has since been approved again,\nwhich clears this back to 0.",
|
||||
"adminAuthorView.verified": "Verified is IDENTITY proof of the login, NOT proof of any repository: true\nwhen the connect took the login from IAM's linked forge account (and for the\nseeded treasury author), false when the caller merely declared it. A false\nhere still earns — repository ownership is proven separately, per claim.",
|
||||
"adminBook.data": "Data is the book.",
|
||||
"adminBook.msg": "Msg is the envelope's message slot, empty on success.",
|
||||
"adminBook.status": "Status is \"ok\" — the operator console's envelope discriminator.",
|
||||
"adminBookData.authors": "Authors are the author records, with each one's repository and deploy counts.",
|
||||
"adminBookData.summary": "Summary is the fleet roll-up: how many authors at each status and the money\naccrued, pending and paid across all of them.",
|
||||
"adminLimit.limit": "Limit bounds the page. 0 or less means the default of 500; anything above\n1000 is clamped to 1000.",
|
||||
"authorProgramSummary.accruedCents": "AccruedCents is the page's lifetime royalty accrued, in integer USD cents.",
|
||||
"authorProgramSummary.approved": "Approved is how many are admitted and accruing.",
|
||||
"authorProgramSummary.connected": "Connected is how many of those are enrolled but not yet admitted to earning.",
|
||||
"authorProgramSummary.paidCents": "PaidCents is what has been RECORDED as paid across the page, in integer USD\ncents. Recorded, not settled: the money leaves in a human's hands.",
|
||||
"authorProgramSummary.pendingCents": "PendingCents is what the platform still owes across the page, in integer USD\ncents — the sum of each author's own accrued − paid, each floored at zero.",
|
||||
"authorProgramSummary.suspended": "Suspended is how many have been stopped from accruing further. An author holds\nexactly one status, so the three buckets never overlap and connected +\napproved + suspended = total.",
|
||||
"authorProgramSummary.total": "Total is how many author records this response actually carried. The roll-up\nis folded over the SAME page as authors — newest first, bounded by limit\n(default 500, ceiling 1000) — so on a program larger than the page it\nsummarizes that page, not the fleet.",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/admin/authors/:id/basis", zip.Doc{
|
||||
@@ -44,39 +65,87 @@ func init() {
|
||||
zip.Describe("POST /v1/admin/authors/:id/approve", zip.Doc{
|
||||
Description: "Admits one author to EARNING, optionally on a negotiated royalty\nshare. Until this runs, a connected author accrues nothing however many verified\nrepositories they have.\n\nA share override applies from here forward only — existing ledger rows keep the\nshare that was applied when they were written, because a rate change must never\nrewrite what was already owed.\n\nA Hanzo platform operation: a caller who is not a SuperAdmin gets 403.",
|
||||
Fields: map[string]string{
|
||||
"approveRequest.id": "ID is the author to approve, from the path.",
|
||||
"approveRequest.shareBps": "ShareBps overrides this author's royalty share, in basis points (0–10000).\n0 keeps the platform default. A share change never rewrites history: existing\nledger rows keep the share that was applied when they were written.",
|
||||
"authorData.author": "Author is the author record after the change. Its repository and deploy counts\nare 0 here — this is the mutated row, not a re-listing.",
|
||||
"authorResult.data": "Data carries the author.",
|
||||
"authorResult.msg": "Msg is the envelope's message slot, empty on success.",
|
||||
"authorResult.status": "Status is \"ok\".",
|
||||
"adminAuthorView.accruedCents": "AccruedCents is lifetime royalty accrued, in integer USD cents: the sum of\nevery latched accrual (spend × shareBps / 10000). It only ever rises — a\npayout is recorded against paidCents and never reduces this.",
|
||||
"adminAuthorView.approvedAt": "ApprovedAt is unix seconds of the first approval, and 0 means never approved —\nwhich is also \"has never been able to accrue\". Re-approving to renegotiate the\nshare leaves it at the original date.",
|
||||
"adminAuthorView.createdAt": "CreatedAt is unix seconds at the FIRST connect. Re-connecting re-links the\nlogin and leaves this alone, so it dates the enrolment, not the latest link.",
|
||||
"adminAuthorView.deployCount": "DeployCount is how many attribution edges point at this author — one per\n(repository, project, deploying org), so re-deploying the same project adds\nnone. It includes self-deploys, which are recorded for provenance and excluded\nfrom accrual, so it measures reach, not the earning set.",
|
||||
"adminAuthorView.githubLogin": "GithubLogin is the linked forge account, lowercased. It comes from IAM's\nlinked account when the connect had one — which is also what sets verified —\nand otherwise from the login the caller declared. The treasury author carries\n\"<brand>-maintainers\".",
|
||||
"adminAuthorView.id": "ID is the author record's server-minted handle, \"aut_\"-prefixed. It is the id\nthe approve, suspend, payout and admin-basis routes address.",
|
||||
"adminAuthorView.org": "Org is the tenant org that owns this author record — UNIQUE, one author per\norg. It is exposed HERE and nowhere else (Author.Org is json:\"-\" on the tenant\nsurface), and it is the org excluded from this author's own accrual: deploying\nyour own repo earns you nothing.",
|
||||
"adminAuthorView.paidCents": "PaidCents is lifetime royalty RECORDED as paid, in integer USD cents. It rises\nthe moment a payout reserves against pending — recording, not settling; a human\nmoves the money out of band — and falls back only when a payout is voided.",
|
||||
"adminAuthorView.pendingCents": "PendingCents is what a payout may still draw against — accrued − paid, floored\nat zero. It is derived for each response, never stored, and it is the exact\nfigure the atomic payout guard refuses to exceed.",
|
||||
"adminAuthorView.repoCount": "RepoCount is how many of this author's repository claims are VERIFIED, counted\nfor this response in one GROUP BY over the whole table rather than a query per\nrow. The single-author replies from approve, suspend and payout report 0: they\ncarry the mutated row, not a re-listing.",
|
||||
"adminAuthorView.shareBps": "ShareBps is the royalty rate accrual applies, in basis points of a deploying\norg's metered spend for the period: 2000 (the platform default) is 20%, 10000\nwould be the entire spend. The platform keeps 10000 − shareBps. Changing it\nnever rewrites history — each ledger row keeps the rate it was written with.",
|
||||
"adminAuthorView.status": "Status is connected, approved or suspended. Only an approved author accrues;\na connected one may verify repos and collect deploy edges but earns nothing\nuntil a reviewer admits it.",
|
||||
"adminAuthorView.suspendedAt": "SuspendedAt is unix seconds of the most recent suspension. 0 means the author\nis not suspended: either never was, or was and has since been approved again,\nwhich clears this back to 0.",
|
||||
"adminAuthorView.verified": "Verified is IDENTITY proof of the login, NOT proof of any repository: true\nwhen the connect took the login from IAM's linked forge account (and for the\nseeded treasury author), false when the caller merely declared it. A false\nhere still earns — repository ownership is proven separately, per claim.",
|
||||
"approveRequest.id": "ID is the author to approve, from the path.",
|
||||
"approveRequest.shareBps": "ShareBps overrides this author's royalty share, in basis points (0–10000).\n0 keeps the platform default. A share change never rewrites history: existing\nledger rows keep the share that was applied when they were written.",
|
||||
"authorData.author": "Author is the author record after the change. Its repository and deploy counts\nare 0 here — this is the mutated row, not a re-listing.",
|
||||
"authorResult.data": "Data carries the author.",
|
||||
"authorResult.msg": "Msg is the envelope's message slot, empty on success.",
|
||||
"authorResult.status": "Status is \"ok\".",
|
||||
},
|
||||
Example: json.RawMessage(`{"id":"aut_1f…","shareBps":2500}`),
|
||||
})
|
||||
zip.Describe("POST /v1/admin/authors/:id/payout", zip.Doc{
|
||||
Description: "Records a payout of accrued royalty and settles it.\n\nThe amount is RESERVED against the author's pending royalty atomically before\nanything is paid, so a payout can never exceed what is owed even under concurrent\ncalls. An external author's payout is then BACKED against the platform reserve\nfund — a second, independent guard — and refused with 402 if the reserve cannot\ncover it, with the reservation voided. A \"credits\" method issues the actual wallet\ngrant after both guards; a cash method is record-only. A first-party (treasury)\nauthor's royalty is realized into Hanzo's own reserve instead of an external\nwallet, and every payout row discloses which of the three it was.\n\nA Hanzo platform operation: a caller who is not a SuperAdmin gets 403.",
|
||||
Fields: map[string]string{
|
||||
"payoutData.author": "Author is the author record after the payout, with the balances updated.",
|
||||
"payoutData.payout": "Payout is the recorded payout, including where it settled.",
|
||||
"payoutRequest.amountCents": "AmountCents is how much to pay, in cents. Must be positive and can never\nexceed the author's pending royalty (accrued minus paid).",
|
||||
"payoutRequest.id": "ID is the author to pay, from the path.",
|
||||
"payoutRequest.method": "Method is how it settles: \"credits\" issues a grant into the author's wallet;\nwire, paypal and the like are record-only. Required.",
|
||||
"payoutRequest.reference": "Reference is the operator's external reference for a cash settlement — a wire\nconfirmation, a PayPal transaction id.",
|
||||
"payoutResult.data": "Data carries the payout and the author.",
|
||||
"payoutResult.msg": "Msg is the envelope's message slot, empty on success.",
|
||||
"payoutResult.status": "Status is \"ok\".",
|
||||
"payoutView.settlement": "Settlement discloses treasury-vs-wallet-vs-cash on every payout, to the author\nand to the admin mirror alike — the disclosure that keeps a first-party\nsettlement legible as internal accounting.",
|
||||
"adminAuthorView.accruedCents": "AccruedCents is lifetime royalty accrued, in integer USD cents: the sum of\nevery latched accrual (spend × shareBps / 10000). It only ever rises — a\npayout is recorded against paidCents and never reduces this.",
|
||||
"adminAuthorView.approvedAt": "ApprovedAt is unix seconds of the first approval, and 0 means never approved —\nwhich is also \"has never been able to accrue\". Re-approving to renegotiate the\nshare leaves it at the original date.",
|
||||
"adminAuthorView.createdAt": "CreatedAt is unix seconds at the FIRST connect. Re-connecting re-links the\nlogin and leaves this alone, so it dates the enrolment, not the latest link.",
|
||||
"adminAuthorView.deployCount": "DeployCount is how many attribution edges point at this author — one per\n(repository, project, deploying org), so re-deploying the same project adds\nnone. It includes self-deploys, which are recorded for provenance and excluded\nfrom accrual, so it measures reach, not the earning set.",
|
||||
"adminAuthorView.githubLogin": "GithubLogin is the linked forge account, lowercased. It comes from IAM's\nlinked account when the connect had one — which is also what sets verified —\nand otherwise from the login the caller declared. The treasury author carries\n\"<brand>-maintainers\".",
|
||||
"adminAuthorView.id": "ID is the author record's server-minted handle, \"aut_\"-prefixed. It is the id\nthe approve, suspend, payout and admin-basis routes address.",
|
||||
"adminAuthorView.org": "Org is the tenant org that owns this author record — UNIQUE, one author per\norg. It is exposed HERE and nowhere else (Author.Org is json:\"-\" on the tenant\nsurface), and it is the org excluded from this author's own accrual: deploying\nyour own repo earns you nothing.",
|
||||
"adminAuthorView.paidCents": "PaidCents is lifetime royalty RECORDED as paid, in integer USD cents. It rises\nthe moment a payout reserves against pending — recording, not settling; a human\nmoves the money out of band — and falls back only when a payout is voided.",
|
||||
"adminAuthorView.pendingCents": "PendingCents is what a payout may still draw against — accrued − paid, floored\nat zero. It is derived for each response, never stored, and it is the exact\nfigure the atomic payout guard refuses to exceed.",
|
||||
"adminAuthorView.repoCount": "RepoCount is how many of this author's repository claims are VERIFIED, counted\nfor this response in one GROUP BY over the whole table rather than a query per\nrow. The single-author replies from approve, suspend and payout report 0: they\ncarry the mutated row, not a re-listing.",
|
||||
"adminAuthorView.shareBps": "ShareBps is the royalty rate accrual applies, in basis points of a deploying\norg's metered spend for the period: 2000 (the platform default) is 20%, 10000\nwould be the entire spend. The platform keeps 10000 − shareBps. Changing it\nnever rewrites history — each ledger row keeps the rate it was written with.",
|
||||
"adminAuthorView.status": "Status is connected, approved or suspended. Only an approved author accrues;\na connected one may verify repos and collect deploy edges but earns nothing\nuntil a reviewer admits it.",
|
||||
"adminAuthorView.suspendedAt": "SuspendedAt is unix seconds of the most recent suspension. 0 means the author\nis not suspended: either never was, or was and has since been approved again,\nwhich clears this back to 0.",
|
||||
"adminAuthorView.verified": "Verified is IDENTITY proof of the login, NOT proof of any repository: true\nwhen the connect took the login from IAM's linked forge account (and for the\nseeded treasury author), false when the caller merely declared it. A false\nhere still earns — repository ownership is proven separately, per claim.",
|
||||
"payoutData.author": "Author is the author record after the payout, with the balances updated.",
|
||||
"payoutData.payout": "Payout is the recorded payout, including where it settled.",
|
||||
"payoutRequest.amountCents": "AmountCents is how much to pay, in cents. Must be positive and can never\nexceed the author's pending royalty (accrued minus paid).",
|
||||
"payoutRequest.id": "ID is the author to pay, from the path.",
|
||||
"payoutRequest.method": "Method is how it settles: \"credits\" issues a grant into the author's wallet;\nwire, paypal and the like are record-only. Required.",
|
||||
"payoutRequest.reference": "Reference is the operator's external reference for a cash settlement — a wire\nconfirmation, a PayPal transaction id.",
|
||||
"payoutResult.data": "Data carries the payout and the author.",
|
||||
"payoutResult.msg": "Msg is the envelope's message slot, empty on success.",
|
||||
"payoutResult.status": "Status is \"ok\".",
|
||||
"payoutView.amountCents": "AmountCents is the amount RESERVED against pending royalty, in integer USD\ncents, always positive. The reservation is atomic and can never exceed\naccrued − paid, so this is owed money moved out of pending — not money moved.",
|
||||
"payoutView.createdAt": "CreatedAt is unix seconds when the payout was RECORDED — the moment the amount\nleft pending, not the moment a human moved the money.",
|
||||
"payoutView.id": "ID is the payout row's server-minted handle, \"apo_\"-prefixed. A caller never\nsupplies it; it is what an operator quotes when reconciling a settlement.",
|
||||
"payoutView.method": "Method is how the operator says this settles, lowercased as recorded.\n\"credits\" is the one method that means the author's own wallet; anything else\n— wire, paypal, check — is a cash disbursement a human performs. Recording it\npays nobody either way.",
|
||||
"payoutView.reference": "Reference is the operator's external handle for the settlement: a wire\nconfirmation, a PayPal transaction id. Absent when none was given.",
|
||||
"payoutView.settlement": "Settlement discloses treasury-vs-wallet-vs-cash on every payout, to the author\nand to the admin mirror alike — the disclosure that keeps a first-party\nsettlement legible as internal accounting.",
|
||||
"payoutView.txn": "Txn is the commerce ledger transaction id of a SETTLED credits payout, and it\nis absent on every payout this service records. Recording moves no money, and\nauthors asks the money plane exactly one question — what has this org spent? —\nwith no write to answer it with, so there is no receipt to carry. It fills in\nonly when a settlement stamps its transaction back onto the row.",
|
||||
},
|
||||
Example: json.RawMessage(`{"id":"aut_1f…","amountCents":25000,"method":"credits"}`),
|
||||
})
|
||||
zip.Describe("POST /v1/admin/authors/:id/suspend", zip.Doc{
|
||||
Description: "Stops one author earning. Their record, verified claims and ledger\nare untouched — suspension halts future accrual, it does not erase what was already\nowed, and it does not delete the evidence behind it.\n\nA Hanzo platform operation: a caller who is not a SuperAdmin gets 403.",
|
||||
Fields: map[string]string{
|
||||
"authorData.author": "Author is the author record after the change. Its repository and deploy counts\nare 0 here — this is the mutated row, not a re-listing.",
|
||||
"authorRef.id": "ID is the author record's handle, \"aut_\"-prefixed.",
|
||||
"authorResult.data": "Data carries the author.",
|
||||
"authorResult.msg": "Msg is the envelope's message slot, empty on success.",
|
||||
"authorResult.status": "Status is \"ok\".",
|
||||
"adminAuthorView.accruedCents": "AccruedCents is lifetime royalty accrued, in integer USD cents: the sum of\nevery latched accrual (spend × shareBps / 10000). It only ever rises — a\npayout is recorded against paidCents and never reduces this.",
|
||||
"adminAuthorView.approvedAt": "ApprovedAt is unix seconds of the first approval, and 0 means never approved —\nwhich is also \"has never been able to accrue\". Re-approving to renegotiate the\nshare leaves it at the original date.",
|
||||
"adminAuthorView.createdAt": "CreatedAt is unix seconds at the FIRST connect. Re-connecting re-links the\nlogin and leaves this alone, so it dates the enrolment, not the latest link.",
|
||||
"adminAuthorView.deployCount": "DeployCount is how many attribution edges point at this author — one per\n(repository, project, deploying org), so re-deploying the same project adds\nnone. It includes self-deploys, which are recorded for provenance and excluded\nfrom accrual, so it measures reach, not the earning set.",
|
||||
"adminAuthorView.githubLogin": "GithubLogin is the linked forge account, lowercased. It comes from IAM's\nlinked account when the connect had one — which is also what sets verified —\nand otherwise from the login the caller declared. The treasury author carries\n\"<brand>-maintainers\".",
|
||||
"adminAuthorView.id": "ID is the author record's server-minted handle, \"aut_\"-prefixed. It is the id\nthe approve, suspend, payout and admin-basis routes address.",
|
||||
"adminAuthorView.org": "Org is the tenant org that owns this author record — UNIQUE, one author per\norg. It is exposed HERE and nowhere else (Author.Org is json:\"-\" on the tenant\nsurface), and it is the org excluded from this author's own accrual: deploying\nyour own repo earns you nothing.",
|
||||
"adminAuthorView.paidCents": "PaidCents is lifetime royalty RECORDED as paid, in integer USD cents. It rises\nthe moment a payout reserves against pending — recording, not settling; a human\nmoves the money out of band — and falls back only when a payout is voided.",
|
||||
"adminAuthorView.pendingCents": "PendingCents is what a payout may still draw against — accrued − paid, floored\nat zero. It is derived for each response, never stored, and it is the exact\nfigure the atomic payout guard refuses to exceed.",
|
||||
"adminAuthorView.repoCount": "RepoCount is how many of this author's repository claims are VERIFIED, counted\nfor this response in one GROUP BY over the whole table rather than a query per\nrow. The single-author replies from approve, suspend and payout report 0: they\ncarry the mutated row, not a re-listing.",
|
||||
"adminAuthorView.shareBps": "ShareBps is the royalty rate accrual applies, in basis points of a deploying\norg's metered spend for the period: 2000 (the platform default) is 20%, 10000\nwould be the entire spend. The platform keeps 10000 − shareBps. Changing it\nnever rewrites history — each ledger row keeps the rate it was written with.",
|
||||
"adminAuthorView.status": "Status is connected, approved or suspended. Only an approved author accrues;\na connected one may verify repos and collect deploy edges but earns nothing\nuntil a reviewer admits it.",
|
||||
"adminAuthorView.suspendedAt": "SuspendedAt is unix seconds of the most recent suspension. 0 means the author\nis not suspended: either never was, or was and has since been approved again,\nwhich clears this back to 0.",
|
||||
"adminAuthorView.verified": "Verified is IDENTITY proof of the login, NOT proof of any repository: true\nwhen the connect took the login from IAM's linked forge account (and for the\nseeded treasury author), false when the caller merely declared it. A false\nhere still earns — repository ownership is proven separately, per claim.",
|
||||
"authorData.author": "Author is the author record after the change. Its repository and deploy counts\nare 0 here — this is the mutated row, not a re-listing.",
|
||||
"authorRef.id": "ID is the author record's handle, \"aut_\"-prefixed.",
|
||||
"authorResult.data": "Data carries the author.",
|
||||
"authorResult.msg": "Msg is the envelope's message slot, empty on success.",
|
||||
"authorResult.status": "Status is \"ok\".",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/admin/authors/sweep", zip.Doc{
|
||||
@@ -124,10 +193,22 @@ func init() {
|
||||
zip.Describe("POST /v1/authors/repos/verify", zip.Doc{
|
||||
Description: "Proves that the caller owns a repository — or a whole OWNER — and\nrecords the claim, which is what makes deploys of that code earn royalty.\n\nOwnership is proven the SAME two ways in both cases, tried in order: an IAM-linked\nforge token with admin or push permission, or a hanzo.json on the default branch\ncarrying the author's verify code. Claiming an OWNER proves it against that\nowner's \".github\" control repository, and is exactly as strong as a per-repository\nclaim — an owner the caller cannot prove is refused with 422, never assumed.\n\nA per-repository claim wins over an owner-wide one, so a specifically-claimed\nrepository always earns for its own author. A repository another author has\nalready verified is a 409. The org must have connected first.\n\nAnswers 201 when it recorded a new claim and 200 when the claim already existed.",
|
||||
Fields: map[string]string{
|
||||
"claim.created": "Created reports whether this call recorded a new claim (201) or found an\nexisting one (200).",
|
||||
"claim.org": "Org is the verified owner-wide claim, present when an owner was claimed. It\ncovers every repository the author publishes under that owner.",
|
||||
"claim.repo": "Repo is the verified repository claim, present when a repository was claimed.",
|
||||
"verifyRequest.repoUrl": "RepoURL is what to claim: a repository (github.com/owner/name) or a whole\nOWNER (github.com/owner, no repository segment). gitlab.com is accepted too.",
|
||||
"authorRepo.badgeMarkdown": "BadgeMarkdown is the ready-to-paste README snippet, DERIVED for each response\nfrom this deployment's badge host and never stored: a \"Deploy on Hanzo\" image\nlinking to the one-click import of this repository. Re-hosting the builder\nchanges every badge without touching a row.",
|
||||
"authorRepo.createdAt": "CreatedAt is unix seconds when the claim was first recorded. It equals\nverifiedAt on the first proof and then stays put while verifiedAt moves, so the\npair reads as \"claimed since / last proven\".",
|
||||
"authorRepo.method": "Method is HOW ownership was proven: \"oauth\" — an IAM-linked forge token showed\nadmin or push on the repository; \"file\" — a hanzo.json on the default branch\ncarried this author's verify code; or \"maintainer\" — the repository sits in a\nfirst-party namespace, where ownership is intrinsic and the treasury author\nholds it with no proof step. Omitted on a row written before the method was\nrecorded.",
|
||||
"authorRepo.repoUrl": "RepoURL is the claim key in canonical form — lowercased \"host/owner/name\",\nno scheme, no .git, host ∈ {github.com, gitlab.com}. A deploy's source repo is\nnormalized through the same function before attribution, so the two sides can\nnever miss on a cosmetic difference. UNIQUE across every author: first proven\nclaim wins.",
|
||||
"authorRepo.verified": "Verified reports that ownership was proven. Only a proven claim is ever\nwritten, so it is true on every row this surface returns; the deploy path\nre-reads it regardless, because an unverified claim attributes nothing.",
|
||||
"authorRepo.verifiedAt": "VerifiedAt is unix seconds of the most recent successful proof. Re-verifying\nrefreshes it, and the method beside it, in place.",
|
||||
"claim.created": "Created reports whether this call recorded a new claim (201) or found an\nexisting one (200).",
|
||||
"claim.org": "Org is the verified owner-wide claim, present when an owner was claimed. It\ncovers every repository the author publishes under that owner.",
|
||||
"claim.repo": "Repo is the verified repository claim, present when a repository was claimed.",
|
||||
"orgView.badgeMarkdown": "BadgeMarkdown is the ready-to-paste README snippet, DERIVED for each response\nfrom this deployment's badge host and never stored — here it deep-links the\nOWNER's template import rather than one repository's.",
|
||||
"orgView.createdAt": "CreatedAt is unix seconds when the owner claim was first recorded — equal to\nverifiedAt on the first proof, then fixed while verifiedAt moves.",
|
||||
"orgView.method": "Method is HOW the owner was proven, always against its \".github\" control\nrepository: \"oauth\" — an IAM-linked forge token showed admin or push on it; or\n\"file\" — a hanzo.json on its default branch carried this author's verify code.\nThe \"maintainer\" shortcut is a per-repository attribution and never appears\nhere. Omitted on a row written before the method was recorded.",
|
||||
"orgView.ownerUrl": "OwnerURL is the claim key in canonical form — lowercased \"host/owner\" with NO\nrepository segment, host ∈ {github.com, gitlab.com}. It covers every repository\nunder that owner, so code with no claim of its own still earns; a per-repository\nclaim outranks it. UNIQUE across every author: first proven claim wins.",
|
||||
"orgView.verified": "Verified reports that ownership of the WHOLE owner was proven — against that\nowner's \".github\" control repository, which is exactly as strong as a\nper-repository claim. Only a proven claim is written, so every row returned\nhere is true.",
|
||||
"orgView.verifiedAt": "VerifiedAt is unix seconds of the most recent successful proof of the owner;\nre-verifying refreshes it, and the method beside it, in place.",
|
||||
"verifyRequest.repoUrl": "RepoURL is what to claim: a repository (github.com/owner/name) or a whole\nOWNER (github.com/owner, no repository segment). gitlab.com is accepted too.",
|
||||
},
|
||||
Example: json.RawMessage(`{"repoUrl":"github.com/octocat/hello-world"}`),
|
||||
})
|
||||
|
||||
@@ -86,6 +86,9 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
|
||||
}
|
||||
svc := &cloud.Service[*state]{Base: b, State: mounted}
|
||||
routes(app, svc)
|
||||
// The ledger's headline figures, for the peers that live in other processes —
|
||||
// the unified advisor above all (see figures_rpc.go).
|
||||
exposeFigures()
|
||||
// No "commerce configured" bit to report: the ledger is reached BY NAME, so
|
||||
// there is nothing a deployment sets and nothing that can be set wrong.
|
||||
b.Log.Info("books mounted", "prefix", "/v1/books")
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package books
|
||||
|
||||
// figures_rpc.go — the ledger's headline numbers, on the internal plane.
|
||||
//
|
||||
// It is the SAME snapshot GET /v1/books/metrics returns, answered over the
|
||||
// socket instead of the edge, because the caller that wants it most is in
|
||||
// another process. The unified advisor (/v1/ask) ships as its own plugin
|
||||
// binary: it mounts /v1/ask and nothing else, so the in-process read it used to
|
||||
// make for these figures could never reach this app and every money question
|
||||
// fell through to "I can answer questions about your finances today" with no
|
||||
// figures behind it. One op fixes that, and fixes it for every future peer at
|
||||
// the same time.
|
||||
//
|
||||
// It recomputes nothing. computeMetrics is the one aggregation and
|
||||
// metricsFigures is the one formatter, both shared verbatim with the HTTP read,
|
||||
// so a figure here is byte-identical to the same figure on the edge — books owns
|
||||
// both the number and how it is spelled, in exactly one place.
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/plane"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// exposeFigures publishes the ledger's headline read on the internal plane.
|
||||
// Called from Mount.
|
||||
func exposeFigures() {
|
||||
zip.Post[plane.FiguresIn, plane.FiguresOut](cloud.Plane(), "/books/figures", planeFigures,
|
||||
zip.WithOperationID(plane.BooksFigures),
|
||||
zip.WithSummary("The caller's headline ledger figures"))
|
||||
}
|
||||
|
||||
// planeFigures answers the caller's own all-time ledger snapshot as formatted
|
||||
// figures.
|
||||
//
|
||||
// The org is the CALLER's plane identity and there is no argument that could
|
||||
// name another: [plane.FiguresIn] is empty, and the identity zip forwards is the
|
||||
// one the gateway minted. Anonymous is REFUSED rather than defaulted — a books
|
||||
// read with no principal behind it is not a read of nobody's ledger, it is a
|
||||
// read of the first org whose name a bug supplies.
|
||||
//
|
||||
// The LIVE ledger, never the sandbox. A sandbox figure narrated as an answer
|
||||
// about the business would be a fabricated number wearing a real one's label,
|
||||
// and the selector that would let a caller ask for it is the same argument this
|
||||
// op refuses to grow.
|
||||
func planeFigures(ctx context.Context, _ *plane.FiguresIn) (*plane.FiguresOut, error) {
|
||||
who := cloud.Who(ctx)
|
||||
if who.Org == "" {
|
||||
return nil, zip.ErrForbidden("books figures: org required")
|
||||
}
|
||||
if mounted == nil {
|
||||
return nil, zip.Errorf(503, "books not mounted")
|
||||
}
|
||||
st, err := mounted.storeFor(who.Org, false)
|
||||
if err != nil {
|
||||
return nil, zip.ErrInternal("books figures: open failed")
|
||||
}
|
||||
m, err := computeMetrics(ctx, st, "", "")
|
||||
if err != nil {
|
||||
return nil, zip.ErrInternal("books figures: metrics failed")
|
||||
}
|
||||
return &plane.FiguresOut{Figures: planeFiguresOf(metricsFigures(m))}, nil
|
||||
}
|
||||
|
||||
// planeFiguresOf carries books' own figures onto the wire shape unchanged. It is
|
||||
// a projection and never a computation: same labels, same already-formatted
|
||||
// values, same period. The two types are separate because the plane package
|
||||
// cannot import an app, not because the figures differ.
|
||||
func planeFiguresOf(in []Figure) []plane.Figure {
|
||||
out := make([]plane.Figure, 0, len(in))
|
||||
for _, f := range in {
|
||||
out = append(out, plane.Figure{Label: f.Label, Value: f.Value, Period: f.Period})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -178,6 +178,9 @@ func init() {
|
||||
},
|
||||
Example: json.RawMessage(`{"sandbox":"false"}`),
|
||||
})
|
||||
zip.Describe("POST /books/figures", zip.Doc{
|
||||
Description: "Answers the caller's own all-time ledger snapshot as formatted\nfigures.\n\nThe org is the CALLER's plane identity and there is no argument that could\nname another: [plane.FiguresIn] is empty, and the identity zip forwards is the\none the gateway minted. Anonymous is REFUSED rather than defaulted — a books\nread with no principal behind it is not a read of nobody's ledger, it is a\nread of the first org whose name a bug supplies.\n\nThe LIVE ledger, never the sandbox. A sandbox figure narrated as an answer\nabout the business would be a fabricated number wearing a real one's label,\nand the selector that would let a caller ask for it is the same argument this\nop refuses to grow.",
|
||||
})
|
||||
zip.Describe("POST /v1/books/ask", zip.Doc{
|
||||
Description: "Answers a plain-language question about the caller's own books — \"what is my\nMRR?\", \"how long is my runway?\" — with figures taken from their ledger, never a guessed\nnumber. A deterministic keyword router picks the intent and reads the real metrics, and\nthose figures, followups and report sources are computed BEFORE any model call and are\nnever altered by one: the optional narration seam only rephrases the sentence, and it\ndegrades silently to the templated answer when no AI plane is wired. It is strictly\nread-only — it restates the books, it never posts to them.",
|
||||
Fields: map[string]string{
|
||||
|
||||
+1
-1
@@ -85,7 +85,7 @@ func mountRelay(app cloud.Router, deps cloud.Deps) error {
|
||||
return fmt.Errorf("bots.mountRelay: nil deps.Logger")
|
||||
}
|
||||
s := &relay{
|
||||
target: executorURL(),
|
||||
target: executorURL(""),
|
||||
log: deps.Logger.New("subsystem", "bots"),
|
||||
cc: &http.Client{Timeout: 60 * time.Second},
|
||||
}
|
||||
|
||||
+25
-8
@@ -96,6 +96,17 @@ type Call struct {
|
||||
User string
|
||||
Body any
|
||||
Secret bool
|
||||
|
||||
// Base overrides the destination for THIS call. Empty means the bot runtime.
|
||||
//
|
||||
// This stays a transport concern and not a meaning one: a caller names WHERE
|
||||
// its bytes go, and this file still does not know a coding run from a channel
|
||||
// relay. It exists because a sandbox is not the bot — deep research, bare
|
||||
// exec and coding all want a SANDBOX, and whatever runs sandboxes may not be
|
||||
// the service that runs channels. Resolving that address inside here would
|
||||
// mean this file learning what a run is, which is exactly the line the header
|
||||
// draws.
|
||||
Base string
|
||||
}
|
||||
|
||||
// Do invokes c and discards any response payload — the command form. It returns
|
||||
@@ -173,7 +184,7 @@ func Stream(ctx context.Context, c Call, fn func(msg []byte)) error {
|
||||
// off: the runtime then fails the request closed at its own auth gate.
|
||||
func send(ctx context.Context, c Call, method, accept string) (*http.Response, error) {
|
||||
if c.Secret {
|
||||
if err := requireSecure(); err != nil {
|
||||
if err := requireSecure(c.Base); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -185,7 +196,7 @@ func send(ctx context.Context, c Call, method, accept string) (*http.Response, e
|
||||
}
|
||||
body = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, executorURL()+c.Op, body)
|
||||
req, err := http.NewRequestWithContext(ctx, method, executorURL(c.Base)+c.Op, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("runtime: build call: %w", err)
|
||||
}
|
||||
@@ -262,8 +273,12 @@ func ErrBody(resp *http.Response) string {
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
// executorURL resolves the executor base, no trailing slash.
|
||||
func executorURL() string {
|
||||
// executorURL resolves the base for one call, no trailing slash. A Call's own
|
||||
// Base wins; otherwise the bot runtime.
|
||||
func executorURL(base string) string {
|
||||
if base != "" {
|
||||
return strings.TrimRight(base, "/")
|
||||
}
|
||||
if v := getenv(urlEnv); v != "" {
|
||||
return strings.TrimRight(v, "/")
|
||||
}
|
||||
@@ -279,13 +294,15 @@ func executorURL() string {
|
||||
// This guard exists because the transport does not authenticate its peer. A ZAP
|
||||
// entry point pins X25519MLKEM768 and refuses a classical-only peer structurally,
|
||||
// which is what makes this check unnecessary rather than merely satisfied.
|
||||
func requireSecure() error {
|
||||
u := executorURL()
|
||||
// It checks the base the call will ACTUALLY use: guarding the default while the
|
||||
// bytes go somewhere else would be a guard on the wrong hop.
|
||||
func requireSecure(base string) error {
|
||||
u := executorURL(base)
|
||||
if strings.HasPrefix(u, "https://") || getenv(plaintextEnv) == "1" {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("runtime: refusing to send a credential over cleartext %q (set %s to https, or %s=1 if the hop is mesh-mTLS secured)",
|
||||
u, urlEnv, plaintextEnv)
|
||||
return fmt.Errorf("runtime: refusing to send a credential over cleartext %q (set the destination to https, or %s=1 if the hop is mesh-mTLS secured)",
|
||||
u, plaintextEnv)
|
||||
}
|
||||
|
||||
func getenv(key string) string { return strings.TrimSpace(os.Getenv(key)) }
|
||||
|
||||
+89
-26
@@ -26,37 +26,95 @@ const defaultRangeDays = 30
|
||||
|
||||
// ChannelMetric is one channel's spend contribution to a campaign's metrics.
|
||||
type ChannelMetric struct {
|
||||
Kind string `json:"kind"`
|
||||
Platform string `json:"platform"`
|
||||
Status string `json:"status"`
|
||||
// Kind is which channel this row is: paid, organic or email. It is also the
|
||||
// row's identity — a campaign carries at most one channel per kind.
|
||||
Kind string `json:"kind"`
|
||||
// Platform is the provider the spend was read from: meta, google, x, instagram,
|
||||
// or the email provider.
|
||||
Platform string `json:"platform"`
|
||||
// Status is the channel's launch state on the campaign — pending, live, paused,
|
||||
// failed or unavailable. Only a live channel is asked for its spend at all.
|
||||
Status string `json:"status"`
|
||||
// ExternalID is the provider-side id of the execution the spend belongs to.
|
||||
// Absent until the channel has launched.
|
||||
ExternalID string `json:"externalId,omitempty"`
|
||||
SpendCents int64 `json:"spendCents"`
|
||||
SpendError string `json:"spendError,omitempty"` // honest: connector spend read failed
|
||||
// SpendCents is what the provider itself reports this channel spent, in CENTS.
|
||||
// 0 when the channel never launched, when no executor is wired for it, or when
|
||||
// the read failed — SpendError tells the last case apart from a genuine zero.
|
||||
SpendCents int64 `json:"spendCents"`
|
||||
// SpendError is why this channel's spend could not be read (connector not
|
||||
// connected, provider error), as one secret-free line. Present only on failure;
|
||||
// the campaign total then simply omits this channel rather than failing.
|
||||
SpendError string `json:"spendError,omitempty"`
|
||||
}
|
||||
|
||||
// Metrics is a campaign's results view: the analytics-sourced funnel + the
|
||||
// campaignResults is a campaign's results view: the analytics-sourced funnel + the
|
||||
// connector-sourced spend + the derived growth KPIs. Available reflects the
|
||||
// analytics events lens (false = warehouse not yet emitting, honest-empty).
|
||||
type Metrics struct {
|
||||
CampaignID string `json:"campaignId"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Range string `json:"range"`
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
Available bool `json:"available"`
|
||||
Impressions int64 `json:"impressions"`
|
||||
Clicks int64 `json:"clicks"`
|
||||
Conversions int64 `json:"conversions"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
Visitors int64 `json:"visitors"`
|
||||
SpendCents int64 `json:"spendCents"`
|
||||
CTR float64 `json:"ctr"` // clicks / impressions
|
||||
CVR float64 `json:"cvr"` // conversions / clicks
|
||||
CAC float64 `json:"cac"` // spend $ per conversion
|
||||
ROAS float64 `json:"roas"` // revenue per spend $
|
||||
Channels []ChannelMetric `json:"channels"`
|
||||
Source string `json:"source"`
|
||||
//
|
||||
// Declared under its published name for the reason campaignRecord is (store.go): a
|
||||
// field's prose reaches the document only under the name its struct literal is
|
||||
// declared with.
|
||||
type campaignResults struct {
|
||||
// CampaignID is the campaign these results are for, echoed from the request.
|
||||
CampaignID string `json:"campaignId"`
|
||||
// Name is the campaign's display name at read time, so a result can be labelled
|
||||
// without a second fetch.
|
||||
Name string `json:"name"`
|
||||
// Status is the campaign's lifecycle state at read time — draft, live, paused,
|
||||
// completed or failed. A draft has never run, so its funnel is legitimately zero.
|
||||
Status string `json:"status"`
|
||||
// Range is the window actually used: 24h, 7d, 30d, 90d, or "custom" when an
|
||||
// explicit start/end pair was honored. An unparseable or absent range reads 30d,
|
||||
// so this is the value to trust, not the one that was sent.
|
||||
Range string `json:"range"`
|
||||
// Start is the window's inclusive start, RFC3339 UTC.
|
||||
Start string `json:"start"`
|
||||
// End is the window's end, RFC3339 UTC — the read's own clock unless an explicit
|
||||
// pair was given. The window is a LOOKBACK, not the campaign's own lifetime.
|
||||
End string `json:"end"`
|
||||
// Available is false when the analytics warehouse is not connected or the query
|
||||
// failed: the funnel below is then zero because nothing could be read, not
|
||||
// because nothing happened. Spend and Channels are still real — they come from
|
||||
// the connectors, not the warehouse.
|
||||
Available bool `json:"available"`
|
||||
// Impressions is how many times the campaign's creatives were shown, counted
|
||||
// from its utm_campaign-tagged impression events.
|
||||
Impressions int64 `json:"impressions"`
|
||||
// Clicks is the campaign's click events over the window.
|
||||
Clicks int64 `json:"clicks"`
|
||||
// Conversions is the terminal funnel events attributed to the campaign — orders
|
||||
// completed, signups completed, explicit conversion events.
|
||||
Conversions int64 `json:"conversions"`
|
||||
// Revenue is the summed revenue attribute of the campaign's events, in whole
|
||||
// CURRENCY UNITS (dollars) — the one money value here that is not in cents.
|
||||
Revenue float64 `json:"revenue"`
|
||||
// Visitors is how many distinct people the campaign reached, counted by event
|
||||
// identity across ALL its events in the window — not a subset of Impressions, so
|
||||
// it can exceed them for a campaign whose provider reports clicks but not views.
|
||||
Visitors int64 `json:"visitors"`
|
||||
// SpendCents is the campaign's total spend in CENTS: the sum of what each live
|
||||
// channel's provider reports. A channel whose spend could not be read
|
||||
// contributes 0 and says so on its own row.
|
||||
SpendCents int64 `json:"spendCents"`
|
||||
// CTR is clicks per impression, a fraction rounded to 4 places (0.0123 = 1.23%),
|
||||
// not a percentage. 0 when there were no impressions to divide by.
|
||||
CTR float64 `json:"ctr"`
|
||||
// CVR is conversions per click, a fraction rounded to 4 places. 0 when there
|
||||
// were no clicks.
|
||||
CVR float64 `json:"cvr"`
|
||||
// CAC is customer acquisition cost: spend DOLLARS per conversion, rounded to
|
||||
// cents. 0 when nothing converted — that is "not yet computable", not "free".
|
||||
CAC float64 `json:"cac"`
|
||||
// ROAS is return on ad spend: revenue per spend DOLLAR, rounded to 2 places
|
||||
// (2.5 = $2.50 back per $1). 0 when nothing was spent.
|
||||
ROAS float64 `json:"roas"`
|
||||
// Channels is the per-channel spend breakdown that SpendCents sums, one row per
|
||||
// channel on the campaign including the ones that never launched.
|
||||
Channels []ChannelMetric `json:"channels"`
|
||||
// Source names the analytics table the funnel was read from, so an operator can
|
||||
// see exactly what was counted. Set even when Available is false.
|
||||
Source string `json:"source"`
|
||||
// ABTest is the creative A/B analysis from the experiments primitive
|
||||
// (experiments.Analyze, pull-model), present only when the campaign runs
|
||||
// more than one creative and an experiment is wired. Opaque JSON — campaign
|
||||
@@ -64,6 +122,11 @@ type Metrics struct {
|
||||
ABTest json.RawMessage `json:"abTest,omitempty"`
|
||||
}
|
||||
|
||||
// Metrics is the domain spelling of campaignResults — an ALIAS, the same type, for
|
||||
// the same reason Campaign is one for campaignRecord (store.go): apps/agents
|
||||
// already publishes a "Metrics" into the fleet's flat schema namespace.
|
||||
type Metrics = campaignResults
|
||||
|
||||
// channelSpend fans the spend read across a campaign's live channels. Each read
|
||||
// is best-effort: a connector-disabled or provider-error channel contributes 0
|
||||
// with an honest SpendError, never failing the whole metrics read. The org is
|
||||
|
||||
+80
-19
@@ -52,33 +52,94 @@ const (
|
||||
// provider-side id + status the orchestrator recorded. It carries NO credential;
|
||||
// the executor resolves the org's connector token itself at launch time.
|
||||
type ChannelSpec struct {
|
||||
Kind string `json:"kind"` // paid | organic | email
|
||||
Platform string `json:"platform"` // meta | google | x | instagram | (email provider)
|
||||
Account string `json:"account,omitempty"` // provider account ref (ad-account/page/list id)
|
||||
// Kind is the channel and the identity a campaign holds at most one of: paid,
|
||||
// organic or email. It picks the executor the launch fans out to.
|
||||
Kind string `json:"kind"`
|
||||
// Platform is the provider within the kind — meta, google, x, instagram, or the
|
||||
// email provider.
|
||||
Platform string `json:"platform"`
|
||||
// Account is the provider account this channel runs under: an ad-account, a page
|
||||
// or a mailing-list id. An executor may replace it at launch with the account it
|
||||
// actually used.
|
||||
Account string `json:"account,omitempty"`
|
||||
// ExternalID is the provider-side id of the running execution, recorded by the
|
||||
// orchestrator at launch and handed back verbatim to read spend or to pause.
|
||||
// Server-owned and absent until this channel has launched; anything a caller
|
||||
// sends for it is dropped.
|
||||
ExternalID string `json:"externalId,omitempty"`
|
||||
Status string `json:"status"` // pending | live | paused | failed | unavailable
|
||||
Detail string `json:"detail,omitempty"` // honest last-outcome detail (never a secret)
|
||||
// Status is this channel's own launch outcome, not the campaign's: pending (added,
|
||||
// never launched), live, paused, failed (Detail says why) or unavailable (no
|
||||
// executor wired on this deployment). Server-owned — a caller can never assert it.
|
||||
Status string `json:"status"`
|
||||
// Detail is the last outcome in one secret-free line — the failure reason, or
|
||||
// what the executor reported. Absent when there is nothing to explain.
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// Campaign is the top-level GTM object — a VALUE that spans channels. Budget is
|
||||
// minor units (cents). Content is the ordered creative set (Content[0] is the
|
||||
// campaignRecord is the top-level GTM object — a VALUE that spans channels. Budget
|
||||
// is minor units (cents). Content is the ordered creative set (Content[0] is the
|
||||
// active creative; the rest are A/B variants when an experiment is composed).
|
||||
// Metrics are deliberately NOT a field: they are read at query time from the ONE
|
||||
// analytics plane (metrics.go), never stored here.
|
||||
type Campaign struct {
|
||||
ID string `json:"id"`
|
||||
Org string `json:"-"` // tenant key — server-set from the validated owner claim, never client
|
||||
Name string `json:"name"`
|
||||
Audience string `json:"audience,omitempty"` // segment/audience selector ref
|
||||
Content []string `json:"content"` // creative(s)
|
||||
Channels []ChannelSpec `json:"channels"` // fan-out targets
|
||||
ScheduleAt int64 `json:"scheduleAt,omitempty"`
|
||||
Budget int64 `json:"budget"` // cents
|
||||
Status string `json:"status"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
//
|
||||
// It is DECLARED under its published name and spelled Campaign everywhere else,
|
||||
// rather than the reverse, because prose only reaches the document keyed by the
|
||||
// name the FIELDS are declared under: zipdoc lifts a field's comment from the
|
||||
// struct literal it stands in, and the schema builder looks it up under the type
|
||||
// reflect names. A defined type over another struct (type campaignRecord Campaign)
|
||||
// has no literal of its own, so every field of it published bare.
|
||||
type campaignRecord struct {
|
||||
// ID is the campaign's server-minted handle — "cmp_" and 128 random bits — and
|
||||
// the id every other campaign call is addressed by. Never read off the wire: a
|
||||
// create that sends one has it ignored.
|
||||
ID string `json:"id"`
|
||||
// Org is the owning tenant, set from the validated bearer's owner claim. It is
|
||||
// the isolation key on every query and is deliberately NOT published: a caller
|
||||
// only ever sees their own org's campaigns, so the field would say nothing.
|
||||
Org string `json:"-"`
|
||||
// Name is the campaign's display name. Required on write, trimmed, and capped at
|
||||
// 2048 characters.
|
||||
Name string `json:"name"`
|
||||
// Audience is an opaque reference to the segment this campaign targets. It is
|
||||
// stored and echoed but not yet handed to the executors — a channel targets
|
||||
// through the provider account it runs under — so it is documentation for now.
|
||||
// Absent when never set.
|
||||
Audience string `json:"audience,omitempty"`
|
||||
// Content is the ordered creative set, at most 32, empty entries dropped.
|
||||
// Content[0] is the creative that runs; the rest are A/B variants a wired
|
||||
// experiment can assign per launch.
|
||||
Content []string `json:"content"`
|
||||
// Channels are the fan-out targets, at most one per kind and at most 12, each
|
||||
// carrying its own post-launch state. Empty means nothing to launch, which is
|
||||
// what makes a launch of this campaign a 400.
|
||||
Channels []ChannelSpec `json:"channels"`
|
||||
// ScheduleAt is when the campaign should run, in unix seconds. 0 (absent) means
|
||||
// launch immediately. It is passed to each executor; nothing in this service
|
||||
// wakes up to launch it for you.
|
||||
ScheduleAt int64 `json:"scheduleAt,omitempty"`
|
||||
// Budget is the campaign's total budget in CENTS, handed to each executor as the
|
||||
// budget for its channel. 0 means none was set.
|
||||
Budget int64 `json:"budget"`
|
||||
// Status is the lifecycle state, server-owned and never accepted from a caller.
|
||||
// Four values actually occur: draft (inert and fully mutable — nothing is sent
|
||||
// and no budget is committed), live, paused and failed. After a fan-out live
|
||||
// means AT LEAST ONE channel launched — read the channel rows for the rest —
|
||||
// and failed means none did.
|
||||
Status string `json:"status"`
|
||||
// CreatedAt is when the campaign was created, in unix seconds. Server-set.
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
// UpdatedAt is the last write in unix seconds — an edit, a launch or a pause.
|
||||
// Server-set on every save.
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Campaign is the domain spelling of campaignRecord. An ALIAS, so it is the SAME
|
||||
// type and not a second shape to keep in sync: the store, the fan-out and the
|
||||
// executors read in domain language while the document keeps the name the fleet's
|
||||
// flat schema namespace needs (apps/marketing already publishes an email
|
||||
// "Campaign", and openapi.Weave refuses one name meaning two things).
|
||||
type Campaign = campaignRecord
|
||||
|
||||
// Store is the campaign database. ONE SQLite file — the system namespace's
|
||||
// "campaign" — holds every org's records; tenant isolation is the `org` column,
|
||||
// enforced on EVERY query. Mirrors clients/ads exactly (the ONE storage
|
||||
|
||||
+13
-22
@@ -106,18 +106,12 @@ type campaignRef struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// campaignRecord is a campaign as the API publishes it. It is a DEFINED type over
|
||||
// Campaign, not a second shape: the fields and their json tags are the same value,
|
||||
// so the wire is byte-identical. It exists because the fleet's schema namespace is
|
||||
// FLAT — openapi.Weave refuses one name meaning two things — and apps/marketing
|
||||
// already publishes an email "Campaign". One name, one shape, so this one says which
|
||||
// plane it belongs to.
|
||||
type campaignRecord Campaign
|
||||
|
||||
// campaignResults is a campaign's metrics as the API publishes it — a DEFINED type
|
||||
// over Metrics for the same reason campaignRecord is one: apps/agents already
|
||||
// publishes a "Metrics".
|
||||
type campaignResults Metrics
|
||||
// campaignRecord (store.go) and campaignResults (metrics.go) are what this surface
|
||||
// publishes. Each is declared under its published name — with Campaign and Metrics
|
||||
// as domain aliases of the same type — because the fleet's schema namespace is FLAT
|
||||
// (openapi.Weave refuses one name meaning two things, and apps/marketing already
|
||||
// publishes an email "Campaign", apps/agents a "Metrics"), and because a field's
|
||||
// doc comment only reaches the document under the name its struct literal carries.
|
||||
|
||||
// campaignFilter narrows the org's campaign list. Both fields are query
|
||||
// parameters and both are optional; an unparseable limit reads as the default,
|
||||
@@ -262,9 +256,7 @@ func (o ops) list(ctx context.Context, in *campaignFilter) (*campaignPage, error
|
||||
return nil, zip.Errorf(http.StatusInternalServerError, "list: %v", err)
|
||||
}
|
||||
page := make([]campaignRecord, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
page = append(page, campaignRecord(r))
|
||||
}
|
||||
page = append(page, rows...)
|
||||
return &campaignPage{Data: page}, nil
|
||||
}
|
||||
|
||||
@@ -323,10 +315,10 @@ func (o ops) create(ctx context.Context, in *campaignWrite) (*campaignRecord, er
|
||||
return record(saved), nil
|
||||
}
|
||||
|
||||
// record publishes a stored campaign under the API's own schema name. The
|
||||
// conversion is free — campaignRecord IS Campaign — and keeps every call site one
|
||||
// line rather than a temporary per return.
|
||||
func record(c Campaign) *campaignRecord { r := campaignRecord(c); return &r }
|
||||
// record answers a stored campaign by pointer. campaignRecord IS Campaign — one
|
||||
// type, two spellings — so this only exists to keep every call site one line
|
||||
// rather than a temporary per return.
|
||||
func record(c campaignRecord) *campaignRecord { return &c }
|
||||
|
||||
// GetCampaign returns one campaign of the caller's org — its name, audience,
|
||||
// creatives, channels with their per-channel launch state, schedule, budget and
|
||||
@@ -446,7 +438,7 @@ func (o ops) metrics(ctx context.Context, in *metricsQuery) (*campaignResults, e
|
||||
// Spend — each live channel's connector-reported spend, fanned in fail-soft.
|
||||
spendCents, chMetrics := channelSpend(ctx, org, camp)
|
||||
|
||||
m := Metrics{
|
||||
m := campaignResults{
|
||||
CampaignID: camp.ID,
|
||||
Name: camp.Name,
|
||||
Status: camp.Status,
|
||||
@@ -470,8 +462,7 @@ func (o ops) metrics(ctx context.Context, in *metricsQuery) (*campaignResults, e
|
||||
// A/B lens: the experiments primitive's pull-model analysis (nil when the
|
||||
// campaign runs a single creative or no experiment is wired).
|
||||
m.ABTest = analyzeExperiment(ctx, org, camp, start, end)
|
||||
out := campaignResults(m)
|
||||
return &out, nil
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// AddCampaignChannel adds a channel to a campaign, or REPLACES the one it already
|
||||
|
||||
+149
-58
@@ -18,48 +18,106 @@ func init() {
|
||||
zip.Describe("DELETE /v1/campaign/:id/channels/:kind", zip.Doc{
|
||||
Description: "Drops one channel from a campaign and returns the updated\ncampaign. 404 when the campaign carries no channel of that kind.\n\nIt removes the channel from the PLAN. A channel that is live at its provider\nshould be paused first — dropping the row here leaves nothing to pause it with\nafterwards.",
|
||||
Fields: map[string]string{
|
||||
"ChannelSpec.account": "provider account ref (ad-account/page/list id)",
|
||||
"ChannelSpec.detail": "honest last-outcome detail (never a secret)",
|
||||
"ChannelSpec.kind": "paid | organic | email",
|
||||
"ChannelSpec.platform": "meta | google | x | instagram | (email provider)",
|
||||
"ChannelSpec.status": "pending | live | paused | failed | unavailable",
|
||||
"channelRef.id": "ID is the campaign, from the path.",
|
||||
"channelRef.kind": "Kind is the channel to remove: paid, organic or email.",
|
||||
"ChannelSpec.account": "Account is the provider account this channel runs under: an ad-account, a page\nor a mailing-list id. An executor may replace it at launch with the account it\nactually used.",
|
||||
"ChannelSpec.detail": "Detail is the last outcome in one secret-free line — the failure reason, or\nwhat the executor reported. Absent when there is nothing to explain.",
|
||||
"ChannelSpec.externalId": "ExternalID is the provider-side id of the running execution, recorded by the\norchestrator at launch and handed back verbatim to read spend or to pause.\nServer-owned and absent until this channel has launched; anything a caller\nsends for it is dropped.",
|
||||
"ChannelSpec.kind": "Kind is the channel and the identity a campaign holds at most one of: paid,\norganic or email. It picks the executor the launch fans out to.",
|
||||
"ChannelSpec.platform": "Platform is the provider within the kind — meta, google, x, instagram, or the\nemail provider.",
|
||||
"ChannelSpec.status": "Status is this channel's own launch outcome, not the campaign's: pending (added,\nnever launched), live, paused, failed (Detail says why) or unavailable (no\nexecutor wired on this deployment). Server-owned — a caller can never assert it.",
|
||||
"campaignRecord.audience": "Audience is an opaque reference to the segment this campaign targets. It is\nstored and echoed but not yet handed to the executors — a channel targets\nthrough the provider account it runs under — so it is documentation for now.\nAbsent when never set.",
|
||||
"campaignRecord.budget": "Budget is the campaign's total budget in CENTS, handed to each executor as the\nbudget for its channel. 0 means none was set.",
|
||||
"campaignRecord.channels": "Channels are the fan-out targets, at most one per kind and at most 12, each\ncarrying its own post-launch state. Empty means nothing to launch, which is\nwhat makes a launch of this campaign a 400.",
|
||||
"campaignRecord.content": "Content is the ordered creative set, at most 32, empty entries dropped.\nContent[0] is the creative that runs; the rest are A/B variants a wired\nexperiment can assign per launch.",
|
||||
"campaignRecord.createdAt": "CreatedAt is when the campaign was created, in unix seconds. Server-set.",
|
||||
"campaignRecord.id": "ID is the campaign's server-minted handle — \"cmp_\" and 128 random bits — and\nthe id every other campaign call is addressed by. Never read off the wire: a\ncreate that sends one has it ignored.",
|
||||
"campaignRecord.name": "Name is the campaign's display name. Required on write, trimmed, and capped at\n2048 characters.",
|
||||
"campaignRecord.scheduleAt": "ScheduleAt is when the campaign should run, in unix seconds. 0 (absent) means\nlaunch immediately. It is passed to each executor; nothing in this service\nwakes up to launch it for you.",
|
||||
"campaignRecord.status": "Status is the lifecycle state, server-owned and never accepted from a caller.\nFour values actually occur: draft (inert and fully mutable — nothing is sent\nand no budget is committed), live, paused and failed. After a fan-out live\nmeans AT LEAST ONE channel launched — read the channel rows for the rest —\nand failed means none did.",
|
||||
"campaignRecord.updatedAt": "UpdatedAt is the last write in unix seconds — an edit, a launch or a pause.\nServer-set on every save.",
|
||||
"channelRef.id": "ID is the campaign, from the path.",
|
||||
"channelRef.kind": "Kind is the channel to remove: paid, organic or email.",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/campaign", zip.Doc{
|
||||
Description: "Returns the org's campaigns, newest first, optionally narrowed to\none status.\n\nA campaign is the top-level go-to-market object: a value that SPANS channels\n(paid, organic, email) and fans out to the executor for each. The listing is\norg-scoped server-side, so one org can never see another's campaigns.",
|
||||
Fields: map[string]string{
|
||||
"ChannelSpec.account": "provider account ref (ad-account/page/list id)",
|
||||
"ChannelSpec.detail": "honest last-outcome detail (never a secret)",
|
||||
"ChannelSpec.kind": "paid | organic | email",
|
||||
"ChannelSpec.platform": "meta | google | x | instagram | (email provider)",
|
||||
"ChannelSpec.status": "pending | live | paused | failed | unavailable",
|
||||
"campaignFilter.limit": "Limit bounds the page. 0 or less means the default of 200; anything above\n1000 is clamped to 1000.",
|
||||
"campaignFilter.status": "Status keeps only campaigns in that state: draft, live, paused or failed.\nEmpty means any.",
|
||||
"campaignPage.data": "Data are the campaigns on this page.",
|
||||
"ChannelSpec.account": "Account is the provider account this channel runs under: an ad-account, a page\nor a mailing-list id. An executor may replace it at launch with the account it\nactually used.",
|
||||
"ChannelSpec.detail": "Detail is the last outcome in one secret-free line — the failure reason, or\nwhat the executor reported. Absent when there is nothing to explain.",
|
||||
"ChannelSpec.externalId": "ExternalID is the provider-side id of the running execution, recorded by the\norchestrator at launch and handed back verbatim to read spend or to pause.\nServer-owned and absent until this channel has launched; anything a caller\nsends for it is dropped.",
|
||||
"ChannelSpec.kind": "Kind is the channel and the identity a campaign holds at most one of: paid,\norganic or email. It picks the executor the launch fans out to.",
|
||||
"ChannelSpec.platform": "Platform is the provider within the kind — meta, google, x, instagram, or the\nemail provider.",
|
||||
"ChannelSpec.status": "Status is this channel's own launch outcome, not the campaign's: pending (added,\nnever launched), live, paused, failed (Detail says why) or unavailable (no\nexecutor wired on this deployment). Server-owned — a caller can never assert it.",
|
||||
"campaignFilter.limit": "Limit bounds the page. 0 or less means the default of 200; anything above\n1000 is clamped to 1000.",
|
||||
"campaignFilter.status": "Status keeps only campaigns in that state: draft, live, paused or failed.\nEmpty means any.",
|
||||
"campaignPage.data": "Data are the campaigns on this page.",
|
||||
"campaignRecord.audience": "Audience is an opaque reference to the segment this campaign targets. It is\nstored and echoed but not yet handed to the executors — a channel targets\nthrough the provider account it runs under — so it is documentation for now.\nAbsent when never set.",
|
||||
"campaignRecord.budget": "Budget is the campaign's total budget in CENTS, handed to each executor as the\nbudget for its channel. 0 means none was set.",
|
||||
"campaignRecord.channels": "Channels are the fan-out targets, at most one per kind and at most 12, each\ncarrying its own post-launch state. Empty means nothing to launch, which is\nwhat makes a launch of this campaign a 400.",
|
||||
"campaignRecord.content": "Content is the ordered creative set, at most 32, empty entries dropped.\nContent[0] is the creative that runs; the rest are A/B variants a wired\nexperiment can assign per launch.",
|
||||
"campaignRecord.createdAt": "CreatedAt is when the campaign was created, in unix seconds. Server-set.",
|
||||
"campaignRecord.id": "ID is the campaign's server-minted handle — \"cmp_\" and 128 random bits — and\nthe id every other campaign call is addressed by. Never read off the wire: a\ncreate that sends one has it ignored.",
|
||||
"campaignRecord.name": "Name is the campaign's display name. Required on write, trimmed, and capped at\n2048 characters.",
|
||||
"campaignRecord.scheduleAt": "ScheduleAt is when the campaign should run, in unix seconds. 0 (absent) means\nlaunch immediately. It is passed to each executor; nothing in this service\nwakes up to launch it for you.",
|
||||
"campaignRecord.status": "Status is the lifecycle state, server-owned and never accepted from a caller.\nFour values actually occur: draft (inert and fully mutable — nothing is sent\nand no budget is committed), live, paused and failed. After a fan-out live\nmeans AT LEAST ONE channel launched — read the channel rows for the rest —\nand failed means none did.",
|
||||
"campaignRecord.updatedAt": "UpdatedAt is the last write in unix seconds — an edit, a launch or a pause.\nServer-set on every save.",
|
||||
},
|
||||
Example: json.RawMessage(`{"status":"live","limit":50}`),
|
||||
})
|
||||
zip.Describe("GET /v1/campaign/:id", zip.Doc{
|
||||
Description: "Returns one campaign of the caller's org — its name, audience,\ncreatives, channels with their per-channel launch state, schedule, budget and\nstatus. 404 when the org has no campaign with that id.",
|
||||
Fields: map[string]string{
|
||||
"ChannelSpec.account": "provider account ref (ad-account/page/list id)",
|
||||
"ChannelSpec.detail": "honest last-outcome detail (never a secret)",
|
||||
"ChannelSpec.kind": "paid | organic | email",
|
||||
"ChannelSpec.platform": "meta | google | x | instagram | (email provider)",
|
||||
"ChannelSpec.status": "pending | live | paused | failed | unavailable",
|
||||
"campaignRef.id": "ID is the campaign's server-minted handle, \"cmp_\"-prefixed.",
|
||||
"ChannelSpec.account": "Account is the provider account this channel runs under: an ad-account, a page\nor a mailing-list id. An executor may replace it at launch with the account it\nactually used.",
|
||||
"ChannelSpec.detail": "Detail is the last outcome in one secret-free line — the failure reason, or\nwhat the executor reported. Absent when there is nothing to explain.",
|
||||
"ChannelSpec.externalId": "ExternalID is the provider-side id of the running execution, recorded by the\norchestrator at launch and handed back verbatim to read spend or to pause.\nServer-owned and absent until this channel has launched; anything a caller\nsends for it is dropped.",
|
||||
"ChannelSpec.kind": "Kind is the channel and the identity a campaign holds at most one of: paid,\norganic or email. It picks the executor the launch fans out to.",
|
||||
"ChannelSpec.platform": "Platform is the provider within the kind — meta, google, x, instagram, or the\nemail provider.",
|
||||
"ChannelSpec.status": "Status is this channel's own launch outcome, not the campaign's: pending (added,\nnever launched), live, paused, failed (Detail says why) or unavailable (no\nexecutor wired on this deployment). Server-owned — a caller can never assert it.",
|
||||
"campaignRecord.audience": "Audience is an opaque reference to the segment this campaign targets. It is\nstored and echoed but not yet handed to the executors — a channel targets\nthrough the provider account it runs under — so it is documentation for now.\nAbsent when never set.",
|
||||
"campaignRecord.budget": "Budget is the campaign's total budget in CENTS, handed to each executor as the\nbudget for its channel. 0 means none was set.",
|
||||
"campaignRecord.channels": "Channels are the fan-out targets, at most one per kind and at most 12, each\ncarrying its own post-launch state. Empty means nothing to launch, which is\nwhat makes a launch of this campaign a 400.",
|
||||
"campaignRecord.content": "Content is the ordered creative set, at most 32, empty entries dropped.\nContent[0] is the creative that runs; the rest are A/B variants a wired\nexperiment can assign per launch.",
|
||||
"campaignRecord.createdAt": "CreatedAt is when the campaign was created, in unix seconds. Server-set.",
|
||||
"campaignRecord.id": "ID is the campaign's server-minted handle — \"cmp_\" and 128 random bits — and\nthe id every other campaign call is addressed by. Never read off the wire: a\ncreate that sends one has it ignored.",
|
||||
"campaignRecord.name": "Name is the campaign's display name. Required on write, trimmed, and capped at\n2048 characters.",
|
||||
"campaignRecord.scheduleAt": "ScheduleAt is when the campaign should run, in unix seconds. 0 (absent) means\nlaunch immediately. It is passed to each executor; nothing in this service\nwakes up to launch it for you.",
|
||||
"campaignRecord.status": "Status is the lifecycle state, server-owned and never accepted from a caller.\nFour values actually occur: draft (inert and fully mutable — nothing is sent\nand no budget is committed), live, paused and failed. After a fan-out live\nmeans AT LEAST ONE channel launched — read the channel rows for the rest —\nand failed means none did.",
|
||||
"campaignRecord.updatedAt": "UpdatedAt is the last write in unix seconds — an edit, a launch or a pause.\nServer-set on every save.",
|
||||
"campaignRef.id": "ID is the campaign's server-minted handle, \"cmp_\"-prefixed.",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/campaign/:id/metrics", zip.Doc{
|
||||
Description: "Returns a campaign's results over a window: the analytics\nfunnel (impressions, clicks, conversions, revenue, visitors), the spend each\nchannel's connector reports, and the derived growth KPIs — CTR, CVR, CAC and\nROAS.\n\nThere is exactly ONE metrics plane and nothing is stored here: the funnel is an\nanalytics query over the campaign's utm_campaign-tagged events, and the spend is\neach provider's own number read through the org's connector. A warehouse that is\nnot emitting yet degrades to available:false with zeroes — honest-empty, never a\n500 and never a fabricated number. When the campaign runs more than one creative\nand an experiment is wired, abTest carries the A/B analysis.",
|
||||
Fields: map[string]string{
|
||||
"ChannelMetric.spendError": "honest: connector spend read failed",
|
||||
"metricsQuery.end": "End is an explicit RFC3339 window end.",
|
||||
"metricsQuery.id": "ID is the campaign to report on, from the path.",
|
||||
"metricsQuery.range": "Range is the lookback window: 24h, 7d, 30d or 90d. Anything else, including\nempty, reads as 30d.",
|
||||
"metricsQuery.start": "Start is an explicit RFC3339 window start. Honored only together with End,\nand only when End is after it.",
|
||||
"ChannelMetric.externalId": "ExternalID is the provider-side id of the execution the spend belongs to.\nAbsent until the channel has launched.",
|
||||
"ChannelMetric.kind": "Kind is which channel this row is: paid, organic or email. It is also the\nrow's identity — a campaign carries at most one channel per kind.",
|
||||
"ChannelMetric.platform": "Platform is the provider the spend was read from: meta, google, x, instagram,\nor the email provider.",
|
||||
"ChannelMetric.spendCents": "SpendCents is what the provider itself reports this channel spent, in CENTS.\n0 when the channel never launched, when no executor is wired for it, or when\nthe read failed — SpendError tells the last case apart from a genuine zero.",
|
||||
"ChannelMetric.spendError": "SpendError is why this channel's spend could not be read (connector not\nconnected, provider error), as one secret-free line. Present only on failure;\nthe campaign total then simply omits this channel rather than failing.",
|
||||
"ChannelMetric.status": "Status is the channel's launch state on the campaign — pending, live, paused,\nfailed or unavailable. Only a live channel is asked for its spend at all.",
|
||||
"campaignResults.abTest": "ABTest is the creative A/B analysis from the experiments primitive\n(experiments.Analyze, pull-model), present only when the campaign runs\nmore than one creative and an experiment is wired. Opaque JSON — campaign\nstays decoupled from the experiments analysis type.",
|
||||
"campaignResults.available": "Available is false when the analytics warehouse is not connected or the query\nfailed: the funnel below is then zero because nothing could be read, not\nbecause nothing happened. Spend and Channels are still real — they come from\nthe connectors, not the warehouse.",
|
||||
"campaignResults.cac": "CAC is customer acquisition cost: spend DOLLARS per conversion, rounded to\ncents. 0 when nothing converted — that is \"not yet computable\", not \"free\".",
|
||||
"campaignResults.campaignId": "CampaignID is the campaign these results are for, echoed from the request.",
|
||||
"campaignResults.channels": "Channels is the per-channel spend breakdown that SpendCents sums, one row per\nchannel on the campaign including the ones that never launched.",
|
||||
"campaignResults.clicks": "Clicks is the campaign's click events over the window.",
|
||||
"campaignResults.conversions": "Conversions is the terminal funnel events attributed to the campaign — orders\ncompleted, signups completed, explicit conversion events.",
|
||||
"campaignResults.ctr": "CTR is clicks per impression, a fraction rounded to 4 places (0.0123 = 1.23%),\nnot a percentage. 0 when there were no impressions to divide by.",
|
||||
"campaignResults.cvr": "CVR is conversions per click, a fraction rounded to 4 places. 0 when there\nwere no clicks.",
|
||||
"campaignResults.end": "End is the window's end, RFC3339 UTC — the read's own clock unless an explicit\npair was given. The window is a LOOKBACK, not the campaign's own lifetime.",
|
||||
"campaignResults.impressions": "Impressions is how many times the campaign's creatives were shown, counted\nfrom its utm_campaign-tagged impression events.",
|
||||
"campaignResults.name": "Name is the campaign's display name at read time, so a result can be labelled\nwithout a second fetch.",
|
||||
"campaignResults.range": "Range is the window actually used: 24h, 7d, 30d, 90d, or \"custom\" when an\nexplicit start/end pair was honored. An unparseable or absent range reads 30d,\nso this is the value to trust, not the one that was sent.",
|
||||
"campaignResults.revenue": "Revenue is the summed revenue attribute of the campaign's events, in whole\nCURRENCY UNITS (dollars) — the one money value here that is not in cents.",
|
||||
"campaignResults.roas": "ROAS is return on ad spend: revenue per spend DOLLAR, rounded to 2 places\n(2.5 = $2.50 back per $1). 0 when nothing was spent.",
|
||||
"campaignResults.source": "Source names the analytics table the funnel was read from, so an operator can\nsee exactly what was counted. Set even when Available is false.",
|
||||
"campaignResults.spendCents": "SpendCents is the campaign's total spend in CENTS: the sum of what each live\nchannel's provider reports. A channel whose spend could not be read\ncontributes 0 and says so on its own row.",
|
||||
"campaignResults.start": "Start is the window's inclusive start, RFC3339 UTC.",
|
||||
"campaignResults.status": "Status is the campaign's lifecycle state at read time — draft, live, paused,\ncompleted or failed. A draft has never run, so its funnel is legitimately zero.",
|
||||
"campaignResults.visitors": "Visitors is how many distinct people the campaign reached, counted by event\nidentity across ALL its events in the window — not a subset of Impressions, so\nit can exceed them for a campaign whose provider reports clicks but not views.",
|
||||
"metricsQuery.end": "End is an explicit RFC3339 window end.",
|
||||
"metricsQuery.id": "ID is the campaign to report on, from the path.",
|
||||
"metricsQuery.range": "Range is the lookback window: 24h, 7d, 30d or 90d. Anything else, including\nempty, reads as 30d.",
|
||||
"metricsQuery.start": "Start is an explicit RFC3339 window start. Honored only together with End,\nand only when End is after it.",
|
||||
},
|
||||
Example: json.RawMessage(`{"id":"cmp_1f…","range":"7d"}`),
|
||||
})
|
||||
@@ -75,32 +133,54 @@ func init() {
|
||||
zip.Describe("POST /v1/campaign", zip.Doc{
|
||||
Description: "Creates a campaign as a DRAFT and returns it.\n\nA draft is inert: nothing is sent, no connector is touched and no budget is\ncommitted until the campaign is launched. The channels named here are validated\nand de-duplicated by kind (one executor per kind), and every channel starts\n\"pending\" whatever the caller claims — a client can never assert a launched\nstate.",
|
||||
Fields: map[string]string{
|
||||
"ChannelSpec.account": "provider account ref (ad-account/page/list id)",
|
||||
"ChannelSpec.detail": "honest last-outcome detail (never a secret)",
|
||||
"ChannelSpec.kind": "paid | organic | email",
|
||||
"ChannelSpec.platform": "meta | google | x | instagram | (email provider)",
|
||||
"ChannelSpec.status": "pending | live | paused | failed | unavailable",
|
||||
"campaignWrite.audience": "Audience is the segment or audience selector this campaign targets.",
|
||||
"campaignWrite.budget": "Budget is the campaign's total budget in CENTS. Negative reads as 0.",
|
||||
"campaignWrite.channels": "Channels are the fan-out targets, at most one per kind (paid, organic,\nemail) and at most 12. A channel's status and provider id are server-owned:\nwhatever the caller sends for them is replaced with \"pending\".",
|
||||
"campaignWrite.content": "Content is the ordered creative set. Content[0] is the active creative and\nthe rest are A/B variants; at most 32, empty entries dropped.",
|
||||
"campaignWrite.name": "Name is the campaign's display name. Required; trimmed and capped at 2048\ncharacters.",
|
||||
"campaignWrite.scheduleAt": "ScheduleAt is when the campaign should run, in unix seconds. Negative reads\nas 0 (immediately).",
|
||||
"ChannelSpec.account": "Account is the provider account this channel runs under: an ad-account, a page\nor a mailing-list id. An executor may replace it at launch with the account it\nactually used.",
|
||||
"ChannelSpec.detail": "Detail is the last outcome in one secret-free line — the failure reason, or\nwhat the executor reported. Absent when there is nothing to explain.",
|
||||
"ChannelSpec.externalId": "ExternalID is the provider-side id of the running execution, recorded by the\norchestrator at launch and handed back verbatim to read spend or to pause.\nServer-owned and absent until this channel has launched; anything a caller\nsends for it is dropped.",
|
||||
"ChannelSpec.kind": "Kind is the channel and the identity a campaign holds at most one of: paid,\norganic or email. It picks the executor the launch fans out to.",
|
||||
"ChannelSpec.platform": "Platform is the provider within the kind — meta, google, x, instagram, or the\nemail provider.",
|
||||
"ChannelSpec.status": "Status is this channel's own launch outcome, not the campaign's: pending (added,\nnever launched), live, paused, failed (Detail says why) or unavailable (no\nexecutor wired on this deployment). Server-owned — a caller can never assert it.",
|
||||
"campaignRecord.audience": "Audience is an opaque reference to the segment this campaign targets. It is\nstored and echoed but not yet handed to the executors — a channel targets\nthrough the provider account it runs under — so it is documentation for now.\nAbsent when never set.",
|
||||
"campaignRecord.budget": "Budget is the campaign's total budget in CENTS, handed to each executor as the\nbudget for its channel. 0 means none was set.",
|
||||
"campaignRecord.channels": "Channels are the fan-out targets, at most one per kind and at most 12, each\ncarrying its own post-launch state. Empty means nothing to launch, which is\nwhat makes a launch of this campaign a 400.",
|
||||
"campaignRecord.content": "Content is the ordered creative set, at most 32, empty entries dropped.\nContent[0] is the creative that runs; the rest are A/B variants a wired\nexperiment can assign per launch.",
|
||||
"campaignRecord.createdAt": "CreatedAt is when the campaign was created, in unix seconds. Server-set.",
|
||||
"campaignRecord.id": "ID is the campaign's server-minted handle — \"cmp_\" and 128 random bits — and\nthe id every other campaign call is addressed by. Never read off the wire: a\ncreate that sends one has it ignored.",
|
||||
"campaignRecord.name": "Name is the campaign's display name. Required on write, trimmed, and capped at\n2048 characters.",
|
||||
"campaignRecord.scheduleAt": "ScheduleAt is when the campaign should run, in unix seconds. 0 (absent) means\nlaunch immediately. It is passed to each executor; nothing in this service\nwakes up to launch it for you.",
|
||||
"campaignRecord.status": "Status is the lifecycle state, server-owned and never accepted from a caller.\nFour values actually occur: draft (inert and fully mutable — nothing is sent\nand no budget is committed), live, paused and failed. After a fan-out live\nmeans AT LEAST ONE channel launched — read the channel rows for the rest —\nand failed means none did.",
|
||||
"campaignRecord.updatedAt": "UpdatedAt is the last write in unix seconds — an edit, a launch or a pause.\nServer-set on every save.",
|
||||
"campaignWrite.audience": "Audience is the segment or audience selector this campaign targets.",
|
||||
"campaignWrite.budget": "Budget is the campaign's total budget in CENTS. Negative reads as 0.",
|
||||
"campaignWrite.channels": "Channels are the fan-out targets, at most one per kind (paid, organic,\nemail) and at most 12. A channel's status and provider id are server-owned:\nwhatever the caller sends for them is replaced with \"pending\".",
|
||||
"campaignWrite.content": "Content is the ordered creative set. Content[0] is the active creative and\nthe rest are A/B variants; at most 32, empty entries dropped.",
|
||||
"campaignWrite.name": "Name is the campaign's display name. Required; trimmed and capped at 2048\ncharacters.",
|
||||
"campaignWrite.scheduleAt": "ScheduleAt is when the campaign should run, in unix seconds. Negative reads\nas 0 (immediately).",
|
||||
},
|
||||
Example: json.RawMessage(`{"name":"Spring launch","budget":250000,"content":["Ship faster"],"channels":[{"kind":"paid","platform":"meta"}]}`),
|
||||
})
|
||||
zip.Describe("POST /v1/campaign/:id/channels", zip.Doc{
|
||||
Description: "Adds a channel to a campaign, or REPLACES the one it already\nhas of that kind, and returns the updated campaign.\n\nA campaign carries at most one channel per kind, because the kind IS the\nexecutor: adding a second \"paid\" channel would mean two ad accounts running one\ncampaign with no way to tell their results apart. The new channel starts\n\"pending\" — adding it does not launch it.",
|
||||
Fields: map[string]string{
|
||||
"ChannelSpec.account": "provider account ref (ad-account/page/list id)",
|
||||
"ChannelSpec.detail": "honest last-outcome detail (never a secret)",
|
||||
"ChannelSpec.kind": "paid | organic | email",
|
||||
"ChannelSpec.platform": "meta | google | x | instagram | (email provider)",
|
||||
"ChannelSpec.status": "pending | live | paused | failed | unavailable",
|
||||
"channelAdd.account": "Account is the provider account this channel runs under: an ad-account, a\npage, or a mailing-list id.",
|
||||
"channelAdd.id": "ID is the campaign to add the channel to, from the path.",
|
||||
"channelAdd.kind": "Kind is the channel kind and the identity a campaign holds at most one of:\npaid, organic or email.",
|
||||
"channelAdd.platform": "Platform is the provider within the kind — meta, google, x, instagram, or\nthe email provider.",
|
||||
"ChannelSpec.account": "Account is the provider account this channel runs under: an ad-account, a page\nor a mailing-list id. An executor may replace it at launch with the account it\nactually used.",
|
||||
"ChannelSpec.detail": "Detail is the last outcome in one secret-free line — the failure reason, or\nwhat the executor reported. Absent when there is nothing to explain.",
|
||||
"ChannelSpec.externalId": "ExternalID is the provider-side id of the running execution, recorded by the\norchestrator at launch and handed back verbatim to read spend or to pause.\nServer-owned and absent until this channel has launched; anything a caller\nsends for it is dropped.",
|
||||
"ChannelSpec.kind": "Kind is the channel and the identity a campaign holds at most one of: paid,\norganic or email. It picks the executor the launch fans out to.",
|
||||
"ChannelSpec.platform": "Platform is the provider within the kind — meta, google, x, instagram, or the\nemail provider.",
|
||||
"ChannelSpec.status": "Status is this channel's own launch outcome, not the campaign's: pending (added,\nnever launched), live, paused, failed (Detail says why) or unavailable (no\nexecutor wired on this deployment). Server-owned — a caller can never assert it.",
|
||||
"campaignRecord.audience": "Audience is an opaque reference to the segment this campaign targets. It is\nstored and echoed but not yet handed to the executors — a channel targets\nthrough the provider account it runs under — so it is documentation for now.\nAbsent when never set.",
|
||||
"campaignRecord.budget": "Budget is the campaign's total budget in CENTS, handed to each executor as the\nbudget for its channel. 0 means none was set.",
|
||||
"campaignRecord.channels": "Channels are the fan-out targets, at most one per kind and at most 12, each\ncarrying its own post-launch state. Empty means nothing to launch, which is\nwhat makes a launch of this campaign a 400.",
|
||||
"campaignRecord.content": "Content is the ordered creative set, at most 32, empty entries dropped.\nContent[0] is the creative that runs; the rest are A/B variants a wired\nexperiment can assign per launch.",
|
||||
"campaignRecord.createdAt": "CreatedAt is when the campaign was created, in unix seconds. Server-set.",
|
||||
"campaignRecord.id": "ID is the campaign's server-minted handle — \"cmp_\" and 128 random bits — and\nthe id every other campaign call is addressed by. Never read off the wire: a\ncreate that sends one has it ignored.",
|
||||
"campaignRecord.name": "Name is the campaign's display name. Required on write, trimmed, and capped at\n2048 characters.",
|
||||
"campaignRecord.scheduleAt": "ScheduleAt is when the campaign should run, in unix seconds. 0 (absent) means\nlaunch immediately. It is passed to each executor; nothing in this service\nwakes up to launch it for you.",
|
||||
"campaignRecord.status": "Status is the lifecycle state, server-owned and never accepted from a caller.\nFour values actually occur: draft (inert and fully mutable — nothing is sent\nand no budget is committed), live, paused and failed. After a fan-out live\nmeans AT LEAST ONE channel launched — read the channel rows for the rest —\nand failed means none did.",
|
||||
"campaignRecord.updatedAt": "UpdatedAt is the last write in unix seconds — an edit, a launch or a pause.\nServer-set on every save.",
|
||||
"channelAdd.account": "Account is the provider account this channel runs under: an ad-account, a\npage, or a mailing-list id.",
|
||||
"channelAdd.id": "ID is the campaign to add the channel to, from the path.",
|
||||
"channelAdd.kind": "Kind is the channel kind and the identity a campaign holds at most one of:\npaid, organic or email.",
|
||||
"channelAdd.platform": "Platform is the provider within the kind — meta, google, x, instagram, or\nthe email provider.",
|
||||
},
|
||||
Example: json.RawMessage(`{"id":"cmp_1f…","kind":"email","platform":"sendgrid","account":"list_42"}`),
|
||||
})
|
||||
@@ -113,18 +193,29 @@ func init() {
|
||||
zip.Describe("PUT /v1/campaign/:id", zip.Doc{
|
||||
Description: "Rewrites a campaign's core fields — name, audience, creatives,\nschedule and budget — and returns the updated campaign.\n\nChannels are replaced ONLY while the campaign is still a draft. Once it is\nlaunched its channels carry provider state (an external id, a live status), so\nthey are added and removed explicitly through the channels sub-resource\ninstead; a whole-object write would silently orphan a running execution.",
|
||||
Fields: map[string]string{
|
||||
"ChannelSpec.account": "provider account ref (ad-account/page/list id)",
|
||||
"ChannelSpec.detail": "honest last-outcome detail (never a secret)",
|
||||
"ChannelSpec.kind": "paid | organic | email",
|
||||
"ChannelSpec.platform": "meta | google | x | instagram | (email provider)",
|
||||
"ChannelSpec.status": "pending | live | paused | failed | unavailable",
|
||||
"campaignUpdate.id": "ID is the campaign to update, from the path.",
|
||||
"campaignWrite.audience": "Audience is the segment or audience selector this campaign targets.",
|
||||
"campaignWrite.budget": "Budget is the campaign's total budget in CENTS. Negative reads as 0.",
|
||||
"campaignWrite.channels": "Channels are the fan-out targets, at most one per kind (paid, organic,\nemail) and at most 12. A channel's status and provider id are server-owned:\nwhatever the caller sends for them is replaced with \"pending\".",
|
||||
"campaignWrite.content": "Content is the ordered creative set. Content[0] is the active creative and\nthe rest are A/B variants; at most 32, empty entries dropped.",
|
||||
"campaignWrite.name": "Name is the campaign's display name. Required; trimmed and capped at 2048\ncharacters.",
|
||||
"campaignWrite.scheduleAt": "ScheduleAt is when the campaign should run, in unix seconds. Negative reads\nas 0 (immediately).",
|
||||
"ChannelSpec.account": "Account is the provider account this channel runs under: an ad-account, a page\nor a mailing-list id. An executor may replace it at launch with the account it\nactually used.",
|
||||
"ChannelSpec.detail": "Detail is the last outcome in one secret-free line — the failure reason, or\nwhat the executor reported. Absent when there is nothing to explain.",
|
||||
"ChannelSpec.externalId": "ExternalID is the provider-side id of the running execution, recorded by the\norchestrator at launch and handed back verbatim to read spend or to pause.\nServer-owned and absent until this channel has launched; anything a caller\nsends for it is dropped.",
|
||||
"ChannelSpec.kind": "Kind is the channel and the identity a campaign holds at most one of: paid,\norganic or email. It picks the executor the launch fans out to.",
|
||||
"ChannelSpec.platform": "Platform is the provider within the kind — meta, google, x, instagram, or the\nemail provider.",
|
||||
"ChannelSpec.status": "Status is this channel's own launch outcome, not the campaign's: pending (added,\nnever launched), live, paused, failed (Detail says why) or unavailable (no\nexecutor wired on this deployment). Server-owned — a caller can never assert it.",
|
||||
"campaignRecord.audience": "Audience is an opaque reference to the segment this campaign targets. It is\nstored and echoed but not yet handed to the executors — a channel targets\nthrough the provider account it runs under — so it is documentation for now.\nAbsent when never set.",
|
||||
"campaignRecord.budget": "Budget is the campaign's total budget in CENTS, handed to each executor as the\nbudget for its channel. 0 means none was set.",
|
||||
"campaignRecord.channels": "Channels are the fan-out targets, at most one per kind and at most 12, each\ncarrying its own post-launch state. Empty means nothing to launch, which is\nwhat makes a launch of this campaign a 400.",
|
||||
"campaignRecord.content": "Content is the ordered creative set, at most 32, empty entries dropped.\nContent[0] is the creative that runs; the rest are A/B variants a wired\nexperiment can assign per launch.",
|
||||
"campaignRecord.createdAt": "CreatedAt is when the campaign was created, in unix seconds. Server-set.",
|
||||
"campaignRecord.id": "ID is the campaign's server-minted handle — \"cmp_\" and 128 random bits — and\nthe id every other campaign call is addressed by. Never read off the wire: a\ncreate that sends one has it ignored.",
|
||||
"campaignRecord.name": "Name is the campaign's display name. Required on write, trimmed, and capped at\n2048 characters.",
|
||||
"campaignRecord.scheduleAt": "ScheduleAt is when the campaign should run, in unix seconds. 0 (absent) means\nlaunch immediately. It is passed to each executor; nothing in this service\nwakes up to launch it for you.",
|
||||
"campaignRecord.status": "Status is the lifecycle state, server-owned and never accepted from a caller.\nFour values actually occur: draft (inert and fully mutable — nothing is sent\nand no budget is committed), live, paused and failed. After a fan-out live\nmeans AT LEAST ONE channel launched — read the channel rows for the rest —\nand failed means none did.",
|
||||
"campaignRecord.updatedAt": "UpdatedAt is the last write in unix seconds — an edit, a launch or a pause.\nServer-set on every save.",
|
||||
"campaignUpdate.id": "ID is the campaign to update, from the path.",
|
||||
"campaignWrite.audience": "Audience is the segment or audience selector this campaign targets.",
|
||||
"campaignWrite.budget": "Budget is the campaign's total budget in CENTS. Negative reads as 0.",
|
||||
"campaignWrite.channels": "Channels are the fan-out targets, at most one per kind (paid, organic,\nemail) and at most 12. A channel's status and provider id are server-owned:\nwhatever the caller sends for them is replaced with \"pending\".",
|
||||
"campaignWrite.content": "Content is the ordered creative set. Content[0] is the active creative and\nthe rest are A/B variants; at most 32, empty entries dropped.",
|
||||
"campaignWrite.name": "Name is the campaign's display name. Required; trimmed and capped at 2048\ncharacters.",
|
||||
"campaignWrite.scheduleAt": "ScheduleAt is when the campaign should run, in unix seconds. Negative reads\nas 0 (immediately).",
|
||||
},
|
||||
Example: json.RawMessage(`{"name":"Spring launch","budget":500000}`),
|
||||
})
|
||||
|
||||
@@ -15,10 +15,47 @@ import (
|
||||
// media:false / actions:false this pass — renderText (envelope.go) is the ONE
|
||||
// downgrade path; native rendering is a named follow-up.
|
||||
type capabilities struct {
|
||||
DM bool `json:"dm"`
|
||||
Group bool `json:"group"`
|
||||
Thread bool `json:"thread"`
|
||||
Media bool `json:"media"`
|
||||
// DM is whether the transport carries a DIRECT message at all. True for slack,
|
||||
// teams and telegram. False for discord, honestly: that ingress is guild-scoped
|
||||
// slash commands — an interaction without a guild id is refused at the door —
|
||||
// so nothing ever arrives classified as a DM, no reply route is ever learned
|
||||
// for one, and a send addressed at a Discord DM is refused 409.
|
||||
DM bool `json:"dm"`
|
||||
// Group is whether the transport carries multi-person rooms — a Discord guild
|
||||
// channel, a Slack channel, a Teams channel or group chat, a Telegram group or
|
||||
// supergroup. True on all four.
|
||||
Group bool `json:"group"`
|
||||
// Thread is whether a reply can be threaded UNDER a specific message. True for
|
||||
// slack alone: it is the only transport whose ingress reports a thread
|
||||
// (thread_ts, published as the envelope's replyTo) and whose door posts back
|
||||
// into it. Discord's replyTo makes an inline reply rather than a thread,
|
||||
// Telegram's answers one message id, and Teams carries no reply target at all —
|
||||
// a replyTo sent to it is ignored.
|
||||
Thread bool `json:"thread"`
|
||||
// Media is whether the transport renders an ATTACHMENT natively. False on all
|
||||
// four this pass, and a send is not refused for it: renderText flattens each
|
||||
// attachment to one `kind: url (mime)` line after the text rather than dropping
|
||||
// it.
|
||||
Media bool `json:"media"`
|
||||
// Actions is whether the transport renders an INTERACTIVE control natively, and
|
||||
// it is the flag to read before composing one. The vocabulary is a closed
|
||||
// kind-tagged union (envelope.go), exactly four kinds, each carrying only its
|
||||
// own field plus an optional label:
|
||||
//
|
||||
// command — a bot command to run (`command`), rendered as a button that
|
||||
// invokes it.
|
||||
// url — an external link (`url`), rendered as a link button.
|
||||
// select — a menu (`options`, each a label and the value choosing it
|
||||
// returns), rendered as a picker.
|
||||
// approval — a reference to an approval request (`approval.id`), rendered as
|
||||
// approve/deny controls bound to that id.
|
||||
//
|
||||
// False on all four transports this pass, and nothing refuses a send for it:
|
||||
// actions are accepted, validated per kind, and flattened by renderText to one
|
||||
// line each after the text — `[label] command`, `[label] url`,
|
||||
// `[label] opt | opt`, `[label] approval requested: <id>`. So a caller that
|
||||
// needs a real control must read this flag and degrade itself; a caller that
|
||||
// only needs the choice communicated can send actions and take the text form.
|
||||
Actions bool `json:"actions"`
|
||||
}
|
||||
|
||||
|
||||
+162
-27
@@ -138,44 +138,179 @@ func requireOrgAdmin(ctx context.Context) error {
|
||||
|
||||
// ── JSON projections (camelCase, closed shapes) ──────────────────────────────
|
||||
|
||||
// channelView is one chat transport as this org sees it: the fixed transport
|
||||
// facts, the org's connection to it, and the org's access policy for it.
|
||||
type channelView struct {
|
||||
ID string `json:"id"`
|
||||
Connected bool `json:"connected"`
|
||||
Account string `json:"account"`
|
||||
AccountLabel string `json:"accountLabel"`
|
||||
Capabilities capabilities `json:"capabilities"`
|
||||
DMPolicy DMPolicy `json:"dmPolicy"`
|
||||
GroupPolicy GroupPolicy `json:"groupPolicy"`
|
||||
PendingPairing int `json:"pendingPairing"`
|
||||
// ID is the fixed transport identifier — discord, slack, teams or telegram —
|
||||
// and the value every route on this surface names a channel by, including the
|
||||
// `:channel` segment of the send path. The listing is always in that order.
|
||||
ID string `json:"id"`
|
||||
// Connected is whether integrations holds a connection for (this org, this
|
||||
// transport) — whether someone finished its connect flow. False leaves Account
|
||||
// and AccountLabel empty, and a send is then refused downstream rather than
|
||||
// here: by the transport's own binding check (403 for a Telegram chat this org
|
||||
// has not bound, 409 for a Discord or Teams room with no inbound-learned
|
||||
// route), or on Slack by the absent per-org bot token, which surfaces as 502.
|
||||
Connected bool `json:"connected"`
|
||||
// Account is the id-shaped fact about that connection: the lowercased external
|
||||
// id integrations custodies for it — a Discord guild id, a Slack team
|
||||
// (workspace) id, a Teams AAD tenant id, or the Telegram chat the org bound.
|
||||
// Empty when not connected. Informational: the access policy keys on
|
||||
// (org, channel), so exactly one account is representable per pair.
|
||||
Account string `json:"account"`
|
||||
// AccountLabel is the human label of that same account — the Discord guild
|
||||
// name, the Slack team name, the Teams tenant name (falling back to the tenant
|
||||
// id), the Telegram chat title. DISPLAY ONLY: never a key, and never swapped
|
||||
// with Account, on any surface.
|
||||
AccountLabel string `json:"accountLabel"`
|
||||
// Capabilities is what this transport renders natively — read it before
|
||||
// composing a message that needs threading, media or interactive actions.
|
||||
Capabilities capabilities `json:"capabilities"`
|
||||
// DMPolicy is how this org admits direct messages here: "pairing", "allowlist"
|
||||
// or "open", defaulting to "pairing" when the org has never set one.
|
||||
DMPolicy DMPolicy `json:"dmPolicy"`
|
||||
// GroupPolicy is how this org admits group and thread rooms here: "open",
|
||||
// "allowlist" or "disabled", defaulting to "open". Both policy fields come
|
||||
// back EMPTY — rather than the listing failing — when the policy cannot be
|
||||
// read; GET /v1/channels/allowlist carries the same two with the entries they
|
||||
// consult.
|
||||
GroupPolicy GroupPolicy `json:"groupPolicy"`
|
||||
// PendingPairing counts the org's UNEXPIRED pairing requests on this channel:
|
||||
// exactly the rows GET /v1/channels/pairing returns for it, one per person
|
||||
// waiting on an admin. It never exceeds three — the pending cap per
|
||||
// (org, channel) — and expired requests are not counted.
|
||||
PendingPairing int `json:"pendingPairing"`
|
||||
}
|
||||
|
||||
// inboxView is one stored inbound message: the portable envelope (envelope.go)
|
||||
// as this API publishes it, identical in shape whichever transport it came from.
|
||||
type inboxView struct {
|
||||
ID int64 `json:"id"`
|
||||
Channel string `json:"channel"`
|
||||
Account string `json:"account"`
|
||||
RoomID string `json:"roomId"`
|
||||
RoomKind string `json:"roomKind"`
|
||||
Sender string `json:"sender"`
|
||||
// ID is the store's row id, assigned on insert — SERVER-SET, and the cursor:
|
||||
// pass a page's last id back as `since`. It rises with arrival order but is
|
||||
// not contiguous, because one sequence is shared by every org in the store and
|
||||
// a caller reads only its own rows.
|
||||
ID int64 `json:"id"`
|
||||
// Channel is the transport this message arrived on — discord, slack, teams or
|
||||
// telegram — and the `:channel` segment to reply through.
|
||||
Channel string `json:"channel"`
|
||||
// Account is the lowercased external id of the org's connected account on that
|
||||
// transport: the Discord guild id, the Slack team id, the Teams AAD tenant id,
|
||||
// or the bound Telegram chat id. Informational only — the gate keys on
|
||||
// (org, channel), never on the account.
|
||||
Account string `json:"account"`
|
||||
// RoomID is the conversation on the ORIGINATING transport, and the value to
|
||||
// send back as `room.id`: a Discord channel snowflake, a Slack conversation id
|
||||
// (D… IM, C… public channel, G… private or mpim), a Teams conversation id
|
||||
// (19:…@thread.… for a channel or group chat, a:… for a personal chat), or a
|
||||
// Telegram chat id in decimal (negative for a group, positive for a DM). It is
|
||||
// stable for the life of the room, so every message from one conversation
|
||||
// carries the same value.
|
||||
RoomID string `json:"roomId"`
|
||||
// RoomKind is how ingest classified the room: "dm", "group" or "thread". It
|
||||
// decides which policy gated the message — dmPolicy for "dm", groupPolicy for
|
||||
// BOTH "group" and "thread". Only Slack ever reports "thread"; Telegram's
|
||||
// reply-to id becomes ReplyTo instead, and Discord's ingress is guild-scoped
|
||||
// so its rooms are always "group".
|
||||
RoomKind string `json:"roomKind"`
|
||||
// Sender is the TRANSPORT-NATIVE user id of whoever wrote the message — a
|
||||
// Discord member.user.id, a Slack U… user id, a Teams aadObjectId (falling
|
||||
// back to from.id), a Telegram from.id in decimal. Stable per person per
|
||||
// transport, and the identity the gate keys on: an allow entry, an access-group
|
||||
// member and a pairing approval all name exactly this value.
|
||||
Sender string `json:"sender"`
|
||||
// SenderUser is the HANZO account subject that chat identity is linked to,
|
||||
// resolved at ingest through the org's user link. Best-effort and omitted when
|
||||
// absent: a person who never linked their chat account — or a link store that
|
||||
// could not be read — leaves it empty and is never blocked for it.
|
||||
SenderUser string `json:"senderUser,omitempty"`
|
||||
Text string `json:"text"`
|
||||
ReplyTo string `json:"replyTo,omitempty"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
// Text is the body as the transport delivered it, with the bot mention already
|
||||
// stripped by the ingress adapter (on Discord it is the /hanzo prompt argument,
|
||||
// since that ingress is slash commands only), truncated to 8 KiB on store.
|
||||
// Inbound attachments are not stored — this is the whole of what was said.
|
||||
Text string `json:"text"`
|
||||
// ReplyTo is the transport's reply target for this message: Slack's thread_ts,
|
||||
// or the Telegram message id it arrived as. Send it back as the body's
|
||||
// `replyTo` to answer in the SAME thread. Empty means the transport reported
|
||||
// none — a top-level Slack message, and every Discord and Teams message, since
|
||||
// neither carries one — and a reply then lands at the top level of the room.
|
||||
ReplyTo string `json:"replyTo,omitempty"`
|
||||
// CreatedAt is Unix SECONDS, stamped by the ingest goroutine when the message
|
||||
// was accepted — not the transport's own send time. Rows are dropped 30 days
|
||||
// after it.
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
// pairingView is one pending pairing request: someone who messaged a
|
||||
// pairing-gated channel and is waiting on an org admin.
|
||||
type pairingView struct {
|
||||
Channel string `json:"channel"`
|
||||
Sender string `json:"sender"`
|
||||
Code string `json:"code"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
LastSeen int64 `json:"lastSeen"`
|
||||
// Channel is the transport the request arrived on — discord, slack, teams or
|
||||
// telegram — and half of what approval names. The cap of three unapproved
|
||||
// requests applies per (org, channel); while it is full no further code is
|
||||
// minted until one is approved or expires.
|
||||
Channel string `json:"channel"`
|
||||
// Sender is the transport-native user id waiting for access — the same
|
||||
// identity inbox messages carry. Approving mints a DM allow entry for exactly
|
||||
// this value and nothing wider: pairing never grants group access.
|
||||
Sender string `json:"sender"`
|
||||
// Code is the CAPABILITY that authorises the approval: eight characters from a
|
||||
// 32-symbol uppercase alphabet (A-Z0-9 minus the confusables 0, O, 1 and I),
|
||||
// minted with crypto/rand and also sent to the requester in chat. An org admin
|
||||
// passes it with the channel to POST /v1/channels/pairing/approve, which
|
||||
// CONSUMES it — the request row is deleted, so a code approves once — and which
|
||||
// takes org admin as well as the code. It lives ONE HOUR from CreatedAt;
|
||||
// expired requests are not listed here, and approving one is a 404. It is shown
|
||||
// on this admin surface and NEVER logged.
|
||||
Code string `json:"code"`
|
||||
// CreatedAt is Unix SECONDS of FIRST contact: when the request was minted and
|
||||
// the code sent. Expiry is measured from here and from nowhere else.
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
// LastSeen is Unix SECONDS of the MOST RECENT message from this sender while
|
||||
// the request has been pending. It moves as they keep writing, which is how an
|
||||
// admin tells a live request from an abandoned one — but it does not extend the
|
||||
// hour and does not re-send the code, since one request sends exactly one chat
|
||||
// reply.
|
||||
LastSeen int64 `json:"lastSeen"`
|
||||
}
|
||||
|
||||
// allowlistView is one channel's access policy for this org: what the gate does
|
||||
// to an inbound message, and every entry it consults doing it. Both the GET and
|
||||
// the PUT answer this shape.
|
||||
type allowlistView struct {
|
||||
DMPolicy DMPolicy `json:"dmPolicy"`
|
||||
GroupPolicy GroupPolicy `json:"groupPolicy"`
|
||||
DM []string `json:"dm"`
|
||||
Group []string `json:"group"`
|
||||
Paired []string `json:"paired"`
|
||||
// DMPolicy decides every inbound DIRECT message, defaulting to "pairing" when
|
||||
// the org has never set one. "pairing": a sender with no entry is sent a
|
||||
// pairing code and the message is DROPPED — it never reaches the inbox — and
|
||||
// they are admitted only once an admin approves. "allowlist": only DM admits,
|
||||
// and Paired senders are suspended, since a pairing grant counts under
|
||||
// "pairing" alone. "open" is not unconditional either — it still requires `*`
|
||||
// or a matching entry in DM.
|
||||
DMPolicy DMPolicy `json:"dmPolicy"`
|
||||
// GroupPolicy decides every inbound GROUP or THREAD message — a thread is a
|
||||
// group surface — defaulting to "open". "open" admits every sender in the room.
|
||||
// "allowlist" admits only what Group lists, so an EMPTY Group blocks the
|
||||
// channel's group rooms outright. "disabled" drops all of them.
|
||||
GroupPolicy GroupPolicy `json:"groupPolicy"`
|
||||
// DM is the CONFIG-managed DM allow entries — the list PUT
|
||||
// /v1/channels/allowlist owns and replaces wholesale. An entry matches a sender
|
||||
// either EXACTLY, as the transport-native id inbox messages carry, or as
|
||||
// `accessGroup:<name>` resolved through AccessGroups. A bare `*` admits
|
||||
// everyone, but only while DMPolicy is "open": it is gate syntax, not an
|
||||
// identity, so under "allowlist" it matches nobody.
|
||||
DM []string `json:"dm"`
|
||||
// Group is the CONFIG-managed group allow entries, consulted only while
|
||||
// GroupPolicy is "allowlist". Entries match the same two ways as DM, and here a
|
||||
// bare `*` admits every sender in the room.
|
||||
Group []string `json:"group"`
|
||||
// Paired is the senders admitted by PAIRING — the entries POST
|
||||
// /v1/channels/pairing/approve minted, DM scope only. READ-ONLY on this
|
||||
// endpoint: the PUT writes config entries and can never revoke one of these
|
||||
// (listing a paired sender under DM instead promotes that entry to config,
|
||||
// which the admin then owns). They admit only while DMPolicy is "pairing".
|
||||
Paired []string `json:"paired"`
|
||||
// AccessGroups is the org's named sender sets, as group name -> channel ->
|
||||
// member entries, held once for the whole org. A DM or Group entry written
|
||||
// `accessGroup:<name>` admits any sender listed under that name for THIS
|
||||
// channel, or under the channel `*`, which is how one set covers all four
|
||||
// transports. Replaced wholesale by the PUT.
|
||||
AccessGroups map[string]map[string][]string `json:"accessGroups"`
|
||||
}
|
||||
|
||||
|
||||
@@ -12,30 +12,64 @@ func init() {
|
||||
zip.Describe("GET /v1/channels", zip.Doc{
|
||||
Description: "Returns every chat transport channels can talk to — Discord, Slack, Teams\nand Telegram — with the caller org's own facts on each: whether it is\nconnected and to which account, what the transport supports, the org's DM and\ngroup access policies, and how many pairing requests are pending approval. The\norder is fixed, so a console can render the same rows every time. A policy that\ncannot be read leaves that channel's policy fields empty rather than failing\nthe whole listing.",
|
||||
Fields: map[string]string{
|
||||
"chatChannels.channels": "Channels is every chat transport this deployment supports, in a fixed\norder, each carrying whether the org has connected it, the account behind\nthe connection, what the transport can do, the org's DM/group access\npolicies for it, and how many pairing requests are waiting.",
|
||||
"capabilities.actions": "Actions is whether the transport renders an INTERACTIVE control natively, and\nit is the flag to read before composing one. The vocabulary is a closed\nkind-tagged union (envelope.go), exactly four kinds, each carrying only its\nown field plus an optional label:\n\n\tcommand — a bot command to run (`command`), rendered as a button that\n\t invokes it.\n\turl — an external link (`url`), rendered as a link button.\n\tselect — a menu (`options`, each a label and the value choosing it\n\t returns), rendered as a picker.\n\tapproval — a reference to an approval request (`approval.id`), rendered as\n\t approve/deny controls bound to that id.\n\nFalse on all four transports this pass, and nothing refuses a send for it:\nactions are accepted, validated per kind, and flattened by renderText to one\nline each after the text — `[label] command`, `[label] url`,\n`[label] opt | opt`, `[label] approval requested: <id>`. So a caller that\nneeds a real control must read this flag and degrade itself; a caller that\nonly needs the choice communicated can send actions and take the text form.",
|
||||
"capabilities.dm": "DM is whether the transport carries a DIRECT message at all. True for slack,\nteams and telegram. False for discord, honestly: that ingress is guild-scoped\nslash commands — an interaction without a guild id is refused at the door —\nso nothing ever arrives classified as a DM, no reply route is ever learned\nfor one, and a send addressed at a Discord DM is refused 409.",
|
||||
"capabilities.group": "Group is whether the transport carries multi-person rooms — a Discord guild\nchannel, a Slack channel, a Teams channel or group chat, a Telegram group or\nsupergroup. True on all four.",
|
||||
"capabilities.media": "Media is whether the transport renders an ATTACHMENT natively. False on all\nfour this pass, and a send is not refused for it: renderText flattens each\nattachment to one `kind: url (mime)` line after the text rather than dropping\nit.",
|
||||
"capabilities.thread": "Thread is whether a reply can be threaded UNDER a specific message. True for\nslack alone: it is the only transport whose ingress reports a thread\n(thread_ts, published as the envelope's replyTo) and whose door posts back\ninto it. Discord's replyTo makes an inline reply rather than a thread,\nTelegram's answers one message id, and Teams carries no reply target at all —\na replyTo sent to it is ignored.",
|
||||
"channelView.account": "Account is the id-shaped fact about that connection: the lowercased external\nid integrations custodies for it — a Discord guild id, a Slack team\n(workspace) id, a Teams AAD tenant id, or the Telegram chat the org bound.\nEmpty when not connected. Informational: the access policy keys on\n(org, channel), so exactly one account is representable per pair.",
|
||||
"channelView.accountLabel": "AccountLabel is the human label of that same account — the Discord guild\nname, the Slack team name, the Teams tenant name (falling back to the tenant\nid), the Telegram chat title. DISPLAY ONLY: never a key, and never swapped\nwith Account, on any surface.",
|
||||
"channelView.capabilities": "Capabilities is what this transport renders natively — read it before\ncomposing a message that needs threading, media or interactive actions.",
|
||||
"channelView.connected": "Connected is whether integrations holds a connection for (this org, this\ntransport) — whether someone finished its connect flow. False leaves Account\nand AccountLabel empty, and a send is then refused downstream rather than\nhere: by the transport's own binding check (403 for a Telegram chat this org\nhas not bound, 409 for a Discord or Teams room with no inbound-learned\nroute), or on Slack by the absent per-org bot token, which surfaces as 502.",
|
||||
"channelView.dmPolicy": "DMPolicy is how this org admits direct messages here: \"pairing\", \"allowlist\"\nor \"open\", defaulting to \"pairing\" when the org has never set one.",
|
||||
"channelView.groupPolicy": "GroupPolicy is how this org admits group and thread rooms here: \"open\",\n\"allowlist\" or \"disabled\", defaulting to \"open\". Both policy fields come\nback EMPTY — rather than the listing failing — when the policy cannot be\nread; GET /v1/channels/allowlist carries the same two with the entries they\nconsult.",
|
||||
"channelView.id": "ID is the fixed transport identifier — discord, slack, teams or telegram —\nand the value every route on this surface names a channel by, including the\n`:channel` segment of the send path. The listing is always in that order.",
|
||||
"channelView.pendingPairing": "PendingPairing counts the org's UNEXPIRED pairing requests on this channel:\nexactly the rows GET /v1/channels/pairing returns for it, one per person\nwaiting on an admin. It never exceeds three — the pending cap per\n(org, channel) — and expired requests are not counted.",
|
||||
"chatChannels.channels": "Channels is every chat transport this deployment supports, in a fixed\norder, each carrying whether the org has connected it, the account behind\nthe connection, what the transport can do, the org's DM/group access\npolicies for it, and how many pairing requests are waiting.",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/channels/allowlist", zip.Doc{
|
||||
Description: "Returns the caller org's access policy for one channel: whether\nDMs are pairing-gated, allowlisted or open, whether group rooms are open,\nallowlisted or disabled, the config-managed DM and group allow entries, the\nsenders approved through PAIRING (read-only here), and the org's named access\ngroups. An unknown channel is a 404.",
|
||||
Fields: map[string]string{
|
||||
"allowlistRef.channel": "Channel is the transport to read: discord, slack, teams or telegram.\nRequired; an unknown value is a 404.",
|
||||
"allowlistRef.channel": "Channel is the transport to read: discord, slack, teams or telegram.\nRequired; an unknown value is a 404.",
|
||||
"allowlistView.accessGroups": "AccessGroups is the org's named sender sets, as group name -> channel ->\nmember entries, held once for the whole org. A DM or Group entry written\n`accessGroup:<name>` admits any sender listed under that name for THIS\nchannel, or under the channel `*`, which is how one set covers all four\ntransports. Replaced wholesale by the PUT.",
|
||||
"allowlistView.dm": "DM is the CONFIG-managed DM allow entries — the list PUT\n/v1/channels/allowlist owns and replaces wholesale. An entry matches a sender\neither EXACTLY, as the transport-native id inbox messages carry, or as\n`accessGroup:<name>` resolved through AccessGroups. A bare `*` admits\neveryone, but only while DMPolicy is \"open\": it is gate syntax, not an\nidentity, so under \"allowlist\" it matches nobody.",
|
||||
"allowlistView.dmPolicy": "DMPolicy decides every inbound DIRECT message, defaulting to \"pairing\" when\nthe org has never set one. \"pairing\": a sender with no entry is sent a\npairing code and the message is DROPPED — it never reaches the inbox — and\nthey are admitted only once an admin approves. \"allowlist\": only DM admits,\nand Paired senders are suspended, since a pairing grant counts under\n\"pairing\" alone. \"open\" is not unconditional either — it still requires `*`\nor a matching entry in DM.",
|
||||
"allowlistView.group": "Group is the CONFIG-managed group allow entries, consulted only while\nGroupPolicy is \"allowlist\". Entries match the same two ways as DM, and here a\nbare `*` admits every sender in the room.",
|
||||
"allowlistView.groupPolicy": "GroupPolicy decides every inbound GROUP or THREAD message — a thread is a\ngroup surface — defaulting to \"open\". \"open\" admits every sender in the room.\n\"allowlist\" admits only what Group lists, so an EMPTY Group blocks the\nchannel's group rooms outright. \"disabled\" drops all of them.",
|
||||
"allowlistView.paired": "Paired is the senders admitted by PAIRING — the entries POST\n/v1/channels/pairing/approve minted, DM scope only. READ-ONLY on this\nendpoint: the PUT writes config entries and can never revoke one of these\n(listing a paired sender under DM instead promotes that entry to config,\nwhich the admin then owns). They admit only while DMPolicy is \"pairing\".",
|
||||
},
|
||||
Example: json.RawMessage(`{"channel":"slack"}`),
|
||||
})
|
||||
zip.Describe("GET /v1/channels/inbox", zip.Doc{
|
||||
Description: "Returns the messages people have sent to the caller org's connected chat\nbots, oldest first, in the portable envelope shape every transport normalises\ninto. It is a CURSOR feed, not a search: pass the returned cursor back as\n`since` to get only what has arrived since. Only this org's messages are\nstored under this org, so the feed can never carry another tenant's chat.",
|
||||
Fields: map[string]string{
|
||||
"inboxIn.limit": "Limit caps how many messages come back. Empty or 0 uses the store's\ndefault page size. Must parse as an integer.",
|
||||
"inboxIn.since": "Since is the exclusive cursor: only messages with a higher row id come\nback. Empty starts at the beginning. Must parse as an integer.",
|
||||
"inboxPage.cursor": "Cursor is the row id to pass back as `since` for the next page. It is the\nlast message's id, or the requested cursor when the page is empty.",
|
||||
"inboxPage.messages": "Messages are the inbound messages, oldest first.",
|
||||
"inboxIn.limit": "Limit caps how many messages come back. Empty or 0 uses the store's\ndefault page size. Must parse as an integer.",
|
||||
"inboxIn.since": "Since is the exclusive cursor: only messages with a higher row id come\nback. Empty starts at the beginning. Must parse as an integer.",
|
||||
"inboxPage.cursor": "Cursor is the row id to pass back as `since` for the next page. It is the\nlast message's id, or the requested cursor when the page is empty.",
|
||||
"inboxPage.messages": "Messages are the inbound messages, oldest first.",
|
||||
"inboxView.account": "Account is the lowercased external id of the org's connected account on that\ntransport: the Discord guild id, the Slack team id, the Teams AAD tenant id,\nor the bound Telegram chat id. Informational only — the gate keys on\n(org, channel), never on the account.",
|
||||
"inboxView.channel": "Channel is the transport this message arrived on — discord, slack, teams or\ntelegram — and the `:channel` segment to reply through.",
|
||||
"inboxView.createdAt": "CreatedAt is Unix SECONDS, stamped by the ingest goroutine when the message\nwas accepted — not the transport's own send time. Rows are dropped 30 days\nafter it.",
|
||||
"inboxView.id": "ID is the store's row id, assigned on insert — SERVER-SET, and the cursor:\npass a page's last id back as `since`. It rises with arrival order but is\nnot contiguous, because one sequence is shared by every org in the store and\na caller reads only its own rows.",
|
||||
"inboxView.replyTo": "ReplyTo is the transport's reply target for this message: Slack's thread_ts,\nor the Telegram message id it arrived as. Send it back as the body's\n`replyTo` to answer in the SAME thread. Empty means the transport reported\nnone — a top-level Slack message, and every Discord and Teams message, since\nneither carries one — and a reply then lands at the top level of the room.",
|
||||
"inboxView.roomId": "RoomID is the conversation on the ORIGINATING transport, and the value to\nsend back as `room.id`: a Discord channel snowflake, a Slack conversation id\n(D… IM, C… public channel, G… private or mpim), a Teams conversation id\n(19:…@thread.… for a channel or group chat, a:… for a personal chat), or a\nTelegram chat id in decimal (negative for a group, positive for a DM). It is\nstable for the life of the room, so every message from one conversation\ncarries the same value.",
|
||||
"inboxView.roomKind": "RoomKind is how ingest classified the room: \"dm\", \"group\" or \"thread\". It\ndecides which policy gated the message — dmPolicy for \"dm\", groupPolicy for\nBOTH \"group\" and \"thread\". Only Slack ever reports \"thread\"; Telegram's\nreply-to id becomes ReplyTo instead, and Discord's ingress is guild-scoped\nso its rooms are always \"group\".",
|
||||
"inboxView.sender": "Sender is the TRANSPORT-NATIVE user id of whoever wrote the message — a\nDiscord member.user.id, a Slack U… user id, a Teams aadObjectId (falling\nback to from.id), a Telegram from.id in decimal. Stable per person per\ntransport, and the identity the gate keys on: an allow entry, an access-group\nmember and a pairing approval all name exactly this value.",
|
||||
"inboxView.senderUser": "SenderUser is the HANZO account subject that chat identity is linked to,\nresolved at ingest through the org's user link. Best-effort and omitted when\nabsent: a person who never linked their chat account — or a link store that\ncould not be read — leaves it empty and is never blocked for it.",
|
||||
"inboxView.text": "Text is the body as the transport delivered it, with the bot mention already\nstripped by the ingress adapter (on Discord it is the /hanzo prompt argument,\nsince that ingress is slash commands only), truncated to 8 KiB on store.\nInbound attachments are not stored — this is the whole of what was said.",
|
||||
},
|
||||
Example: json.RawMessage(`{"since":"1042","limit":"100"}`),
|
||||
})
|
||||
zip.Describe("GET /v1/channels/pairing", zip.Doc{
|
||||
Description: "Returns the pairing requests waiting for the caller org to approve\n— one per person who messaged a connected bot on a channel whose DM policy is\n\"pairing\" and who is not allowed yet. Each row carries the CODE an org admin\npasses to POST /v1/channels/pairing/approve. Expired requests are not\nreturned. Codes are capability strings: they are shown here, and never logged.",
|
||||
Fields: map[string]string{
|
||||
"pairingQueue.pending": "Pending is every unexpired pairing request waiting on an org admin, each\ncarrying the channel, the requesting sender and the code to approve it with.",
|
||||
"pairingQueue.pending": "Pending is every unexpired pairing request waiting on an org admin, each\ncarrying the channel, the requesting sender and the code to approve it with.",
|
||||
"pairingView.channel": "Channel is the transport the request arrived on — discord, slack, teams or\ntelegram — and half of what approval names. The cap of three unapproved\nrequests applies per (org, channel); while it is full no further code is\nminted until one is approved or expires.",
|
||||
"pairingView.code": "Code is the CAPABILITY that authorises the approval: eight characters from a\n32-symbol uppercase alphabet (A-Z0-9 minus the confusables 0, O, 1 and I),\nminted with crypto/rand and also sent to the requester in chat. An org admin\npasses it with the channel to POST /v1/channels/pairing/approve, which\nCONSUMES it — the request row is deleted, so a code approves once — and which\ntakes org admin as well as the code. It lives ONE HOUR from CreatedAt;\nexpired requests are not listed here, and approving one is a 404. It is shown\non this admin surface and NEVER logged.",
|
||||
"pairingView.createdAt": "CreatedAt is Unix SECONDS of FIRST contact: when the request was minted and\nthe code sent. Expiry is measured from here and from nowhere else.",
|
||||
"pairingView.lastSeen": "LastSeen is Unix SECONDS of the MOST RECENT message from this sender while\nthe request has been pending. It moves as they keep writing, which is how an\nadmin tells a live request from an abandoned one — but it does not extend the\nhour and does not re-send the code, since one request sends exactly one chat\nreply.",
|
||||
"pairingView.sender": "Sender is the transport-native user id waiting for access — the same\nidentity inbox messages carry. Approving mints a DM allow entry for exactly\nthis value and nothing wider: pairing never grants group access.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/channels/:channel/send", zip.Doc{
|
||||
@@ -60,6 +94,12 @@ func init() {
|
||||
"allowlistPutIn.dmPolicy": "DMPolicy sets how direct messages are admitted: \"pairing\" (a person must be\napproved first), \"allowlist\" (only listed senders) or \"open\". Empty leaves\nit unchanged.",
|
||||
"allowlistPutIn.group": "Group REPLACES the config-managed group allow entries. Absent or null\nleaves them alone; an empty list clears them.",
|
||||
"allowlistPutIn.groupPolicy": "GroupPolicy sets how group and thread rooms are admitted: \"open\",\n\"allowlist\" or \"disabled\". Empty leaves it unchanged.",
|
||||
"allowlistView.accessGroups": "AccessGroups is the org's named sender sets, as group name -> channel ->\nmember entries, held once for the whole org. A DM or Group entry written\n`accessGroup:<name>` admits any sender listed under that name for THIS\nchannel, or under the channel `*`, which is how one set covers all four\ntransports. Replaced wholesale by the PUT.",
|
||||
"allowlistView.dm": "DM is the CONFIG-managed DM allow entries — the list PUT\n/v1/channels/allowlist owns and replaces wholesale. An entry matches a sender\neither EXACTLY, as the transport-native id inbox messages carry, or as\n`accessGroup:<name>` resolved through AccessGroups. A bare `*` admits\neveryone, but only while DMPolicy is \"open\": it is gate syntax, not an\nidentity, so under \"allowlist\" it matches nobody.",
|
||||
"allowlistView.dmPolicy": "DMPolicy decides every inbound DIRECT message, defaulting to \"pairing\" when\nthe org has never set one. \"pairing\": a sender with no entry is sent a\npairing code and the message is DROPPED — it never reaches the inbox — and\nthey are admitted only once an admin approves. \"allowlist\": only DM admits,\nand Paired senders are suspended, since a pairing grant counts under\n\"pairing\" alone. \"open\" is not unconditional either — it still requires `*`\nor a matching entry in DM.",
|
||||
"allowlistView.group": "Group is the CONFIG-managed group allow entries, consulted only while\nGroupPolicy is \"allowlist\". Entries match the same two ways as DM, and here a\nbare `*` admits every sender in the room.",
|
||||
"allowlistView.groupPolicy": "GroupPolicy decides every inbound GROUP or THREAD message — a thread is a\ngroup surface — defaulting to \"open\". \"open\" admits every sender in the room.\n\"allowlist\" admits only what Group lists, so an EMPTY Group blocks the\nchannel's group rooms outright. \"disabled\" drops all of them.",
|
||||
"allowlistView.paired": "Paired is the senders admitted by PAIRING — the entries POST\n/v1/channels/pairing/approve minted, DM scope only. READ-ONLY on this\nendpoint: the PUT writes config entries and can never revoke one of these\n(listing a paired sender under DM instead promotes that entry to config,\nwhich the admin then owns). They admit only while DMPolicy is \"pairing\".",
|
||||
},
|
||||
Example: json.RawMessage(`{"channel":"slack","dmPolicy":"allowlist","dm":["U024BE7LH"]}`),
|
||||
})
|
||||
|
||||
+236
-56
@@ -2,76 +2,256 @@ package coding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud/apps/agents"
|
||||
"github.com/hanzoai/cloud/apps/tracker"
|
||||
"github.com/hanzoai/cloud/plane"
|
||||
)
|
||||
|
||||
// adapters.go binds the session + tracker seams to the real in-process packages.
|
||||
// This is the ONLY file in clients/coding that imports agents/tracker; coding.go
|
||||
// stays pure so the orchestration is unit-tested against fakes. Neither agents nor
|
||||
// tracker imports clients/git or clients/integrations, so these imports are
|
||||
// cycle-free. The third seam, Runner, is bound in task.go — coding's own wire
|
||||
// contract with the bot runtime.
|
||||
// adapters.go binds coding's seams to the apps that OWN them — across the
|
||||
// process boundary, because that is where they are.
|
||||
//
|
||||
// It used to bind them to agents and tracker in-process, which was right while
|
||||
// one binary held every subsystem. It is not right now: each app is its own
|
||||
// process, and every one of those calls reads the callee's `mounted` package
|
||||
// global. A package global is per-PROCESS, so in the process that runs a coding
|
||||
// run they are all nil and each seam answered its zero value — "tracker: not
|
||||
// mounted", an empty clone URL the dispatcher reads as "git is not available",
|
||||
// and a VerifyRef that reports every pushed branch absent and fails the run
|
||||
// closed with no PR. The chat turn died of exactly this shape one file over.
|
||||
//
|
||||
// The orchestration in coding.go is untouched. It always reached its
|
||||
// collaborators through injected seams, which is what makes this a re-binding
|
||||
// and not a rewrite: same Dispatcher, same order, same fail-closed rules, the
|
||||
// calls simply land on a socket instead of a nil global.
|
||||
//
|
||||
// The Runner is the exception that proves it: the bot-gateway sandbox was
|
||||
// always an HTTP client (task.go), so it never had a boundary to cross.
|
||||
|
||||
// NewDispatcher assembles the production Dispatcher: sessions on the live agent
|
||||
// registry, PRs on the tracker, the runner on coding's own runtime stub, plus the
|
||||
// two git seams (cloneURL, verifyRef) the composition root passes from clients/git
|
||||
// (which coding cannot import directly). log is the structured logger for
|
||||
// best-effort mirror failures.
|
||||
func NewDispatcher(
|
||||
cloneURL func(org, repo string) string,
|
||||
verifyRef func(ctx context.Context, org, repo, branch string) (string, bool),
|
||||
log func(msg string, kv ...any),
|
||||
) Dispatcher {
|
||||
// seamTimeout bounds ONE seam call. Every seam here is a small read or write —
|
||||
// open a row, append an event, resolve a name — so a call that has not answered
|
||||
// in this long is a wedged peer, not a slow one, and the run gets an honest
|
||||
// error instead of hanging inside a step.
|
||||
//
|
||||
// It sits UNDER the plane transport's own response-read ceiling, which in a
|
||||
// plugin process is zaphttp's 30s default (zap-proto/http client.go: readTimeout
|
||||
// 30s; only the cmd/cloud host re-registers the zap scheme with a longer one,
|
||||
// and a plugin does not link the host). Under it, the deadline that fires is
|
||||
// always this one — the one whose error names the seam — instead of a bare 502
|
||||
// from the wire. It is also why the RUN itself is not a plane call: a 25-minute
|
||||
// coding run cannot be a request, so it stays a bounded goroutine on the trigger
|
||||
// side and only its seams cross.
|
||||
const seamTimeout = 20 * time.Second
|
||||
|
||||
// NewDispatcher assembles the production Dispatcher: every seam a peer call over
|
||||
// the internal plane, the runner on coding's own bot-gateway wire. log is the
|
||||
// structured logger for best-effort mirror failures (nil is fine).
|
||||
//
|
||||
// It also wires the routed completion seam to THIS dispatcher, so the durable
|
||||
// delivery activity verifies the pushed ref, files the PR and closes the session
|
||||
// exactly as the local path does.
|
||||
func NewDispatcher(log func(msg string, kv ...any)) Dispatcher {
|
||||
d := Dispatcher{
|
||||
Sessions: sessionAdapter{},
|
||||
Tracker: trackerAdapter{},
|
||||
Runner: runner{},
|
||||
CloneURL: cloneURL,
|
||||
VerifyRef: verifyRef,
|
||||
Log: log,
|
||||
// #48 route-work: enqueue a routed run on the ONE embedded tasks engine,
|
||||
// gated by the agents liveness check. Both bind to the real in-process
|
||||
// packages; a routed run with no live engine/target fails closed.
|
||||
Route: enqueueRoutedRun,
|
||||
TargetGate: agents.TargetDispatchable,
|
||||
Sessions: planeSessions{},
|
||||
PR: planePR{},
|
||||
// The SANDBOX runner. The docker-CLI path it replaces could not run at all:
|
||||
// bot-gateway has neither that binary nor a socket, so every dispatch 503d
|
||||
// while the chain read as configured. Same gVisor/Kata boundary, asked of the
|
||||
// apiserver instead of a CLI that is not installed — and over the plane, so
|
||||
// there is no HTTP hop to port.
|
||||
Runner: sandboxRunner{},
|
||||
CloneURL: planeCloneURL,
|
||||
VerifyRef: planeVerifyRef,
|
||||
Log: log,
|
||||
Route: planeRoute,
|
||||
TargetGate: planeTargetGate,
|
||||
}
|
||||
// #48 completion parity: bind the routed completion seam to THIS dispatcher's
|
||||
// git/tracker/session seams, so the durable delivery activity verifies the
|
||||
// pushed ref, files the PR, and closes the session exactly as the local path
|
||||
// does. The two git functions resolve their state at call time, so binding here
|
||||
// (init, before any run) is safe.
|
||||
setRoutedFinalizer(d.finalizeRoutedDurable)
|
||||
return d
|
||||
}
|
||||
|
||||
// sessionAdapter forwards to the agents in-process session API (inproc.go).
|
||||
type sessionAdapter struct{}
|
||||
|
||||
func (sessionAdapter) Open(ctx context.Context, org, actor, agent, title string) (string, error) {
|
||||
return agents.OpenSession(ctx, org, actor, agent, title)
|
||||
}
|
||||
func (sessionAdapter) OpenOn(ctx context.Context, org, actor, agent, title, target string) (string, error) {
|
||||
return agents.OpenSessionOn(ctx, org, actor, agent, title, target)
|
||||
}
|
||||
func (sessionAdapter) Log(ctx context.Context, org, sessionID, kind, actor string, payload []byte) error {
|
||||
return agents.LogSessionEvent(ctx, org, sessionID, kind, actor, payload)
|
||||
}
|
||||
func (sessionAdapter) Close(ctx context.Context, org, sessionID, status string) error {
|
||||
return agents.CloseSession(ctx, org, sessionID, status)
|
||||
// bounded gives one seam call its own deadline without letting it outlive the
|
||||
// run's. A run already cancelled fails here rather than on the wire.
|
||||
func bounded(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(ctx, seamTimeout)
|
||||
}
|
||||
|
||||
// trackerAdapter forwards to the tracker in-process agent-PR create (agentpr.go).
|
||||
type trackerAdapter struct{}
|
||||
// planeSessions is the live agent-session registry, in the agents process.
|
||||
type planeSessions struct{}
|
||||
|
||||
func (trackerAdapter) CreatePR(ctx context.Context, in PRInput) (PRRef, error) {
|
||||
pr, err := tracker.CreateAgentPR(ctx, tracker.AgentPRInput{
|
||||
Org: in.Org, Project: in.Project, Repo: in.Repo, Base: in.Base,
|
||||
Head: in.Head, Title: in.Title, Body: in.Body, Assignee: in.Assignee,
|
||||
})
|
||||
func (planeSessions) Open(ctx context.Context, org, actor, agent, title string) (string, error) {
|
||||
return planeSessions{}.OpenOn(ctx, org, actor, agent, title, "")
|
||||
}
|
||||
|
||||
// OpenOn opens the session, tagged with the machine a routed run was sent to so
|
||||
// mission-control shows it where it is executing. An empty target is the
|
||||
// ordinary sandbox session — ONE op, because "no machine" is a value of the
|
||||
// target and not a different question.
|
||||
func (planeSessions) OpenOn(ctx context.Context, org, actor, agent, title, target string) (string, error) {
|
||||
ctx, cancel := bounded(ctx)
|
||||
defer cancel()
|
||||
out, err := plane.Ask[plane.SessionOpenIn, plane.SessionOpened](ctx, agentsApp, plane.AgentsSessionOpen,
|
||||
&plane.SessionOpenIn{Org: org, Actor: actor, Agent: agent, Title: title, Target: target})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if out == nil || out.SessionID == "" {
|
||||
return "", fmt.Errorf("coding: agents opened no session")
|
||||
}
|
||||
return out.SessionID, nil
|
||||
}
|
||||
|
||||
func (planeSessions) Log(ctx context.Context, org, sessionID, kind, actor string, payload []byte) error {
|
||||
ctx, cancel := bounded(ctx)
|
||||
defer cancel()
|
||||
_, err := plane.Ask[plane.SessionEventIn, plane.CodingAck](ctx, agentsApp, plane.AgentsSessionEvent,
|
||||
&plane.SessionEventIn{Org: org, SessionID: sessionID, Kind: kind, Actor: actor, Payload: payload})
|
||||
return err
|
||||
}
|
||||
|
||||
func (planeSessions) Close(ctx context.Context, org, sessionID, status string) error {
|
||||
ctx, cancel := bounded(ctx)
|
||||
defer cancel()
|
||||
_, err := plane.Ask[plane.SessionCloseIn, plane.CodingAck](ctx, agentsApp, plane.AgentsSessionClose,
|
||||
&plane.SessionCloseIn{Org: org, SessionID: sessionID, Status: status})
|
||||
return err
|
||||
}
|
||||
|
||||
// planePR opens the pull request: the work item on our board (tracker), and the
|
||||
// proposal where the code lives (git — a GitHub pull request for a repository
|
||||
// that mirrors there, the branch's page here otherwise).
|
||||
//
|
||||
// Two peers, ONE seam. The board row is ours and is filed for every run, whatever
|
||||
// host the code is on; the address is a property of the host and only git can
|
||||
// answer it. Splitting them at the caller would put the choice of backend in the
|
||||
// orchestration, which is exactly where it must not be.
|
||||
type planePR struct{}
|
||||
|
||||
func (planePR) Open(ctx context.Context, in PRInput) (PRRef, error) {
|
||||
ctx, cancel := bounded(ctx)
|
||||
defer cancel()
|
||||
// The org travels in the ENVELOPE (cloud.For), not in the body. Both spell the
|
||||
// same word here, but only the envelope is checked: the plane's identity slot
|
||||
// is read back through the same OrgOf rule as the HTTP boundary, so a call
|
||||
// crossing the plane cannot be granted an org key the boundary would refuse.
|
||||
// A body field would arrive unchecked — which is what made this a
|
||||
// cross-tenant write. Same shape as cloud.UpsertIssue's Ask.
|
||||
ctx = plane.For(ctx, in.Org)
|
||||
out, err := plane.Ask[plane.AgentPRIn, plane.AgentPROut](ctx, trackerApp, plane.TrackerAgentPR,
|
||||
&plane.AgentPRIn{
|
||||
Project: in.Project, Repo: in.Repo, Base: in.Base,
|
||||
Head: in.Head, Title: in.Title, Body: in.Body, Assignee: in.Assignee,
|
||||
})
|
||||
if err != nil {
|
||||
return PRRef{}, err
|
||||
}
|
||||
return PRRef{Identifier: pr.Identifier, ProjectKey: pr.ProjectKey, Number: pr.Number}, nil
|
||||
if out == nil {
|
||||
return PRRef{}, fmt.Errorf("coding: tracker filed no PR")
|
||||
}
|
||||
ref := PRRef{Identifier: out.Identifier, ProjectKey: out.ProjectKey, Number: out.Number}
|
||||
|
||||
// The address. Its failure is returned BESIDE the row rather than instead of
|
||||
// it: the work is pushed and tracked either way, and a run that could not
|
||||
// reach GitHub must say so out loud instead of quietly answering with a forge
|
||||
// link that is not where the review will happen.
|
||||
p, perr := plane.Ask[plane.ProposeIn, plane.Proposed](ctx, gitApp, plane.GitPropose,
|
||||
&plane.ProposeIn{
|
||||
Project: in.Project, Repo: in.Repo, Base: in.Base,
|
||||
Head: in.Head, Title: in.Title, Body: in.Body,
|
||||
})
|
||||
if perr != nil {
|
||||
return ref, perr
|
||||
}
|
||||
if p != nil {
|
||||
ref.URL = p.URL
|
||||
}
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
// planeCloneURL asks git for the org's clone URL. An error is an EMPTY url,
|
||||
// which the dispatcher already reads as "git is not available" and refuses the
|
||||
// run on — the same fail-closed answer the in-process seam gave when git was
|
||||
// absent, so no caller learns a new failure mode.
|
||||
func planeCloneURL(ctx context.Context, org, repo string) string {
|
||||
ctx, cancel := bounded(ctx)
|
||||
defer cancel()
|
||||
out, err := plane.Ask[plane.RepoRefIn, plane.RepoCloneURL](ctx, gitApp, plane.GitCloneURL,
|
||||
&plane.RepoRefIn{Org: org, Repo: repo})
|
||||
if err != nil || out == nil {
|
||||
return ""
|
||||
}
|
||||
return out.URL
|
||||
}
|
||||
|
||||
// planeVerifyRef is the integrity gate: git reads the tip off its own storage.
|
||||
// An unreachable git is an UNVERIFIABLE ref, which is treated as absent — the
|
||||
// run fails closed and files no PR, rather than trusting the sandbox's claim.
|
||||
func planeVerifyRef(ctx context.Context, org, repo, branch string) (string, bool) {
|
||||
ctx, cancel := bounded(ctx)
|
||||
defer cancel()
|
||||
out, err := plane.Ask[plane.RefIn, plane.RefTip](ctx, gitApp, plane.GitVerifyRef,
|
||||
&plane.RefIn{Org: org, Repo: repo, Branch: branch})
|
||||
if err != nil || out == nil || !out.Found {
|
||||
return "", false
|
||||
}
|
||||
return out.SHA, true
|
||||
}
|
||||
|
||||
// planeTargetGate is the fail-closed existence+liveness check for a routed run's
|
||||
// machine: it exists in THIS org, is online, and has a live runner.
|
||||
func planeTargetGate(ctx context.Context, org, targetID string) error {
|
||||
ctx, cancel := bounded(ctx)
|
||||
defer cancel()
|
||||
_, err := plane.Ask[plane.TargetGateIn, plane.CodingAck](ctx, agentsApp, plane.AgentsTargetGate,
|
||||
&plane.TargetGateIn{Org: org, TargetID: targetID})
|
||||
return err
|
||||
}
|
||||
|
||||
// planeRoute hands the routed run to agents to enqueue.
|
||||
//
|
||||
// It does NOT enqueue the workflow here. The durable delivery activity offers
|
||||
// the run to an in-memory mailbox that the machine long-polls through agents'
|
||||
// HTTP surface, so an enqueue in any other process would hand the run to a
|
||||
// mailbox nobody reads and burn the whole budget before failing. The engine and
|
||||
// the mailbox have to be one process; agents is that process.
|
||||
func planeRoute(ctx context.Context, run RoutedRun) error {
|
||||
ctx, cancel := bounded(ctx)
|
||||
defer cancel()
|
||||
_, err := plane.Ask[plane.RouteRunIn, plane.CodingAck](ctx, agentsApp, plane.AgentsRouteRun,
|
||||
&plane.RouteRunIn{
|
||||
Org: run.Org, TargetID: run.TargetID, SessionID: run.SessionID,
|
||||
Repo: run.Repo, Project: run.Project, Base: run.Base, Branch: run.Branch,
|
||||
Prompt: run.Prompt, CloneURL: run.CloneURL, TimeoutSeconds: run.TimeoutSeconds,
|
||||
Actor: run.Actor, AgentRef: run.AgentRef,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Enqueue is the SERVER half of planeRoute, run by the agents process: put the
|
||||
// routed run on the durable engine THERE, where the mailbox the machine polls
|
||||
// lives and where the delivery activity can therefore hand the run over.
|
||||
//
|
||||
// It builds the process's Dispatcher on first use, which is what binds the
|
||||
// routed COMPLETION seam (setRoutedFinalizer): when the machine reports, the
|
||||
// delivery activity has to verify the pushed ref, file the PR and close the
|
||||
// session, and it reaches those seams through that dispatcher. Enqueueing
|
||||
// without it would queue runs whose sessions never close.
|
||||
func Enqueue(ctx context.Context, in plane.RouteRunIn, log func(msg string, kv ...any)) error {
|
||||
enqueueOnce.Do(func() { NewDispatcher(log) })
|
||||
return enqueueRoutedRun(ctx, RoutedRun{
|
||||
Org: in.Org, TargetID: in.TargetID, SessionID: in.SessionID,
|
||||
Repo: in.Repo, Project: in.Project, Base: in.Base, Branch: in.Branch,
|
||||
Prompt: in.Prompt, CloneURL: in.CloneURL, TimeoutSeconds: in.TimeoutSeconds,
|
||||
Actor: in.Actor, AgentRef: in.AgentRef,
|
||||
})
|
||||
}
|
||||
|
||||
var enqueueOnce sync.Once
|
||||
|
||||
// The peers, spelled once.
|
||||
const (
|
||||
agentsApp = "agents"
|
||||
gitApp = "git"
|
||||
trackerApp = "tracker"
|
||||
)
|
||||
|
||||
+142
-34
@@ -5,7 +5,7 @@
|
||||
//
|
||||
// It is a LIBRARY, not an app: no route, no plugin, no manifest row. Its one
|
||||
// caller is apps/integrations (the Slack `code:` trigger). It touches its
|
||||
// collaborators only through interface seams (Sessions, Tracker, Runner) plus two
|
||||
// collaborators only through interface seams (Sessions, PR, Runner) plus two
|
||||
// git functions (CloneURL, VerifyRef), so the whole orchestration is unit-testable
|
||||
// with fakes and — critically — coding does NOT import apps/git: git imports
|
||||
// apps/integrations, integrations calls coding, so coding->git would cycle.
|
||||
@@ -22,9 +22,41 @@ package coding
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RepoRE is the repo-name rule every door validates against, mirroring the git
|
||||
// subsystem's own name check. It is here and exported because a door that
|
||||
// invented its own rule would eventually accept a token carrying a path
|
||||
// separator, and a repo name that can carry a path can address another org's
|
||||
// namespace.
|
||||
var RepoRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
||||
|
||||
// OrgRE is the tenant-name rule, mirroring the git subsystem's own (git.go
|
||||
// orgRE). A run's org is interpolated into a git namespace, so it is
|
||||
// shape-checked rather than merely required: a tenant name that can carry a
|
||||
// separator or a `..` can address another tenant's repositories.
|
||||
var OrgRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
|
||||
|
||||
// BaseRE is the branch-name rule, mirroring the git subsystem's own (push.go
|
||||
// branchRE) — nested names allowed, so `release/2.1` works.
|
||||
//
|
||||
// The load-bearing property is the FIRST character class: a branch is alnum-led,
|
||||
// so a base can never begin with '-'. The base reaches a `git clone -b <base>`
|
||||
// argument on a machine we do not own, where a leading dash makes it a flag
|
||||
// rather than a branch, and `--upload-pack=` / `--config=core.fsmonitor=` are
|
||||
// each arbitrary command execution on that machine. Length and the absence of a
|
||||
// space matter too, but the dash is the one that turns data into a program.
|
||||
var BaseRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$`)
|
||||
|
||||
// BranchFor is the ONE branch a run is permitted to write, derived from the
|
||||
// session that owns it. It is a pure function of the session id — not a name the
|
||||
// caller supplies and not a name the model chooses — which is what lets the
|
||||
// forge's ref policy state the rule structurally: a coding run writes
|
||||
// refs/heads/agent/<something>, and nothing else, ever.
|
||||
func BranchFor(sessionID string) string { return "agent/" + shortID(sessionID) }
|
||||
|
||||
// Event kinds mirrored into the agent session. These are the agents session
|
||||
// vocabulary (a stable wire contract): a phase is a tool-call, a free line is a
|
||||
// log, a lifecycle transition is a status. Kept as local constants so coding does
|
||||
@@ -51,9 +83,16 @@ type Sessions interface {
|
||||
Close(ctx context.Context, org, sessionID, status string) error
|
||||
}
|
||||
|
||||
// Tracker is the work-item seam (clients/tracker in-process).
|
||||
type Tracker interface {
|
||||
CreatePR(ctx context.Context, in PRInput) (PRRef, error)
|
||||
// PR opens the pull request for a finished run and says where it can be read.
|
||||
//
|
||||
// It is ONE seam with two backends behind it, because a repository can live in
|
||||
// two places and a run must not care which: the work item lands on our board
|
||||
// either way, and the address comes back from the forge for a repository that
|
||||
// lives only here or from GitHub for one that mirrors there. A caller that had to
|
||||
// ask which host it was talking to would be two orchestrations pretending to be
|
||||
// one, and they would drift.
|
||||
type PR interface {
|
||||
Open(ctx context.Context, in PRInput) (PRRef, error)
|
||||
}
|
||||
|
||||
// Runner is the bot-gateway coding-task seam (clients/bot in-process client).
|
||||
@@ -78,10 +117,29 @@ type PRRef struct {
|
||||
Identifier string
|
||||
ProjectKey string
|
||||
Number int
|
||||
// URL is where the proposal is READ — the GitHub pull request when the
|
||||
// repository mirrors there, the branch's page in the forge when it does not.
|
||||
// It is the only part of a finished run a person can click, so it travels all
|
||||
// the way out to the chat thread; empty is a real answer (a project-scoped
|
||||
// repository has no browsable page) and costs a link, never a run.
|
||||
URL string
|
||||
}
|
||||
|
||||
// RunRequest / Step / RunResult mirror the bot coding contract.
|
||||
type RunRequest struct {
|
||||
// Tool names what runs inside the sandbox: dev | claude | codex | python |
|
||||
// node. Empty means dev. The runtime owns the name→argv table; cloud only
|
||||
// carries the name, so adding a tool is one edit over there and none here.
|
||||
Tool string
|
||||
|
||||
// Desktop selects the xvfb IMAGE VARIANT — a tag, not a mode. A desktop run
|
||||
// gets an X server, so a real browser window exists to drive; every class can
|
||||
// already drive a headless one.
|
||||
Desktop bool
|
||||
|
||||
// The repo is OPTIONAL. CloneURL empty means the run has no checkout — and
|
||||
// then CredUser/CredToken MUST be empty too, because a credential a run can
|
||||
// never use only exists to leak. The runtime refuses the combination.
|
||||
CloneURL string
|
||||
BaseBranch string
|
||||
Branch string
|
||||
@@ -129,6 +187,17 @@ type Req struct {
|
||||
CredToken string
|
||||
TimeoutSeconds int
|
||||
TargetID string // when set, route to this registered machine instead of the sandbox
|
||||
// Tool / Desktop are the caller's choice of harness and whether it needs a
|
||||
// screen. They travel to RunRequest unchanged; classFor and argvFor over in
|
||||
// sandboxrunner.go are the only two things that read them.
|
||||
Tool string
|
||||
Desktop bool
|
||||
// SessionID adopts an ALREADY-OPEN session instead of opening one. The door
|
||||
// (Start) opens it so it can answer with a real handle the moment the run is
|
||||
// admitted, rather than an empty promise the caller cannot watch. Empty keeps
|
||||
// the original behaviour — Run opens its own — which is what the unit tests
|
||||
// exercise and what a direct Dispatcher caller still gets.
|
||||
SessionID string
|
||||
}
|
||||
|
||||
// RoutedRun is the NON-SECRET spec coding hands the Route seam to enqueue on the
|
||||
@@ -184,12 +253,14 @@ const (
|
||||
)
|
||||
|
||||
// Dispatcher wires the seams. The two git functions are injected (not an
|
||||
// interface) because they are pure reads with no cloud-side state.
|
||||
// interface) because they are pure reads with no cloud-side state. Both take a
|
||||
// ctx: git is another PROCESS, so both are calls that can be slow, refused, or
|
||||
// cancelled with the run.
|
||||
type Dispatcher struct {
|
||||
Sessions Sessions
|
||||
Tracker Tracker
|
||||
PR PR
|
||||
Runner Runner
|
||||
CloneURL func(org, repo string) string
|
||||
CloneURL func(ctx context.Context, org, repo string) string
|
||||
VerifyRef func(ctx context.Context, org, repo, branch string) (string, bool)
|
||||
// Log is an optional structured log seam for best-effort mirror failures; nil
|
||||
// is fine (mirror failures are non-fatal and simply dropped).
|
||||
@@ -202,6 +273,20 @@ type Dispatcher struct {
|
||||
// target (agents.TargetDispatchable): the target exists in this org, is
|
||||
// online, and has a live runner. Nil disables routing.
|
||||
TargetGate func(ctx context.Context, org, targetID string) error
|
||||
// Watch observes every event the run mirrors into its session — the SAME
|
||||
// events, at the same moment, from the one place a run narrates itself.
|
||||
//
|
||||
// It exists because a run is watched from more than one place and must not
|
||||
// grow a second narration to serve each. The session stream is the primary
|
||||
// feed; a chat thread is a second reader of the same story. Adding a hook
|
||||
// here rather than a Slack call inside the orchestration is what keeps
|
||||
// coding.go ignorant of chat: it emits events, and who listens is a
|
||||
// composition decision made where the run is started.
|
||||
//
|
||||
// Nil is the ordinary case and costs nothing. It is best-effort by
|
||||
// construction — it runs beside a mirror that is itself best-effort, and a
|
||||
// watcher that fails must never fail the run whose work already happened.
|
||||
Watch func(ctx context.Context, kind string, payload []byte)
|
||||
}
|
||||
|
||||
// Run executes one coding job end to end and returns its Result. It never
|
||||
@@ -241,7 +326,7 @@ func (d Dispatcher) Run(ctx context.Context, req Req) Result {
|
||||
}
|
||||
cloneURL := ""
|
||||
if d.CloneURL != nil {
|
||||
cloneURL = d.CloneURL(org, repo)
|
||||
cloneURL = d.CloneURL(ctx, org, repo)
|
||||
}
|
||||
if cloneURL == "" {
|
||||
res.Error = "git is not available"
|
||||
@@ -254,14 +339,19 @@ func (d Dispatcher) Run(ctx context.Context, req Req) Result {
|
||||
}
|
||||
actor := strings.TrimSpace(req.UserID)
|
||||
|
||||
// 1. Register the live session (the durable record + live stream root).
|
||||
sessionID, err := d.Sessions.Open(ctx, org, actor, agentRef, codingTitle(repo, prompt))
|
||||
if err != nil {
|
||||
res.Error = "could not start a session: " + err.Error()
|
||||
return res
|
||||
// 1. Register the live session (the durable record + live stream root), or
|
||||
// adopt the one the door already opened to answer its caller with.
|
||||
sessionID := strings.TrimSpace(req.SessionID)
|
||||
if sessionID == "" {
|
||||
var err error
|
||||
sessionID, err = d.Sessions.Open(ctx, org, actor, agentRef, codingTitle(repo, prompt))
|
||||
if err != nil {
|
||||
res.Error = "could not start a session: " + err.Error()
|
||||
return res
|
||||
}
|
||||
}
|
||||
res.SessionID = sessionID
|
||||
branch := "agent/" + shortID(sessionID)
|
||||
branch := BranchFor(sessionID)
|
||||
res.Branch = branch
|
||||
|
||||
// Terminal bookkeeping (final status mirror, session close, PR row) runs on a
|
||||
@@ -276,6 +366,7 @@ func (d Dispatcher) Run(ctx context.Context, req Req) Result {
|
||||
|
||||
// 2. Dispatch to the sandbox runtime, mirroring every progress line live.
|
||||
runReq := RunRequest{
|
||||
Tool: req.Tool, Desktop: req.Desktop,
|
||||
CloneURL: cloneURL, BaseBranch: strings.TrimSpace(req.Base), Branch: branch,
|
||||
Prompt: prompt, SessionID: sessionID, RunTimeoutSeconds: timeoutOr(req.TimeoutSeconds),
|
||||
CredUser: req.CredUser, CredToken: req.CredToken,
|
||||
@@ -296,10 +387,17 @@ func (d Dispatcher) Run(ctx context.Context, req Req) Result {
|
||||
res.Diffstat = runRes.Diffstat
|
||||
res.Changed = runRes.Changed
|
||||
res.LogTail = runRes.LogTail
|
||||
if runRes.Branch != "" {
|
||||
res.Branch = runRes.Branch
|
||||
branch = runRes.Branch
|
||||
}
|
||||
// runRes.Branch is NOT read. The branch is BranchFor(sessionID) — issued by
|
||||
// cloud, named after a session the sandbox did not choose — and adopting the
|
||||
// sandbox's self-report let a compromised one answer `main`, which then flowed
|
||||
// into VerifyRef (which only asks whether a ref EXISTS, and main does) and out
|
||||
// as PR.Open{Head: "main"}. A PR headed at the trunk, filed by us, from a run
|
||||
// that never had permission to write there.
|
||||
//
|
||||
// The sandbox has nothing to report here: it was TOLD which branch to push,
|
||||
// and its grant admits that one ref and no other, so a disagreement between
|
||||
// what it claims and what we issued is not new information — it is the
|
||||
// signal that something is wrong.
|
||||
res.CommitSha = runRes.CommitSha
|
||||
|
||||
if !runRes.OK {
|
||||
@@ -352,19 +450,22 @@ func (d Dispatcher) completeChanged(ctx context.Context, c completion, res Resul
|
||||
res.CommitSha = sha // authoritative tip from our own storage
|
||||
}
|
||||
}
|
||||
pr, perr := d.Tracker.CreatePR(ctx, PRInput{
|
||||
pr, perr := d.PR.Open(ctx, PRInput{
|
||||
Org: c.org, Project: strings.TrimSpace(c.project), Repo: c.repo,
|
||||
Base: baseOr(c.base), Head: c.branch, Title: codingTitle(c.repo, c.prompt),
|
||||
Body: prBody(c.prompt, c.base, c.branch, res.CommitSha, c.diffstat, c.sessionID), Assignee: c.agentRef,
|
||||
})
|
||||
// Whatever came back is kept, even beside an error: opening a PR is two acts
|
||||
// in two places, and a run that filed its work item but could not reach GitHub
|
||||
// has a real handle to report. Discarding it would hide the half that worked.
|
||||
res.PR = pr
|
||||
if perr != nil {
|
||||
d.logf("coding: tracker PR create failed", "org", c.org, "repo", c.repo, "err", perr)
|
||||
d.mirror(ctx, c.org, c.sessionID, c.actor, kindLog, map[string]any{"message": "tracker PR not created: " + perr.Error()})
|
||||
} else {
|
||||
res.PR = pr
|
||||
d.logf("coding: PR open failed", "org", c.org, "repo", c.repo, "err", perr)
|
||||
d.mirror(ctx, c.org, c.sessionID, c.actor, kindLog, map[string]any{"message": "pull request: " + perr.Error()})
|
||||
}
|
||||
d.mirror(ctx, c.org, c.sessionID, c.actor, kindStatus, map[string]any{
|
||||
"status": "done", "changed": true, "branch": c.branch, "commit": res.CommitSha, "pr": pr.Identifier,
|
||||
"status": "done", "changed": true, "branch": c.branch,
|
||||
"commit": res.CommitSha, "pr": pr.Identifier, "url": pr.URL,
|
||||
})
|
||||
_ = d.Sessions.Close(ctx, c.org, c.sessionID, statusDone)
|
||||
res.OK = true
|
||||
@@ -400,10 +501,10 @@ func (d Dispatcher) finalizeRouted(ctx context.Context, in RoutedRun, res Routed
|
||||
_ = d.Sessions.Close(ctx, in.Org, in.SessionID, statusDone)
|
||||
return
|
||||
}
|
||||
branch := strings.TrimSpace(res.Branch)
|
||||
if branch == "" {
|
||||
branch = in.Branch
|
||||
}
|
||||
// res.Branch is NOT read, for the reason stated in Run: the branch is the one
|
||||
// cloud issued and put in the RoutedRun, and a machine that reports a
|
||||
// different one is reporting something it was never asked.
|
||||
branch := in.Branch
|
||||
out.Branch = branch
|
||||
out.CommitSha = res.CommitSha
|
||||
out.Changed = true
|
||||
@@ -452,7 +553,7 @@ func (d Dispatcher) routed(ctx context.Context, req Req, org, repo, prompt strin
|
||||
// clone URL (non-secret) to hand it.
|
||||
cloneURL := ""
|
||||
if d.CloneURL != nil {
|
||||
cloneURL = d.CloneURL(org, repo)
|
||||
cloneURL = d.CloneURL(ctx, org, repo)
|
||||
}
|
||||
if cloneURL == "" {
|
||||
res.Error = "git is not available"
|
||||
@@ -472,13 +573,17 @@ func (d Dispatcher) routed(ctx context.Context, req Req, org, repo, prompt strin
|
||||
}
|
||||
actor := strings.TrimSpace(req.UserID)
|
||||
|
||||
sessionID, err := d.Sessions.OpenOn(ctx, org, actor, agentRef, codingTitle(repo, prompt), target)
|
||||
if err != nil {
|
||||
res.Error = "could not start a session: " + err.Error()
|
||||
return res
|
||||
sessionID := strings.TrimSpace(req.SessionID)
|
||||
if sessionID == "" {
|
||||
var err error
|
||||
sessionID, err = d.Sessions.OpenOn(ctx, org, actor, agentRef, codingTitle(repo, prompt), target)
|
||||
if err != nil {
|
||||
res.Error = "could not start a session: " + err.Error()
|
||||
return res
|
||||
}
|
||||
}
|
||||
res.SessionID = sessionID
|
||||
branch := "agent/" + shortID(sessionID)
|
||||
branch := BranchFor(sessionID)
|
||||
res.Branch = branch
|
||||
|
||||
d.mirror(ctx, org, sessionID, actor, kindStatus, map[string]any{
|
||||
@@ -528,6 +633,9 @@ func (d Dispatcher) mirror(ctx context.Context, org, sessionID, actor, kind stri
|
||||
if err := d.Sessions.Log(ctx, org, sessionID, kind, actor, b); err != nil {
|
||||
d.logf("coding: session event mirror failed", "org", org, "session", sessionID, "err", err)
|
||||
}
|
||||
if d.Watch != nil {
|
||||
d.Watch(ctx, kind, b)
|
||||
}
|
||||
}
|
||||
|
||||
func (d Dispatcher) logf(msg string, kv ...any) {
|
||||
|
||||
+14
-14
@@ -56,14 +56,14 @@ func (f *fakeSessions) Close(_ context.Context, org, session, status string) err
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeTracker struct {
|
||||
type fakePR struct {
|
||||
mu sync.Mutex
|
||||
inputs []PRInput
|
||||
ref PRRef
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeTracker) CreatePR(_ context.Context, in PRInput) (PRRef, error) {
|
||||
func (f *fakePR) Open(_ context.Context, in PRInput) (PRRef, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.inputs = append(f.inputs, in)
|
||||
@@ -94,11 +94,11 @@ func (f *fakeRunner) Run(_ context.Context, org, userID string, req RunRequest,
|
||||
// dispatcherFor builds a Dispatcher whose git seams are org-aware fakes: CloneURL
|
||||
// echoes the (org, repo) so a test can assert the sandbox is pointed only at the
|
||||
// caller's namespace; VerifyRef succeeds for a configured branch.
|
||||
func dispatcherFor(sess *fakeSessions, tr *fakeTracker, run *fakeRunner, verifyOK bool) (Dispatcher, *[]string) {
|
||||
func dispatcherFor(sess *fakeSessions, tr *fakePR, run *fakeRunner, verifyOK bool) (Dispatcher, *[]string) {
|
||||
var cloneCalls []string
|
||||
d := Dispatcher{
|
||||
Sessions: sess, Tracker: tr, Runner: run,
|
||||
CloneURL: func(org, repo string) string {
|
||||
Sessions: sess, PR: tr, Runner: run,
|
||||
CloneURL: func(_ context.Context, org, repo string) string {
|
||||
cloneCalls = append(cloneCalls, org+"/"+repo)
|
||||
return "https://git.test/v1/git/" + org + "/" + repo + ".git"
|
||||
},
|
||||
@@ -125,7 +125,7 @@ func baseReq() Req {
|
||||
|
||||
func TestRun_HappyPath_PushVerifyPR_NoCredentialLeak(t *testing.T) {
|
||||
sess := &fakeSessions{id: "sess_abc123def456"}
|
||||
tr := &fakeTracker{ref: PRRef{Identifier: "API-1", ProjectKey: "API", Number: 1}}
|
||||
tr := &fakePR{ref: PRRef{Identifier: "API-1", ProjectKey: "API", Number: 1}}
|
||||
run := &fakeRunner{
|
||||
steps: []Step{{Type: "step", Step: "clone", Status: "ok"}, {Type: "log", Message: "editing handler.go"}, {Type: "step", Step: "push", Status: "ok"}},
|
||||
result: RunResult{Branch: "agent/abc123def456", CommitSha: "deadbeef", Diffstat: "1 file changed", Changed: true, OK: true},
|
||||
@@ -188,7 +188,7 @@ func TestRun_EventVocabulary_StepIsToolCall_LogIsLog(t *testing.T) {
|
||||
steps: []Step{{Type: "step", Step: "clone"}, {Type: "log", Message: "hi"}},
|
||||
result: RunResult{Branch: "agent/x", Changed: true, OK: true},
|
||||
}
|
||||
d, _ := dispatcherFor(sess, &fakeTracker{}, run, true)
|
||||
d, _ := dispatcherFor(sess, &fakePR{}, run, true)
|
||||
d.Run(context.Background(), baseReq())
|
||||
|
||||
var toolCalls, logs, statuses int
|
||||
@@ -214,7 +214,7 @@ func TestRun_EventVocabulary_StepIsToolCall_LogIsLog(t *testing.T) {
|
||||
|
||||
func TestRun_NoChanges_NoPR_DoneNotError(t *testing.T) {
|
||||
sess := &fakeSessions{id: "sess_x"}
|
||||
tr := &fakeTracker{}
|
||||
tr := &fakePR{}
|
||||
run := &fakeRunner{result: RunResult{Changed: false, OK: true}}
|
||||
d, _ := dispatcherFor(sess, tr, run, true)
|
||||
|
||||
@@ -232,7 +232,7 @@ func TestRun_NoChanges_NoPR_DoneNotError(t *testing.T) {
|
||||
|
||||
func TestRun_RunnerError_ClosesError_NoPR(t *testing.T) {
|
||||
sess := &fakeSessions{id: "sess_x"}
|
||||
tr := &fakeTracker{}
|
||||
tr := &fakePR{}
|
||||
run := &fakeRunner{err: context.DeadlineExceeded}
|
||||
d, _ := dispatcherFor(sess, tr, run, true)
|
||||
|
||||
@@ -250,7 +250,7 @@ func TestRun_RunnerError_ClosesError_NoPR(t *testing.T) {
|
||||
|
||||
func TestRun_VerifyRefFails_FailsClosed_NoPR(t *testing.T) {
|
||||
sess := &fakeSessions{id: "sess_x"}
|
||||
tr := &fakeTracker{}
|
||||
tr := &fakePR{}
|
||||
run := &fakeRunner{result: RunResult{Branch: "agent/x", Changed: true, OK: true}}
|
||||
d, _ := dispatcherFor(sess, tr, run, false) // verify fails
|
||||
|
||||
@@ -268,7 +268,7 @@ func TestRun_VerifyRefFails_FailsClosed_NoPR(t *testing.T) {
|
||||
|
||||
func TestRun_MissingCredential_FailsBeforeOpeningSession(t *testing.T) {
|
||||
sess := &fakeSessions{id: "sess_x"}
|
||||
d, _ := dispatcherFor(sess, &fakeTracker{}, &fakeRunner{}, true)
|
||||
d, _ := dispatcherFor(sess, &fakePR{}, &fakeRunner{}, true)
|
||||
|
||||
req := baseReq()
|
||||
req.CredToken = ""
|
||||
@@ -282,7 +282,7 @@ func TestRun_MissingCredential_FailsBeforeOpeningSession(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRun_MissingRepoOrPrompt_FailsClosed(t *testing.T) {
|
||||
d, _ := dispatcherFor(&fakeSessions{id: "s"}, &fakeTracker{}, &fakeRunner{}, true)
|
||||
d, _ := dispatcherFor(&fakeSessions{id: "s"}, &fakePR{}, &fakeRunner{}, true)
|
||||
if r := d.Run(context.Background(), Req{Org: "acme", CredToken: "x", Prompt: "do it"}); r.OK || r.Error == "" {
|
||||
t.Fatalf("missing repo must fail: %+v", r)
|
||||
}
|
||||
@@ -291,9 +291,9 @@ func TestRun_MissingRepoOrPrompt_FailsClosed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_TrackerFailure_DoesNotFailRun(t *testing.T) {
|
||||
func TestRun_PRFailure_DoesNotFailRun(t *testing.T) {
|
||||
sess := &fakeSessions{id: "sess_x"}
|
||||
tr := &fakeTracker{err: context.Canceled}
|
||||
tr := &fakePR{err: context.Canceled}
|
||||
run := &fakeRunner{result: RunResult{Branch: "agent/x", Changed: true, OK: true}}
|
||||
d, _ := dispatcherFor(sess, tr, run, true)
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package coding
|
||||
|
||||
// The credential the agent inside a sandbox answers the gateway with.
|
||||
//
|
||||
// Without one the whole product stops one step short of working: the box leases,
|
||||
// the repo clones, `dev` starts, hanzo-mcp answers — and then the model call
|
||||
// returns `Missing environment variable: HANZO_API_KEY`, or, with a key that
|
||||
// names nobody, the gateway's 402 `a billable tenant is required (no anonymous
|
||||
// usage)`. Every part was built and nothing could finish a task.
|
||||
//
|
||||
// IT IS OUR OWN MACHINE IDENTITY, MINTED PER RUN, and it is minted the way the
|
||||
// rest of the fleet mints: client_credentials against IAM, through the same
|
||||
// golang.org/x/oauth2 config clients/aihttp.go uses for inference. There is no
|
||||
// second notion of "who a sandbox is" — a run authenticates as the deployment
|
||||
// that started it, which is the identity the gateway already prices and meters.
|
||||
//
|
||||
// A STATIC KEY WAS THE OTHER OPTION AND IS WORSE IN EVERY DIRECTION. A long-lived
|
||||
// hk- key would have to be stored, rotated, and handed to a box that is about to
|
||||
// execute a model's output; this token expires on its own, is scoped to one
|
||||
// identity, and nothing has to remember to revoke it.
|
||||
//
|
||||
// The token URL is not resolved here. IAMBaseURL is the fleet's split-horizon
|
||||
// policy — the public issuer is Cloudflare-fronted and 403s an in-cluster
|
||||
// server-side POST (edge 1006), so an in-cluster address must win — and that
|
||||
// policy having exactly one home is why the agent runner works at all.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/clientcredentials"
|
||||
)
|
||||
|
||||
// source is built once and reused: the oauth2 client_credentials source caches a
|
||||
// token until it expires, so a burst of runs mints one token rather than one
|
||||
// each. Nil when the deployment carries no machine identity, which is the honest
|
||||
// answer for a dev box and reads as "no credential" at the call site rather than
|
||||
// as an empty string that looks like one.
|
||||
var source = sync.OnceValue(func() oauth2.TokenSource {
|
||||
id := strings.TrimSpace(os.Getenv("IAM_CLIENT_ID"))
|
||||
secret := strings.TrimSpace(os.Getenv("IAM_CLIENT_SECRET"))
|
||||
base := cloud.IAMBaseURL(cloud.IAMIssuer())
|
||||
if id == "" || secret == "" || base == "" {
|
||||
return nil
|
||||
}
|
||||
cc := &clientcredentials.Config{
|
||||
ClientID: id,
|
||||
ClientSecret: secret,
|
||||
TokenURL: base + "/v1/iam/oauth/token",
|
||||
// hanzo.id reads the credentials from the form body, not Basic auth —
|
||||
// the same style clients/aihttp.go proved against this issuer.
|
||||
AuthStyle: oauth2.AuthStyleInParams,
|
||||
}
|
||||
return cc.TokenSource(context.Background())
|
||||
})
|
||||
|
||||
// key returns the bearer a run authenticates with, or "" when this deployment
|
||||
// holds no machine identity. An error is returned only when an identity IS
|
||||
// configured and minting failed, because that is a broken deployment rather than
|
||||
// an unconfigured one and the difference is what a caller needs to report.
|
||||
func key(ctx context.Context) (string, error) {
|
||||
ts := source()
|
||||
if ts == nil {
|
||||
return "", nil
|
||||
}
|
||||
tok, err := ts.Token()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("mint sandbox credential: %w", err)
|
||||
}
|
||||
return tok.AccessToken, nil
|
||||
}
|
||||
|
||||
// keyed wraps an agent's argv so the credential arrives on STDIN and becomes an
|
||||
// environment variable inside the box, and is never an argument.
|
||||
//
|
||||
// ARGV IS PUBLIC AND STDIN IS NOT. Every process in the pod can read another's
|
||||
// command line out of /proc, the argv is what a run echoes into its own audit
|
||||
// line and its session narration, and the process we are handing it to is about
|
||||
// to execute a language model's output. Stdin reaches this one process and
|
||||
// nothing else, the exec stream already carries it, and the value never touches
|
||||
// a file, a layer, or the pod spec.
|
||||
//
|
||||
// `IFS= read -r` takes exactly the first line and leaves the rest of stdin for
|
||||
// the agent, so this does not consume input a tool might want. `exec "$@"`
|
||||
// replaces the shell, so the agent keeps PID-of-the-command and signals and exit
|
||||
// codes travel unchanged — the wrapper is gone by the time the agent runs.
|
||||
func keyed(argv []string) []string {
|
||||
return append([]string{
|
||||
"sh", "-c",
|
||||
`IFS= read -r HANZO_API_KEY; export HANZO_API_KEY; exec "$@"`,
|
||||
"sh",
|
||||
}, argv...)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package coding
|
||||
|
||||
// Where the sandbox credential is allowed to appear.
|
||||
//
|
||||
// The run hands a bearer to a process that is about to execute a language
|
||||
// model's output, in a pod that also holds the model's own tools. Argv is the
|
||||
// one place it must never be: /proc makes another process's command line
|
||||
// readable, and this argv is echoed into the run's session narration and its
|
||||
// audit line, so a credential there is published three ways at once.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTheCredentialIsNeverAnArgument(t *testing.T) {
|
||||
const secret = "eyJhbGciOiJSUzI1NiJ9.THE-BEARER.sig"
|
||||
got := keyed([]string{"dev", "exec", "--full-auto", "--", "fix the bug"})
|
||||
for i, a := range got {
|
||||
if strings.Contains(a, secret) {
|
||||
t.Fatalf("argv[%d] carries the credential: %q", i, a)
|
||||
}
|
||||
}
|
||||
// It is read from stdin into the environment, and the agent still runs.
|
||||
if !strings.Contains(got[2], "read -r HANZO_API_KEY") {
|
||||
t.Errorf("the wrapper does not read the credential: %q", got[2])
|
||||
}
|
||||
if !strings.Contains(got[2], "export HANZO_API_KEY") {
|
||||
t.Errorf("the credential is read but never exported: %q", got[2])
|
||||
}
|
||||
// `exec` matters: without it the shell outlives the agent and swallows its
|
||||
// signals and exit code, so a cancelled run would report the shell's status.
|
||||
if !strings.Contains(got[2], `exec "$@"`) {
|
||||
t.Errorf("the wrapper does not exec the agent, so it keeps the process: %q", got[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheAgentArgvSurvivesIntact(t *testing.T) {
|
||||
// A wrapper that drops or reorders the agent's own arguments would change the
|
||||
// task being run. `$0` is spent on the shell's name, so the agent's argv must
|
||||
// begin immediately after it.
|
||||
argv := []string{"dev", "exec", "--full-auto", "--skip-git-repo-check", "--", "a prompt with spaces"}
|
||||
got := keyed(argv)
|
||||
if len(got) != len(argv)+4 {
|
||||
t.Fatalf("got %d words, want %d: %v", len(got), len(argv)+4, got)
|
||||
}
|
||||
if got[0] != "sh" || got[1] != "-c" || got[3] != "sh" {
|
||||
t.Fatalf("wrapper prefix is not `sh -c <script> sh`: %v", got[:4])
|
||||
}
|
||||
for i, want := range argv {
|
||||
if got[i+4] != want {
|
||||
t.Errorf("argv[%d] = %q, want %q", i, got[i+4], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoIdentityIsNotAnError(t *testing.T) {
|
||||
// A dev box holds no machine identity. That must read as "no credential" and
|
||||
// let the run proceed to the harness's own honest failure, not as a broken
|
||||
// deployment — the lease, the clone and the branch all still happened.
|
||||
t.Setenv("IAM_CLIENT_ID", "")
|
||||
t.Setenv("IAM_CLIENT_SECRET", "")
|
||||
if got := source(); got != nil {
|
||||
// source() is memoized per process, so this only asserts the shape when
|
||||
// this test is the one that built it.
|
||||
t.Skip("a machine identity was already resolved in this process")
|
||||
}
|
||||
k, err := key(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("no identity reported an error: %v", err)
|
||||
}
|
||||
if k != "" {
|
||||
t.Fatalf("no identity produced a credential: %q", k)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package coding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud/plane"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// plane_test.go runs a whole coding job through REAL SOCKETS.
|
||||
//
|
||||
// The seam fakes elsewhere in this package call the Dispatcher's fields
|
||||
// directly, which is the right shape for testing the orchestration and the
|
||||
// wrong shape for testing this: the bug being fixed here was never in the
|
||||
// orchestration. It was that each seam landed on a package global belonging to
|
||||
// another PROCESS, and no in-process test can fail on that — the calls all
|
||||
// resolve, against the nil that ships.
|
||||
//
|
||||
// So the peers here are actual zip apps on actual unix sockets, serving the
|
||||
// actual ops the production plugins serve, and the Dispatcher's seams are the
|
||||
// production plane clients. What is proven is what could not be proven before:
|
||||
// every argument ENCODES (a map field would die inside zip.Call, before the
|
||||
// socket — see plane_encodable_test.go), every reply decodes, and a run whose
|
||||
// collaborators are all elsewhere still opens its session, points the sandbox at
|
||||
// its own org, verifies the pushed ref and files its PR.
|
||||
|
||||
// peers stands up the three apps a coding run reaches, each backed by the same
|
||||
// recording fakes the in-process tests use, so an assertion can be made about
|
||||
// what ARRIVED on the far side rather than what was sent.
|
||||
type peers struct {
|
||||
sessions *fakeSessions
|
||||
tracker *fakePR
|
||||
clone string
|
||||
tip string
|
||||
found bool
|
||||
gated []string
|
||||
proposed string
|
||||
}
|
||||
|
||||
func servePeers(t *testing.T, p *peers) {
|
||||
t.Helper()
|
||||
t.Setenv("ZIP_RUNTIME_DIR", t.TempDir())
|
||||
|
||||
agentsApp := zip.New(zip.Config{AppName: "agents", DisableStartupMessage: true})
|
||||
zip.Post[plane.SessionOpenIn, plane.SessionOpened](agentsApp, "/agents/session/open",
|
||||
func(ctx context.Context, in *plane.SessionOpenIn) (*plane.SessionOpened, error) {
|
||||
id, err := p.sessions.OpenOn(ctx, in.Org, in.Actor, in.Agent, in.Title, in.Target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &plane.SessionOpened{SessionID: id}, nil
|
||||
}, zip.WithOperationID(plane.AgentsSessionOpen))
|
||||
zip.Post[plane.SessionEventIn, plane.CodingAck](agentsApp, "/agents/session/event",
|
||||
func(ctx context.Context, in *plane.SessionEventIn) (*plane.CodingAck, error) {
|
||||
if err := p.sessions.Log(ctx, in.Org, in.SessionID, in.Kind, in.Actor, in.Payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &plane.CodingAck{OK: true}, nil
|
||||
}, zip.WithOperationID(plane.AgentsSessionEvent))
|
||||
zip.Post[plane.SessionCloseIn, plane.CodingAck](agentsApp, "/agents/session/close",
|
||||
func(ctx context.Context, in *plane.SessionCloseIn) (*plane.CodingAck, error) {
|
||||
if err := p.sessions.Close(ctx, in.Org, in.SessionID, in.Status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &plane.CodingAck{OK: true}, nil
|
||||
}, zip.WithOperationID(plane.AgentsSessionClose))
|
||||
zip.Post[plane.TargetGateIn, plane.CodingAck](agentsApp, "/agents/target-gate",
|
||||
func(_ context.Context, in *plane.TargetGateIn) (*plane.CodingAck, error) {
|
||||
p.gated = append(p.gated, in.Org+"/"+in.TargetID)
|
||||
return &plane.CodingAck{OK: true}, nil
|
||||
}, zip.WithOperationID(plane.AgentsTargetGate))
|
||||
|
||||
gitApp := zip.New(zip.Config{AppName: "git", DisableStartupMessage: true})
|
||||
zip.Post[plane.RepoRefIn, plane.RepoCloneURL](gitApp, "/git/clone-url",
|
||||
func(_ context.Context, in *plane.RepoRefIn) (*plane.RepoCloneURL, error) {
|
||||
p.clone = in.Org + "/" + in.Repo
|
||||
return &plane.RepoCloneURL{URL: "https://git.test/v1/git/" + in.Org + "/" + in.Repo + ".git"}, nil
|
||||
}, zip.WithOperationID(plane.GitCloneURL))
|
||||
zip.Post[plane.RefIn, plane.RefTip](gitApp, "/git/verify-ref",
|
||||
func(_ context.Context, _ *plane.RefIn) (*plane.RefTip, error) {
|
||||
return &plane.RefTip{SHA: p.tip, Found: p.found}, nil
|
||||
}, zip.WithOperationID(plane.GitVerifyRef))
|
||||
zip.Post[plane.ProposeIn, plane.Proposed](gitApp, "/git/propose",
|
||||
func(_ context.Context, in *plane.ProposeIn) (*plane.Proposed, error) {
|
||||
p.proposed = in.Head
|
||||
return &plane.Proposed{URL: "https://git.test/git/acme/api?ref=" + in.Head}, nil
|
||||
}, zip.WithOperationID(plane.GitPropose))
|
||||
|
||||
trackerApp := zip.New(zip.Config{AppName: "tracker", DisableStartupMessage: true})
|
||||
zip.Post[plane.AgentPRIn, plane.AgentPROut](trackerApp, "/tracker/agent-pr",
|
||||
func(ctx context.Context, in *plane.AgentPRIn) (*plane.AgentPROut, error) {
|
||||
// No in.Org: the org is the caller's plane identity, mirroring the real
|
||||
// handler (plugin/tracker/seams.go) after the cross-tenant write was closed.
|
||||
ref, err := p.tracker.Open(ctx, PRInput{
|
||||
Org: zip.CallerOf(ctx).Org, Project: in.Project, Repo: in.Repo, Base: in.Base,
|
||||
Head: in.Head, Title: in.Title, Body: in.Body, Assignee: in.Assignee,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &plane.AgentPROut{Identifier: ref.Identifier, ProjectKey: ref.ProjectKey, Number: ref.Number}, nil
|
||||
}, zip.WithOperationID(plane.TrackerAgentPR))
|
||||
|
||||
for name, app := range map[string]*zip.App{"agents": agentsApp, "git": gitApp, "tracker": trackerApp} {
|
||||
app := app
|
||||
plane.Bind()
|
||||
go func(path string) { _ = app.Listen(path) }(zip.SocketPath(name))
|
||||
t.Cleanup(func() { _ = app.Shutdown() })
|
||||
waitListening(t, name)
|
||||
}
|
||||
}
|
||||
|
||||
func waitListening(t *testing.T, app string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if up, err := plane.Listening(zip.SocketPath(app)); err == nil && up {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("peer %s never came up", app)
|
||||
}
|
||||
|
||||
// planeDispatcher is the production seam set (adapters.go) with only the sandbox
|
||||
// runner faked — the runner was always an HTTP client and never had a boundary.
|
||||
func planeDispatcher(run *fakeRunner) Dispatcher {
|
||||
return Dispatcher{
|
||||
Sessions: planeSessions{}, PR: planePR{}, Runner: run,
|
||||
CloneURL: planeCloneURL, VerifyRef: planeVerifyRef, TargetGate: planeTargetGate,
|
||||
}
|
||||
}
|
||||
|
||||
// A changed run completes with every seam on the far side of a socket.
|
||||
func TestRun_OverThePlane_CompletesAcrossProcesses(t *testing.T) {
|
||||
p := &peers{
|
||||
sessions: &fakeSessions{id: "sess_abc123def456"},
|
||||
tracker: &fakePR{ref: PRRef{Identifier: "API-7", ProjectKey: "API", Number: 7}},
|
||||
tip: "verifiedsha", found: true,
|
||||
}
|
||||
servePeers(t, p)
|
||||
|
||||
run := &fakeRunner{
|
||||
steps: []Step{{Type: "step", Step: "clone", Message: "cloning", Status: "ok"}},
|
||||
result: RunResult{Changed: true, OK: true, CommitSha: "deadbeef", Diffstat: " 1 file changed"},
|
||||
}
|
||||
res := planeDispatcher(run).Run(context.Background(), Req{
|
||||
Org: "acme", UserID: "u_1", AgentRef: "hanzo", Repo: "api",
|
||||
Prompt: "fix the flake", CredUser: "x-access-token", CredToken: "sk-secret",
|
||||
})
|
||||
|
||||
if !res.OK || !res.Verified {
|
||||
t.Fatalf("run must complete over the plane: %+v", res)
|
||||
}
|
||||
if res.PR.Identifier != "API-7" {
|
||||
t.Fatalf("the PR must be filed through tracker's door, got %q", res.PR.Identifier)
|
||||
}
|
||||
// ONE seam, both backends: the row landed on the board AND git answered where
|
||||
// the proposal is read. A run whose result carries no address gives a person
|
||||
// in a thread nothing to click.
|
||||
if p.proposed != "agent/abc123def456" {
|
||||
t.Fatalf("git was never asked to propose the branch, got %q", p.proposed)
|
||||
}
|
||||
if res.PR.URL != "https://git.test/git/acme/api?ref=agent/abc123def456" {
|
||||
t.Fatalf("the address did not come back with the run: %q", res.PR.URL)
|
||||
}
|
||||
if res.CommitSha != "verifiedsha" {
|
||||
t.Fatalf("the tip must come from git's own storage, got %q", res.CommitSha)
|
||||
}
|
||||
// ISOLATION: the sandbox is pointed only at THIS org's namespace, and that
|
||||
// survives the crossing rather than being re-derived on the far side.
|
||||
if p.clone != "acme/api" {
|
||||
t.Fatalf("git was asked for %q, want acme/api", p.clone)
|
||||
}
|
||||
if !strings.Contains(run.gotReq.CloneURL, "/acme/api.git") {
|
||||
t.Fatalf("clone url did not survive the crossing: %q", run.gotReq.CloneURL)
|
||||
}
|
||||
if run.gotReq.CredToken != "sk-secret" {
|
||||
t.Fatal("the credential must reach the sandbox unchanged")
|
||||
}
|
||||
// The session opened, streamed and closed — all three ops, all across.
|
||||
if len(p.sessions.opened) != 1 || p.sessions.opened[0].org != "acme" {
|
||||
t.Fatalf("session open did not arrive: %+v", p.sessions.opened)
|
||||
}
|
||||
if len(p.sessions.closes) != 1 || p.sessions.closes[0].status != statusDone {
|
||||
t.Fatalf("session close did not arrive: %+v", p.sessions.closes)
|
||||
}
|
||||
// The event payload is bytes precisely so its shape can cross; a dropped
|
||||
// payload would leave mission-control with empty events.
|
||||
var sawStarted bool
|
||||
for _, e := range p.sessions.events {
|
||||
if e.kind == kindStatus && strings.Contains(e.payload, `"status":"started"`) {
|
||||
sawStarted = true
|
||||
}
|
||||
}
|
||||
if !sawStarted {
|
||||
t.Fatalf("event payloads did not survive the crossing: %+v", p.sessions.events)
|
||||
}
|
||||
}
|
||||
|
||||
// A branch git cannot see fails the run CLOSED across the boundary too: the
|
||||
// integrity gate is the reason the PR exists, and an unreachable git must not
|
||||
// read as a verified push.
|
||||
func TestRun_OverThePlane_UnverifiedRefFilesNoPR(t *testing.T) {
|
||||
p := &peers{
|
||||
sessions: &fakeSessions{id: "sess_abc123def456"},
|
||||
tracker: &fakePR{ref: PRRef{Identifier: "API-8"}},
|
||||
found: false,
|
||||
}
|
||||
servePeers(t, p)
|
||||
|
||||
run := &fakeRunner{result: RunResult{Changed: true, OK: true, CommitSha: "deadbeef"}}
|
||||
res := planeDispatcher(run).Run(context.Background(), Req{
|
||||
Org: "acme", UserID: "u_1", Repo: "api", Prompt: "fix", CredToken: "sk-secret",
|
||||
})
|
||||
|
||||
if res.OK || !strings.Contains(res.Error, "not found in native git") {
|
||||
t.Fatalf("an unverified ref must fail closed, got %+v", res)
|
||||
}
|
||||
if len(p.tracker.inputs) != 0 {
|
||||
t.Fatal("no PR may be filed for a branch git cannot see")
|
||||
}
|
||||
if len(p.sessions.closes) != 1 || p.sessions.closes[0].status != statusError {
|
||||
t.Fatalf("the session must close error: %+v", p.sessions.closes)
|
||||
}
|
||||
}
|
||||
|
||||
// A peer that is not part of the deployment is an honest error, not a run that
|
||||
// proceeds without it. This is the shape the whole path had in production.
|
||||
func TestRun_OverThePlane_MissingPeerFailsHonestly(t *testing.T) {
|
||||
t.Setenv("ZIP_RUNTIME_DIR", t.TempDir()) // nothing listening at all
|
||||
res := planeDispatcher(&fakeRunner{}).Run(context.Background(), Req{
|
||||
Org: "acme", UserID: "u_1", Repo: "api", Prompt: "fix", CredToken: "sk-secret",
|
||||
})
|
||||
if res.OK || res.Error != "git is not available" {
|
||||
t.Fatalf("an absent git must stop the run before it starts, got %+v", res)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package coding
|
||||
|
||||
// progress.go reports a run into a chat thread while it is still running.
|
||||
//
|
||||
// # Build for the stream that exists
|
||||
//
|
||||
// Slack has no server-sent events. There is no socket to push a token down and
|
||||
// no way for a client to subscribe to a run. What the platform actually offers
|
||||
// is chat.update: post one message, get its id, and rewrite that message as
|
||||
// often as you like. So this posts ONE message into the thread and edits it —
|
||||
// the run's status line, in place — instead of the dozen messages a
|
||||
// phase-per-message design would bury the channel under.
|
||||
//
|
||||
// # The engine does not hold the token
|
||||
//
|
||||
// It says "put this text at this address" over the plane, and the process that
|
||||
// owns the workspace's bot credential does the posting (integrations_slack_send,
|
||||
// which takes the org from the caller and never from an argument). The engine
|
||||
// therefore reports into a workspace it cannot otherwise reach: it has no bot
|
||||
// token, so a compromised run cannot post anywhere but the thread it was asked
|
||||
// from, and cannot read that workspace at all.
|
||||
//
|
||||
// # Everything here is best-effort, and that is a decision
|
||||
//
|
||||
// A run's work — the branch, the commit, the PR — has already happened by the
|
||||
// time most of these fire. A failed edit must never fail a run that succeeded,
|
||||
// so every error is swallowed after being counted out of the retry budget. The
|
||||
// opposite choice would let a Slack outage roll back real work.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud/plane"
|
||||
)
|
||||
|
||||
// editBudget bounds how many edits one run spends. A run emits a line per step
|
||||
// and a hostile prompt can make a model emit a great many; without a cap, one
|
||||
// run could issue thousands of chat.update calls and exhaust the workspace's
|
||||
// Slack rate limit for every other feature that shares the token. When the
|
||||
// budget runs out the message simply stops moving — the terminal card still
|
||||
// posts, because that one is reserved below.
|
||||
const editBudget = 60
|
||||
|
||||
// editFloor is the minimum gap between two edits of the same message. Slack's
|
||||
// chat.update tier allows roughly one per second per channel; a run narrating
|
||||
// faster than that gets coalesced rather than throttled, so the thread shows the
|
||||
// LATEST state instead of a backlog of stale ones.
|
||||
const editFloor = time.Second
|
||||
|
||||
// progress is one run's status line in one thread.
|
||||
type progress struct {
|
||||
org, channel, thread string
|
||||
|
||||
mu sync.Mutex
|
||||
ts string // the message being rewritten; empty until the first post lands
|
||||
spent int
|
||||
lastAt time.Time
|
||||
lastMsg string
|
||||
dead bool // a post/edit failed in a way that will not get better
|
||||
}
|
||||
|
||||
// newProgress returns a sink for the given address, or nil when there is no
|
||||
// address — a nil *progress's methods are no-ops, so the caller never branches.
|
||||
func newProgress(org, channel, thread string) *progress {
|
||||
if strings.TrimSpace(channel) == "" {
|
||||
return nil
|
||||
}
|
||||
return &progress{org: org, channel: channel, thread: thread}
|
||||
}
|
||||
|
||||
// watch is the Dispatcher.Watch seam: it turns one mirrored session event into
|
||||
// the message's next state. It reads only the fields it needs and never the
|
||||
// whole payload, and it cannot see a credential because no payload the run
|
||||
// mirrors carries one.
|
||||
func (p *progress) watch(ctx context.Context, kind string, payload []byte) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
line, terminal := renderLine(kind, payload)
|
||||
if line == "" {
|
||||
return
|
||||
}
|
||||
p.set(ctx, line, terminal)
|
||||
}
|
||||
|
||||
// renderLine turns one mirrored session event into the message's next state.
|
||||
// PURE, so what a thread will say about a run is testable without a Slack, and
|
||||
// so the injection rules below are checkable as themselves rather than as a
|
||||
// side effect of posting.
|
||||
//
|
||||
// An unparsable or uninteresting event renders "" and changes nothing: a run
|
||||
// that has already pushed a branch must not be disturbed by a payload it cannot
|
||||
// read. terminal reports the last word, which is always spent even when the
|
||||
// edit budget is gone — a finished run must never leave a thread reading
|
||||
// "working…".
|
||||
func renderLine(kind string, payload []byte) (line string, terminal bool) {
|
||||
var e struct {
|
||||
Step string `json:"step"`
|
||||
Message string `json:"message"`
|
||||
Status string `json:"status"`
|
||||
Branch string `json:"branch"`
|
||||
PR string `json:"pr"`
|
||||
URL string `json:"url"`
|
||||
Error string `json:"error"`
|
||||
Changed bool `json:"changed"`
|
||||
}
|
||||
if json.Unmarshal(payload, &e) != nil {
|
||||
return "", false
|
||||
}
|
||||
// EVERY value below is untrusted. A step name, a log line, a branch and an
|
||||
// error are all derived from model output or from repo content the model
|
||||
// read, and this text is posted into a Slack channel as mrkdwn. Unescaped, a
|
||||
// run could emit `<!channel>` and page a whole workspace, or a link element
|
||||
// and put an arbitrary URL under Hanzo's name. Escaped once, HERE, where the
|
||||
// value meets the markup — not at the transport, which would double-escape
|
||||
// text that was already safe.
|
||||
terminal = e.Status == "done" || e.Status == "error"
|
||||
switch {
|
||||
case e.Status == "error":
|
||||
return ":x: " + esc(firstLine(e.Error)), true
|
||||
case e.Status == "done" && !e.Changed:
|
||||
return ":white_check_mark: No changes were needed.", true
|
||||
case e.Status == "done":
|
||||
l := ":sparkles: Pushed `" + esc(e.Branch) + "`"
|
||||
if e.PR != "" {
|
||||
l += " · PR `" + esc(e.PR) + "`"
|
||||
}
|
||||
// The address goes out BARE. Slack turns a plain URL into a link on its
|
||||
// own, so nothing here has to build `<url|text>` — which is the one markup
|
||||
// element that carries an arbitrary destination, and therefore the one
|
||||
// esc() exists to stop a run from writing. A link nobody had to construct
|
||||
// cannot be constructed by a prompt.
|
||||
if e.URL != "" {
|
||||
l += " " + esc(e.URL)
|
||||
}
|
||||
return l, true
|
||||
case e.Status == "started":
|
||||
return ":hourglass_flowing_sand: Working `" + esc(e.Branch) + "`…", false
|
||||
case kind == kindToolCall && e.Step != "":
|
||||
l := ":gear: " + esc(e.Step)
|
||||
if e.Message != "" {
|
||||
l += " — " + esc(firstLine(e.Message))
|
||||
}
|
||||
return l, false
|
||||
case e.Message != "":
|
||||
return ":speech_balloon: " + esc(firstLine(e.Message)), false
|
||||
}
|
||||
return "", terminal
|
||||
}
|
||||
|
||||
// set moves the message to text, posting it the first time and editing it after.
|
||||
func (p *progress) set(ctx context.Context, text string, terminal bool) {
|
||||
p.mu.Lock()
|
||||
if p.dead || text == p.lastMsg {
|
||||
p.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if !terminal {
|
||||
if p.spent >= editBudget || time.Since(p.lastAt) < editFloor {
|
||||
p.mu.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
p.spent++
|
||||
p.lastAt = time.Now()
|
||||
p.lastMsg = text
|
||||
ts := p.ts
|
||||
p.mu.Unlock()
|
||||
|
||||
// A short, independent deadline: a wedged Slack must not hold a run's step
|
||||
// open, and this call is not on the run's critical path.
|
||||
sctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
out, err := plane.Ask[plane.SlackSendIn, plane.SlackSent](sctx, "integrations", plane.IntegrationsSlackSend,
|
||||
&plane.SlackSendIn{Channel: p.channel, Thread: p.thread, Text: text, Update: ts})
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if err != nil {
|
||||
// One failure is transient; a failure with nothing posted yet means there
|
||||
// is no message to edit and never will be. Stop trying either way once the
|
||||
// budget is gone, so a broken workspace costs a run one call and not sixty.
|
||||
if p.ts == "" {
|
||||
p.dead = true
|
||||
}
|
||||
return
|
||||
}
|
||||
if out != nil && out.TS != "" && p.ts == "" {
|
||||
p.ts = out.TS // first post: remember what to rewrite
|
||||
}
|
||||
}
|
||||
|
||||
// esc neutralizes the three mrkdwn-meaningful characters so agent-derived text
|
||||
// cannot inject a link or a <!channel> broadcast, and caps the length so one
|
||||
// enormous log line cannot become the whole message. & goes first, or the
|
||||
// entities it writes get escaped again by the two that follow.
|
||||
func esc(s string) string {
|
||||
if len(s) > maxLine {
|
||||
s = s[:maxLine] + "…"
|
||||
}
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
return s
|
||||
}
|
||||
|
||||
// maxLine bounds one interpolated value. Slack truncates a long message anyway;
|
||||
// this makes the truncation ours, so the status line stays readable and a
|
||||
// hostile prompt cannot push the run's actual state off the end of it.
|
||||
const maxLine = 300
|
||||
+15
-17
@@ -47,8 +47,8 @@ func (g *fakeGate) gate(_ context.Context, org, target string) error {
|
||||
func routedDispatcher(sess *fakeSessions, run *fakeRunner, router *fakeRouter, gate *fakeGate) (Dispatcher, *[]string) {
|
||||
var cloneCalls []string
|
||||
d := Dispatcher{
|
||||
Sessions: sess, Tracker: &fakeTracker{}, Runner: run,
|
||||
CloneURL: func(org, repo string) string {
|
||||
Sessions: sess, PR: &fakePR{}, Runner: run,
|
||||
CloneURL: func(_ context.Context, org, repo string) string {
|
||||
cloneCalls = append(cloneCalls, org+"/"+repo)
|
||||
return "https://git.test/v1/git/" + org + "/" + repo + ".git"
|
||||
},
|
||||
@@ -220,8 +220,10 @@ func TestRun_RoutedButRoutingUnwired_FailsClosed(t *testing.T) {
|
||||
sess := &fakeSessions{id: "sess_x"}
|
||||
run := &fakeRunner{}
|
||||
// No Route / TargetGate seams.
|
||||
d := Dispatcher{Sessions: sess, Tracker: &fakeTracker{}, Runner: run,
|
||||
CloneURL: func(org, repo string) string { return "https://git.test/v1/git/" + org + "/" + repo + ".git" }}
|
||||
d := Dispatcher{Sessions: sess, PR: &fakePR{}, Runner: run,
|
||||
CloneURL: func(_ context.Context, org, repo string) string {
|
||||
return "https://git.test/v1/git/" + org + "/" + repo + ".git"
|
||||
}}
|
||||
|
||||
res := d.Run(context.Background(), routedReq())
|
||||
if res.OK || !strings.Contains(res.Error, "routing is not available") {
|
||||
@@ -234,9 +236,9 @@ func TestRun_RoutedButRoutingUnwired_FailsClosed(t *testing.T) {
|
||||
|
||||
// ---- routed completion parity (#48 I2): verify + PR + session close ----
|
||||
|
||||
func finalizeDispatcher(sess *fakeSessions, tr *fakeTracker, verifyOK bool) Dispatcher {
|
||||
func finalizeDispatcher(sess *fakeSessions, tr *fakePR, verifyOK bool) Dispatcher {
|
||||
return Dispatcher{
|
||||
Sessions: sess, Tracker: tr,
|
||||
Sessions: sess, PR: tr,
|
||||
VerifyRef: func(_ context.Context, _, _, _ string) (string, bool) {
|
||||
if verifyOK {
|
||||
return "verifiedsha", true
|
||||
@@ -250,7 +252,7 @@ func finalizeDispatcher(sess *fakeSessions, tr *fakeTracker, verifyOK bool) Disp
|
||||
// side completion as the local path: the PR is filed and the session is closed done.
|
||||
func TestFinalizeRouted_ChangedVerifyPasses_FilesPR_ClosesDone(t *testing.T) {
|
||||
sess := &fakeSessions{}
|
||||
tr := &fakeTracker{ref: PRRef{Identifier: "API-9"}}
|
||||
tr := &fakePR{ref: PRRef{Identifier: "API-9"}}
|
||||
d := finalizeDispatcher(sess, tr, true)
|
||||
in := RoutedRun{Org: "acme", SessionID: "sess_r", Repo: "api", Base: "main", Branch: "agent/r", Prompt: "add a test", Actor: "u-1", AgentRef: "hanzo"}
|
||||
|
||||
@@ -282,7 +284,7 @@ func TestFinalizeRouted_ChangedVerifyPasses_FilesPR_ClosesDone(t *testing.T) {
|
||||
// — trust the tips we can read, not the machine's self-report.
|
||||
func TestFinalizeRouted_VerifyFails_NoPR_ClosesError(t *testing.T) {
|
||||
sess := &fakeSessions{}
|
||||
tr := &fakeTracker{}
|
||||
tr := &fakePR{}
|
||||
d := finalizeDispatcher(sess, tr, false) // verify fails
|
||||
in := RoutedRun{Org: "acme", SessionID: "s", Repo: "api", Branch: "agent/r"}
|
||||
|
||||
@@ -299,7 +301,7 @@ func TestFinalizeRouted_VerifyFails_NoPR_ClosesError(t *testing.T) {
|
||||
// A routed run that reported NO changes closes the session done with no PR.
|
||||
func TestFinalizeRouted_NoChanges_NoPR_ClosesDone(t *testing.T) {
|
||||
sess := &fakeSessions{}
|
||||
tr := &fakeTracker{}
|
||||
tr := &fakePR{}
|
||||
d := finalizeDispatcher(sess, tr, true)
|
||||
d.finalizeRouted(context.Background(), RoutedRun{Org: "acme", SessionID: "s", Repo: "api"}, RoutedResult{OK: true, Changed: false})
|
||||
|
||||
@@ -315,9 +317,9 @@ func TestFinalizeRouted_NoChanges_NoPR_ClosesDone(t *testing.T) {
|
||||
// even VerifyRef is never consulted (there is nothing to verify).
|
||||
func TestFinalizeRouted_ReportedError_ClosesError_NoPR(t *testing.T) {
|
||||
sess := &fakeSessions{}
|
||||
tr := &fakeTracker{}
|
||||
tr := &fakePR{}
|
||||
verifyCalled := false
|
||||
d := Dispatcher{Sessions: sess, Tracker: tr, VerifyRef: func(context.Context, string, string, string) (string, bool) {
|
||||
d := Dispatcher{Sessions: sess, PR: tr, VerifyRef: func(context.Context, string, string, string) (string, bool) {
|
||||
verifyCalled = true
|
||||
return "", true
|
||||
}}
|
||||
@@ -351,11 +353,7 @@ func TestNewDispatcher_WiresRoutedFinalizer(t *testing.T) {
|
||||
prev := routedFinalizer
|
||||
t.Cleanup(func() { routedFinalizer = prev })
|
||||
routedFinalizer = nil
|
||||
_ = NewDispatcher(
|
||||
func(_, _ string) string { return "https://git.test" },
|
||||
func(context.Context, string, string, string) (string, bool) { return "", true },
|
||||
nil,
|
||||
)
|
||||
_ = NewDispatcher(nil)
|
||||
if routedFinalizer == nil {
|
||||
t.Fatal("NewDispatcher must wire the routed completion seam (else routed sessions never close)")
|
||||
}
|
||||
@@ -365,7 +363,7 @@ func TestNewDispatcher_WiresRoutedFinalizer(t *testing.T) {
|
||||
// the attribution the completion needs.
|
||||
func TestFinalizeRoutedDurable_BridgesFields(t *testing.T) {
|
||||
sess := &fakeSessions{}
|
||||
tr := &fakeTracker{ref: PRRef{Identifier: "API-1"}}
|
||||
tr := &fakePR{ref: PRRef{Identifier: "API-1"}}
|
||||
d := finalizeDispatcher(sess, tr, true)
|
||||
d.finalizeRoutedDurable(context.Background(),
|
||||
agents.RoutedRun{Org: "acme", SessionID: "s", Repo: "api", Branch: "agent/b", AgentRef: "hanzo", Actor: "u-9"},
|
||||
|
||||
@@ -0,0 +1,645 @@
|
||||
package coding
|
||||
|
||||
// sandboxrunner.go runs a coding task in OUR sandbox — a gVisor pod in
|
||||
// hanzo-sandboxes — reached over the internal plane.
|
||||
//
|
||||
// # Why this exists
|
||||
//
|
||||
// The Runner seam had exactly one implementation, and it could not run. It
|
||||
// POSTed to bot's /v1/coding-tasks, which invokes the `docker` CLI —
|
||||
// bot-gateway has neither that binary nor a socket, so every dispatch 503'd.
|
||||
// The whole chain apps/coding → bots.Stream → /v1/coding-tasks was dead in
|
||||
// production while reading as configured.
|
||||
//
|
||||
// The defect was never the ISOLATION. That path asked for --runtime=runsc or
|
||||
// kata-runtime, which is the same gVisor/Kata boundary a sandbox pod gets; the
|
||||
// defect was asking through a CLI that is not installed. apps/sandbox asks the
|
||||
// apiserver for a Pod with runtimeClassName instead — same boundary, a caller
|
||||
// that exists — and it was reachable the whole time with nothing dispatching to
|
||||
// it: a real pod under runsc, digest-pinned, no service-account token, with
|
||||
// hanzo-mcp answering over stdio inside it.
|
||||
//
|
||||
// SANDBOX_RUNTIME_CLASS stays one string (gvisor | kata-fc | kata-clh | empty)
|
||||
// and never a fork in code. That is load-bearing rather than tidy: a benchmark
|
||||
// inverted the expected answer — Firecracker beat gVisor on BOTH axes (git
|
||||
// status 82ms vs 980ms, start 294ms vs 881ms, ~57 MiB either way) — so the
|
||||
// boundary has to be switchable by deployment, not by rewrite.
|
||||
//
|
||||
// # Why the plane and not HTTP
|
||||
//
|
||||
// apps/bots' transport is net/http by its own admission — its doc says the bytes
|
||||
// "should move over ZAP" and that the swap "is meant to be a change to THIS FILE
|
||||
// plus each caller's one stub". This is that stub, and it skips the migration
|
||||
// rather than performing it: a plane op IS ZAP, so there is no HTTP hop to port.
|
||||
// apps/bots keeps its transport for actual bot traffic, which is what it is for.
|
||||
//
|
||||
// # The five ops are the whole vocabulary
|
||||
//
|
||||
// lease → run → end, with read/write for files. They are the same ops the fleet
|
||||
// door now publishes to agents (lease_sandbox, run_in_sandbox, …), so a human
|
||||
// driving a sandbox from chat and this Runner driving one for a coding task are
|
||||
// using ONE surface. A second private path into a sandbox is the duplication
|
||||
// this file exists to avoid.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/plane"
|
||||
)
|
||||
|
||||
// sandboxRunner is the Runner over apps/sandbox.
|
||||
type sandboxRunner struct{}
|
||||
|
||||
// classFor maps a tool to the sandbox class that carries its binary.
|
||||
//
|
||||
// The classes are a CLOSED set — exec, dev, desktop — and the agentic binaries
|
||||
// (dev, hanzo-mcp, claude, codex) live only in dev and desktop. `exec` carries a
|
||||
// bare interpreter, which is why a run asking for an agent got a pod that could
|
||||
// not host one: apps/exec hardcodes exec and nothing consulted the tool.
|
||||
//
|
||||
// desktop is dev plus an X server, so it is selected by the DESKTOP flag rather
|
||||
// than by a tool name — an image variant, never a fourth kind of run.
|
||||
func classFor(tool string, desktop bool) string {
|
||||
if desktop {
|
||||
return "desktop"
|
||||
}
|
||||
switch strings.TrimSpace(tool) {
|
||||
case "python", "node":
|
||||
return "exec" // an interpreter needs no agent harness
|
||||
default:
|
||||
return "dev" // dev | claude | codex | "" all need the toolchain
|
||||
}
|
||||
}
|
||||
|
||||
// argvFor is the command that runs inside the sandbox.
|
||||
//
|
||||
// Each harness names its OWN non-interactive verb, and they do not agree. That
|
||||
// is the whole content of this table: `-p` means print-the-answer to claude and
|
||||
// --profile to dev, so one spelling copied across both is not a style choice,
|
||||
// it is a run that never starts.
|
||||
//
|
||||
// Measured in a dev-class pod (oci.hanzo.ai/hanzoai/sandbox 1.0.1, dev 0.6.91):
|
||||
//
|
||||
// dev -p "say hi" -> Error loading configuration: config profile `say hi` not found
|
||||
// dev exec "say hi" -> runs
|
||||
//
|
||||
// Every coding run cloud has ever dispatched took the first line. The prompt
|
||||
// was read as the name of a config profile, no profile by that name existed,
|
||||
// and the process was gone before a model was ever asked anything.
|
||||
//
|
||||
// TWO FLAGS, AND NEITHER IS A PREFERENCE.
|
||||
//
|
||||
// - --full-auto is `-a on-failure --sandbox workspace-write`: nobody is at a
|
||||
// terminal to approve a command, so a run that asks is a run that hangs.
|
||||
// - --skip-git-repo-check because CloneURL is optional. dev refuses to run
|
||||
// outside a git repo to protect a laptop from itself; the pod is not a
|
||||
// laptop, and the checkout is the thing it was given rather than the thing
|
||||
// it wandered into.
|
||||
//
|
||||
// dev keeps its own workspace-write sandbox on TOP of the pod's isolation. It
|
||||
// is not the boundary — runtimeClass is — but it costs nothing, it was measured
|
||||
// to work under gVisor, and a harness that confines its own writes is one fewer
|
||||
// way for a bad turn to reach the checkout.
|
||||
//
|
||||
// `--` ENDS THE OPTIONS, AND IT IS THE SECURITY-CARRYING TOKEN HERE.
|
||||
//
|
||||
// The prompt is caller text in an argv position, so a prompt that BEGINS WITH A
|
||||
// DASH is not a prompt — it is a flag to the harness. Measured in the same pod,
|
||||
// all three agree:
|
||||
//
|
||||
// dev exec --full-auto "--version" -> hanzo dev-exec 0.6.83 (printed, exited)
|
||||
// codex exec "--version" -> codex-cli-exec 0.146.1
|
||||
// claude -p "--version" -> 2.1.223 (Claude Code)
|
||||
//
|
||||
// None of those ran a task. The reachable end of that is worse than a wasted
|
||||
// run: dev's `-c key=value` overrides its own config, including the sandbox
|
||||
// policy it was just given, so the text a caller sends could choose how much of
|
||||
// the box the model is allowed to touch. With `--` the same string is the
|
||||
// prompt and nothing else. python3 -c / node -e need none: there the prompt is
|
||||
// the VALUE of a flag, which is already a position no parser reinterprets.
|
||||
func argvFor(tool, prompt string) []string {
|
||||
switch strings.TrimSpace(tool) {
|
||||
case "claude":
|
||||
return []string{"claude", "-p", "--", prompt}
|
||||
case "codex":
|
||||
return []string{"codex", "exec", "--", prompt}
|
||||
case "python":
|
||||
return []string{"python3", "-c", prompt}
|
||||
case "node":
|
||||
return []string{"node", "-e", prompt}
|
||||
default:
|
||||
return []string{"dev", "exec", "--full-auto", "--skip-git-repo-check", "--", prompt}
|
||||
}
|
||||
}
|
||||
|
||||
// tools is the closed set a caller may name, and it is closed for the reason
|
||||
// every other set in this package is: `default:` in the two switches above
|
||||
// reads dev, so an unknown name did not fail — it silently ran a DIFFERENT
|
||||
// harness than the one that was asked for, and answered as though it had run
|
||||
// the right one. A typo is refused at the door instead.
|
||||
var tools = map[string]bool{"dev": true, "claude": true, "codex": true, "python": true, "node": true}
|
||||
|
||||
// CheckTool refuses a harness we do not carry. Empty is not a request, so it is
|
||||
// not an error — it is dev, which is what the whole default path already is.
|
||||
func CheckTool(tool string) error {
|
||||
if tool = strings.TrimSpace(tool); tool == "" || tools[tool] {
|
||||
return nil
|
||||
}
|
||||
names := make([]string, 0, len(tools))
|
||||
for t := range tools {
|
||||
names = append(names, t)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return fmt.Errorf("coding: %q is not a harness we run (%s)", tool, strings.Join(names, ", "))
|
||||
}
|
||||
|
||||
// Run leases a sandbox, does the work in it, and ends the lease.
|
||||
//
|
||||
// The lease is ended on EVERY exit including a panic-free error path, because a
|
||||
// sandbox that outlives its run is the orphan class we spent a day clearing —
|
||||
// and the TTL backstop (exec 900s, dev/desktop 14400s) is a floor for crashes,
|
||||
// never a substitute for saying goodbye.
|
||||
func (sandboxRunner) Run(ctx context.Context, org, userID string, req RunRequest, onStep func(Step)) (RunResult, error) {
|
||||
step := func(name, msg, status string) {
|
||||
if onStep != nil {
|
||||
onStep(Step{Type: "step", Step: name, Message: msg, Status: status})
|
||||
}
|
||||
}
|
||||
|
||||
// THE ONE REF THIS RUN MAY WRITE, checked before anything is leased. The name
|
||||
// is cloud's (BranchFor, a pure function of the session), the forge refuses
|
||||
// anything else independently (apps/git/refpolicy.go), and this is the third
|
||||
// statement of the same rule at the only other place that could break it — the
|
||||
// process holding the credential. A run handed `main` stops here rather than
|
||||
// discovering at push time that it was never allowed.
|
||||
branch := strings.TrimSpace(req.Branch)
|
||||
if strings.TrimSpace(req.CloneURL) != "" && !strings.HasPrefix(branch, agentPrefix) {
|
||||
return RunResult{}, fmt.Errorf("coding: %q is not an agent branch", branch)
|
||||
}
|
||||
|
||||
class := classFor(req.Tool, req.Desktop)
|
||||
ttl := req.RunTimeoutSeconds
|
||||
if ttl <= 0 {
|
||||
ttl = 3600
|
||||
}
|
||||
|
||||
// COMPUTE IS NOT FREE, and the gate is here rather than after the work
|
||||
// because a lease holds a pod whether or not anyone can pay for it.
|
||||
if err := affordable(ctx, org); err != nil {
|
||||
return RunResult{}, fmt.Errorf("coding: %w", err)
|
||||
}
|
||||
|
||||
step("lease", "leasing a "+class+" sandbox", "running")
|
||||
// NO PROJECT, and that is the whole of it.
|
||||
//
|
||||
// A project NAMES A DISK. apps/sandbox keys one 20Gi PVC per (org, project) so a
|
||||
// second lease on the same project finds the first disk and a checkout survives
|
||||
// between sessions — which is right, and which is why a disk is KEPT unless
|
||||
// somebody explicitly purges it.
|
||||
//
|
||||
// This run used to pass its SESSION id as the project. A session is opened once
|
||||
// per dispatch and never reopened, so that name could not be asked for twice:
|
||||
// every run minted a disk that was by construction unreusable, and the
|
||||
// deliberate keep-by-default then made it immortal. 20Gi a run, and DO bills a
|
||||
// volume whether or not anything is attached to it.
|
||||
//
|
||||
// The fix is not to purge harder. A purge on the way out still strands the disk
|
||||
// of any run killed, OOM'd, or evicted between the lease and the goodbye. The
|
||||
// fix is to stop asking for the wrong primitive: a run wants scratch space that
|
||||
// dies with its pod, and that is EXACTLY what an emptyDir is — which is what
|
||||
// runtime.go mounts whenever there is no volume. Its lifetime is the pod's by
|
||||
// construction, so there is nothing to remember to delete and nothing to leak.
|
||||
//
|
||||
// The work does not live on that disk anyway: deliver() commits it and pushes it
|
||||
// to the run's ref, which is the only reason anything survives a run at all.
|
||||
leased, err := plane.Ask[plane.LeaseIn, plane.Leased](ctx, "sandboxes", plane.SandboxLease,
|
||||
&plane.LeaseIn{Class: class, TTLSec: ttl})
|
||||
if err != nil {
|
||||
return RunResult{}, fmt.Errorf("coding: lease sandbox: %w", err)
|
||||
}
|
||||
if leased == nil || strings.TrimSpace(leased.ID) == "" {
|
||||
return RunResult{}, fmt.Errorf("coding: lease sandbox: no id")
|
||||
}
|
||||
id := leased.ID
|
||||
|
||||
// THE SANDBOX'S ID IS SAID OUT LOUD, because it is the handle for the only two
|
||||
// things a person watching a run can do to it: stop the work (stop_run) or
|
||||
// release it (end_sandbox). A run that never named its sandbox could be watched
|
||||
// and not touched.
|
||||
step("leased", "sandbox "+id+" ("+class+")", "running")
|
||||
|
||||
// END ON EVERY PATH. A detached context because the caller's may already be
|
||||
// cancelled by the time we unwind — the lease still has to be released, and
|
||||
// releasing it is not the caller's deadline to spend.
|
||||
defer func() {
|
||||
end, cancel := context.WithTimeout(cloud.For(context.Background(), org), 30*time.Second)
|
||||
defer cancel()
|
||||
_, _ = plane.Ask[plane.EndIn, struct{}](end, "sandboxes", plane.SandboxEnd,
|
||||
&plane.EndIn{ID: id})
|
||||
// Said AFTER the lease is actually gone, so "ended" means the pod is
|
||||
// released and not that we intended to release it.
|
||||
step("ended", "released sandbox "+id, "running")
|
||||
}()
|
||||
|
||||
// The checkout, when there is one. No repo means no clone and no credential —
|
||||
// the request shape already guarantees the second (CredToken must be empty
|
||||
// when CloneURL is), so there is nothing to strip here.
|
||||
in := sandbox{ctx: ctx, id: id, ttl: ttl, session: req.SessionID,
|
||||
token: req.CredToken, basic: basicOf(req)}
|
||||
base := ""
|
||||
if u := strings.TrimSpace(req.CloneURL); u != "" {
|
||||
step("clone", "cloning "+u, "running")
|
||||
// The clone narrates into the session like everything else. It is the step
|
||||
// that most often hangs — a big repo, a slow forge — and a watcher seeing
|
||||
// git count objects knows the difference between slow and stuck.
|
||||
if _, err := in.do(cloneArgv(req), "clone"); err != nil {
|
||||
return RunResult{}, err
|
||||
}
|
||||
// The run's branch exists BEFORE the first edit, so a tool that commits for
|
||||
// itself commits onto the run's branch and never onto the base.
|
||||
if _, err := in.do([]string{"git", "switch", "-c", branch}, "branch"); err != nil {
|
||||
return RunResult{}, err
|
||||
}
|
||||
var err error
|
||||
if base, err = in.tip(); err != nil {
|
||||
return RunResult{}, err
|
||||
}
|
||||
}
|
||||
|
||||
step(req.Tool, "running the task", "running")
|
||||
// The credential the agent answers the gateway with. Absent on a deployment
|
||||
// that holds no machine identity, and then the run proceeds without one and
|
||||
// fails at the model call with the harness's own message — which is a truer
|
||||
// report than refusing here would be, because everything up to that point
|
||||
// (lease, clone, branch) still happened and is still worth showing.
|
||||
cred, err := key(ctx)
|
||||
if err != nil {
|
||||
return RunResult{}, fmt.Errorf("coding: %w", err)
|
||||
}
|
||||
argv, stdin := argvFor(req.Tool, req.Prompt), ""
|
||||
if cred != "" {
|
||||
argv, stdin = keyed(argv), cred+"\n"
|
||||
}
|
||||
ran, err := runIn(ctx, id, argv, ttl, req.SessionID, stdin)
|
||||
if err != nil {
|
||||
return RunResult{}, fmt.Errorf("coding: run: %w", err)
|
||||
}
|
||||
|
||||
// Branch is deliberately NOT reported. coding.Run does not read it — the branch
|
||||
// is the one cloud issued, and a sandbox that answers a different one is
|
||||
// reporting something it was never asked — so filling it in would only offer a
|
||||
// value nobody may trust.
|
||||
out := RunResult{
|
||||
OK: ran.ExitCode == 0,
|
||||
LogTail: in.scrub(tail(ran.Stdout, ran.Stderr)),
|
||||
}
|
||||
if !out.OK {
|
||||
// A tool that failed produced nothing anyone should review, so its checkout
|
||||
// stays here and dies with the lease. Pushing it would file work against a
|
||||
// branch whose own run says it did not finish.
|
||||
out.Error = fmt.Sprintf("exit %d", ran.ExitCode)
|
||||
step("done", "finished", statusOf(false))
|
||||
return out, nil
|
||||
}
|
||||
if base != "" {
|
||||
if err := in.deliver(req, branch, base, &out, step); err != nil {
|
||||
return RunResult{}, err
|
||||
}
|
||||
}
|
||||
step("done", "finished", statusOf(out.OK))
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ── the work leaves the sandbox ──────────────────────────────────────────────
|
||||
//
|
||||
// Everything above is how a run HAPPENS; this is how it SURVIVES. Without it the
|
||||
// rest is a model talking to itself: the sandbox is per-run and reaped, so an
|
||||
// edit that stays in the checkout is deleted by the lease ending. The Runner did
|
||||
// lease → clone → run → end and threw the work away.
|
||||
//
|
||||
// It is four facts and one rule.
|
||||
//
|
||||
// the branch is the one cloud issued for the session, created before the
|
||||
// first edit.
|
||||
// the change is the difference between two object ids — what we checked out
|
||||
// and what is there now. Not a status parse and not a guess: it is
|
||||
// the only reading that stays true both when a tool leaves the tree
|
||||
// dirty and when it commits for itself, and a run that reports "no
|
||||
// changes" because it never looked is the failure this file was.
|
||||
// the tip is what rev-parse says AFTER the commit, so the sha we report is
|
||||
// one that exists.
|
||||
// the diffstat is measured over exactly that range, so what a reviewer reads and
|
||||
// what was pushed are the same thing.
|
||||
//
|
||||
// THE RULE: the credential is applied to ONE INVOCATION and never recorded.
|
||||
|
||||
// agentPrefix is the machine namespace, spelled as coding's own BranchFor builds
|
||||
// it. apps/git states the same rule over full refs (refpolicy.go agentRefPrefix)
|
||||
// and the two are deliberately independent: this one keeps an honest run inside
|
||||
// its lane, that one keeps a compromised one there.
|
||||
const agentPrefix = "agent/"
|
||||
|
||||
// Who the commit is by. An agent is not a person and does not borrow one: the
|
||||
// author of a run's commit is the run's harness, and the human who asked for it
|
||||
// is on the PR and in the session.
|
||||
const (
|
||||
authorName = "hanzo-agent"
|
||||
authorEmail = "agent@hanzo.ai"
|
||||
)
|
||||
|
||||
// sandbox is one leased sandbox, addressed. It carries what every command of a
|
||||
// run needs — where, for how long, who is watching, and the grant that must never
|
||||
// come back out — so no call site repeats them and no error path can forget the
|
||||
// last one.
|
||||
type sandbox struct {
|
||||
ctx context.Context
|
||||
id string
|
||||
ttl int
|
||||
session string
|
||||
// Both spellings of the grant, because both exist: the token as the forge
|
||||
// issued it, and the base64 git presents it as. Scrubbing one and not the
|
||||
// other would leave the credential in the output in the only form that
|
||||
// actually travelled.
|
||||
token, basic string
|
||||
}
|
||||
|
||||
// deliver commits what the tool left behind, pushes it to the run's ref, and
|
||||
// fills in what actually happened.
|
||||
//
|
||||
// The commit's EXIT CODE IS NOT READ. `git commit` with nothing staged fails, and
|
||||
// that failure is indistinguishable from a tool that had already committed — so
|
||||
// the question is answered by the sha instead, which is a fact rather than an
|
||||
// opinion about one. When the tip has not moved, nothing changed, and nothing is
|
||||
// pushed: a PR against a branch with no commits teaches a reviewer to ignore the
|
||||
// agent, which is worse than the missing PR it replaces.
|
||||
func (in sandbox) deliver(req RunRequest, branch, base string, out *RunResult, step func(name, msg, status string)) error {
|
||||
if _, err := in.do([]string{"git", "add", "-A"}, "stage"); err != nil {
|
||||
return err
|
||||
}
|
||||
// The identity rides the invocation for the same reason the credential does:
|
||||
// `git -c` before a subcommand is not written into the repository's config.
|
||||
if _, err := runIn(in.ctx, in.id, []string{"git",
|
||||
"-c", "user.name=" + authorName, "-c", "user.email=" + authorEmail,
|
||||
"commit", "--quiet", "-m", message(req.Prompt, in.session)}, in.ttl, in.session, ""); err != nil {
|
||||
return fmt.Errorf("coding: commit: %w", err)
|
||||
}
|
||||
|
||||
tip, err := in.tip()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tip == base {
|
||||
step("done", "no changes were needed", "running")
|
||||
return nil // and Changed stays false, which is now a measurement
|
||||
}
|
||||
|
||||
stat, err := in.do([]string{"git", "diff", "--stat", base, tip}, "diff")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
step("push", "pushing "+branch, "running")
|
||||
if _, err := in.do(pushArgv(req, branch), "push"); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Changed, out.CommitSha, out.Diffstat = true, tip, strings.TrimSpace(stat.Stdout)
|
||||
return nil
|
||||
}
|
||||
|
||||
// cloneArgv checks out the repo. The credential is a per-invocation rewrite, so
|
||||
// what git records as the remote is the plain URL and the grant is gone the
|
||||
// moment the process is.
|
||||
func cloneArgv(req RunRequest) []string {
|
||||
argv := append(gitAs(req), "clone", "--depth", "1")
|
||||
if b := strings.TrimSpace(req.BaseBranch); b != "" {
|
||||
argv = append(argv, "-b", b)
|
||||
}
|
||||
return append(argv, req.CloneURL, ".")
|
||||
}
|
||||
|
||||
// pushArgv writes ONE ref, named in full, and never forces.
|
||||
//
|
||||
// The refspec is explicit rather than `push origin <branch>` because the local
|
||||
// name and the remote ref are then the same string we checked and the grant
|
||||
// names, with no configured remote and no push.default in between to resolve it
|
||||
// into something else.
|
||||
func pushArgv(req RunRequest, branch string) []string {
|
||||
return append(gitAs(req), "push", req.CloneURL, "HEAD:refs/heads/"+branch)
|
||||
}
|
||||
|
||||
// gitAs is `git`, carrying the grant when there is one — as an Authorization
|
||||
// header CONFINED to the one URL it is for, applied to this invocation alone.
|
||||
//
|
||||
// Three properties, each of which cost a bug to learn. All three were verified
|
||||
// against git 2.43 with a server that answers like the forge.
|
||||
//
|
||||
// IT ARRIVES. The credential used to ride the clone URL, and git does not send a
|
||||
// URL-embedded credential until it is CHALLENGED: it makes an anonymous request
|
||||
// first and waits for 401 WWW-Authenticate. The forge answers a caller it does
|
||||
// not recognise with 403 (smart_http.go resolvePackRepo), never 401 — so the
|
||||
// grant was never sent at all, and a clone of any private repo could only 403.
|
||||
// Presented as a header it is on the FIRST request, which is the same thing
|
||||
// apps/git's own wire test does and the reason that test passes.
|
||||
//
|
||||
// IT DOES NOT PERSIST. `git -c` before the subcommand is not written into the new
|
||||
// repository's config — unlike `git clone -c`, which is — so the checkout the
|
||||
// model then edits holds no credential. The URL form wrote one into .git/config
|
||||
// verbatim, where every later step of the run could read it, including the step
|
||||
// that executes untrusted model output.
|
||||
//
|
||||
// IT GOES NOWHERE ELSE. The key is scoped to the URL, so git attaches the header
|
||||
// to that repository and to nothing else. A bare http.extraHeader is sent to
|
||||
// WHATEVER the command reaches — a redirect, another host — and what a repository
|
||||
// makes git reach is chosen by whoever wrote the repository.
|
||||
//
|
||||
// One function, because clone and push are the same act — reach that URL as this
|
||||
// bearer — and a second spelling is a second place to get it wrong.
|
||||
func gitAs(req RunRequest) []string {
|
||||
if req.CredToken == "" || !strings.HasPrefix(req.CloneURL, "https://") {
|
||||
return []string{"git"}
|
||||
}
|
||||
return []string{"git", "-c", "http." + req.CloneURL + ".extraHeader=Authorization: Basic " + basicOf(req)}
|
||||
}
|
||||
|
||||
// basicOf is the grant as git presents it: base64 of user:token. The user is
|
||||
// ignored by the forge — the password is the whole credential — but basic auth
|
||||
// has a shape and it has to be filled.
|
||||
func basicOf(req RunRequest) string {
|
||||
user := req.CredUser
|
||||
if user == "" {
|
||||
user = "x-access-token"
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString([]byte(user + ":" + req.CredToken))
|
||||
}
|
||||
|
||||
// tip reads the checkout's current commit.
|
||||
func (in sandbox) tip() (string, error) {
|
||||
ran, err := in.do([]string{"git", "rev-parse", "HEAD"}, "read the tip")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(ran.Stdout), nil
|
||||
}
|
||||
|
||||
// do is a command whose failure is the RUN's failure.
|
||||
//
|
||||
// runIn answers a non-zero exit as data, deliberately — "the tests failed" and
|
||||
// "the sandbox is broken" are different facts. For the commands here they are the
|
||||
// same fact: a clone that did not clone or a push that did not push leaves
|
||||
// nothing to report, so the exit code is read and the run stops.
|
||||
func (in sandbox) do(argv []string, what string) (*plane.Ran, error) {
|
||||
ran, err := runIn(in.ctx, in.id, argv, in.ttl, in.session, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("coding: %s: %s", what, in.scrub(err.Error()))
|
||||
}
|
||||
if ran.ExitCode != 0 {
|
||||
return nil, fmt.Errorf("coding: %s: exit %d: %s", what, ran.ExitCode,
|
||||
in.scrub(strings.TrimSpace(tail(ran.Stdout, ran.Stderr))))
|
||||
}
|
||||
return ran, nil
|
||||
}
|
||||
|
||||
// message is what the commit says: the task, and the run that did it.
|
||||
//
|
||||
// The session is in the trailer because the commit outlives every other record of
|
||||
// the run — the Slack thread scrolls away, the sandbox is reaped — and a commit
|
||||
// nobody can trace back to the conversation that asked for it is an orphan in
|
||||
// somebody's history.
|
||||
func message(prompt, session string) string {
|
||||
m := firstLine(prompt)
|
||||
if len(m) > maxTitlePrompt {
|
||||
m = strings.TrimSpace(m[:maxTitlePrompt]) + "…"
|
||||
}
|
||||
if m == "" {
|
||||
m = "agent changes"
|
||||
}
|
||||
return m + "\n\nRun: " + session + "\n"
|
||||
}
|
||||
|
||||
// scrub takes the grant out of anything that leaves the sandbox.
|
||||
//
|
||||
// git redacts the userinfo from the URLs it prints — verified, including through
|
||||
// an insteadOf rewrite — and this does not rely on that. Everything here becomes a
|
||||
// session event, a Slack line, a log field and a span; a credential is one string
|
||||
// away from all four, and being sure costs four lines. It hangs off the sandbox
|
||||
// rather than sitting loose so that every path out of a command goes through it
|
||||
// without anyone having to remember.
|
||||
func (in sandbox) scrub(s string) string {
|
||||
if strings.TrimSpace(in.token) == "" {
|
||||
return s
|
||||
}
|
||||
return strings.NewReplacer(in.token, redacted, in.basic, redacted).Replace(s)
|
||||
}
|
||||
|
||||
const redacted = "[redacted]"
|
||||
|
||||
// runIn runs one command in the sandbox and narrates it into the run's session.
|
||||
//
|
||||
// The session travels with the command rather than the output travelling back
|
||||
// here, because the bytes are IN THE SANDBOX and this call does not return until
|
||||
// the command is over. Streamed from where they are produced, a twenty-five
|
||||
// minute agent edit loop is watchable; collected here, it is a silence.
|
||||
func runIn(ctx context.Context, id string, argv []string, ttl int, session, stdin string) (*plane.Ran, error) {
|
||||
ran, err := plane.Ask[plane.RunIn, plane.Ran](ctx, "sandboxes", plane.SandboxRun,
|
||||
&plane.RunIn{ID: id, Argv: argv, TimeoutSec: ttl, Session: session, Stdin: stdin})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ran == nil {
|
||||
return nil, fmt.Errorf("no result")
|
||||
}
|
||||
return ran, nil
|
||||
}
|
||||
|
||||
// tail keeps the end of the output, which is where a failure says why. The cap
|
||||
// is a Slack message's worth: this is read by a person in a thread, not archived.
|
||||
func tail(stdout, stderr string) string {
|
||||
s := stdout
|
||||
if strings.TrimSpace(stderr) != "" {
|
||||
s = strings.TrimRight(s, "\n") + "\n" + stderr
|
||||
}
|
||||
const cap = 3000
|
||||
if len(s) > cap {
|
||||
return "…" + s[len(s)-cap:]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func statusOf(ok bool) string {
|
||||
if ok {
|
||||
return "ok"
|
||||
}
|
||||
return "error"
|
||||
}
|
||||
|
||||
// ── the funded-account gate ──────────────────────────────────────────────────
|
||||
|
||||
// minFundedSeconds is how much compute an org must be able to pay for BEFORE a
|
||||
// sandbox is leased.
|
||||
//
|
||||
// Fifteen minutes, and the number is the argument: a lease is not a request, it
|
||||
// is a pod held on our nodes for as long as the run wants it — dev and desktop
|
||||
// carry a 4-HOUR TTL. Charging afterwards only works if there is something to
|
||||
// charge, so the check has to happen before the pod exists. Fifteen minutes is
|
||||
// long enough that no honest run is refused for a rounding error, and short
|
||||
// enough that an unfunded account cannot open a four-hour hole and walk away.
|
||||
const minFundedSeconds = 15 * 60
|
||||
|
||||
// minFundedDecimal is minFundedSeconds of compute as money — the amount the gate
|
||||
// is asked to authorize.
|
||||
//
|
||||
// A constant rather than a catalog read on purpose: this figure exists to refuse
|
||||
// an EMPTY account, not to quote a bill. The real charge is metered as the lease
|
||||
// runs, and a gate that tried to predict it exactly would be a second pricing
|
||||
// implementation. It only has to be the right order of magnitude.
|
||||
const minFundedDecimal = "0.04" // ~15 min at ~$0.15/hour
|
||||
|
||||
// affordable asks COMMERCE whether org can pay for minFundedSeconds of compute.
|
||||
//
|
||||
// It asks the gate rather than reading a balance and doing arithmetic here.
|
||||
// finance_authorize is labelled "the prepaid gate" in plane.go and it already
|
||||
// knows things a subtraction does not: spend caps, which is why Verdict carries
|
||||
// NoFunds and CapSpent as SEPARATE bits — one says add money, the other says
|
||||
// wait for the period to roll. A balance comparison in this file would be a
|
||||
// second pricing implementation that can disagree with the first.
|
||||
//
|
||||
// The org is stated on a DETACHED context because commerce takes the org from
|
||||
// the CALLER identity and never from an argument, and a Runner has no inbound
|
||||
// request to carry one. cloud.For is read only where no request is behind the
|
||||
// ctx — on a request-bearing one it is a silent no-op that cost four deploys.
|
||||
//
|
||||
// It fails CLOSED on an unreadable verdict, and plane.go says why in the type
|
||||
// itself: Reason "is an upstream failure the caller must treat as UNKNOWN and
|
||||
// fail closed on, and never read as permission." Failing open costs a pod held
|
||||
// for four hours by an account that cannot pay; failing closed costs a retry.
|
||||
func affordable(ctx context.Context, org string) error {
|
||||
v, err := plane.Ask[plane.AuthorizeIn, plane.Verdict](
|
||||
cloud.For(context.Background(), org), "commerce", plane.FinanceAuthorize,
|
||||
&plane.AuthorizeIn{
|
||||
Subject: org,
|
||||
Amount: plane.Money{Decimal: minFundedDecimal, Currency: "usd"},
|
||||
Service: "sandbox",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("compute is not free and the prepaid gate is unreachable: %w", err)
|
||||
}
|
||||
if v == nil {
|
||||
return fmt.Errorf("compute is not free and the prepaid gate answered nothing")
|
||||
}
|
||||
switch {
|
||||
case v.OK:
|
||||
return nil
|
||||
case v.NoFunds:
|
||||
return fmt.Errorf("a sandbox needs at least %d minutes of funded compute (%s); top up to start a run",
|
||||
minFundedSeconds/60, minFundedDecimal)
|
||||
case v.CapSpent:
|
||||
return fmt.Errorf("this org has spent its cap for the period; a sandbox cannot start until it rolls over")
|
||||
default:
|
||||
return fmt.Errorf("the prepaid gate refused a sandbox: %s", v.Reason)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
package coding
|
||||
|
||||
// sandboxrunner_test.go drives the REAL sandboxRunner against a fake sandbox on a
|
||||
// real socket, and asserts the exact sequence of commands a run sends into the
|
||||
// pod.
|
||||
//
|
||||
// That sequence IS the product. A run whose edits never leave the checkout is a
|
||||
// model talking to itself: the sandbox is reaped when the lease ends and the work
|
||||
// dies with it. So the assertions here are about commands and not about a return
|
||||
// value — a runner can be made to answer Changed:true with no test noticing that
|
||||
// nothing was ever pushed.
|
||||
//
|
||||
// The worse of the two failures is the FALSE POSITIVE. A run that reports "no
|
||||
// changes" when it changed something is a lost afternoon; a run that reports
|
||||
// changes when it made none files a PR against an empty branch, and a reviewer
|
||||
// learns to distrust every PR the agent opens. Both are covered, and the
|
||||
// no-changes case is first.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud/plane"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// theGrant is the push credential a run holds. Spelled once so every assertion
|
||||
// about where it may and may not appear is asking about the same string.
|
||||
const theGrant = "hgg_LIVEGRANTVALUE"
|
||||
|
||||
const (
|
||||
cleanURL = "https://git.test/v1/git/acme/api.git"
|
||||
theSHA = "1111111111111111111111111111111111111111"
|
||||
newSHA = "2222222222222222222222222222222222222222"
|
||||
)
|
||||
|
||||
// pod is a fake sandbox that records every command and answers whatever the test
|
||||
// scripts. It records the argv VERBATIM, because the questions worth asking of
|
||||
// this file are all questions about arguments.
|
||||
type pod struct {
|
||||
mu sync.Mutex
|
||||
ran [][]string
|
||||
answer func(argv []string) plane.Ran
|
||||
// leased is every lease the run asked for, verbatim. A lease is a REQUEST for
|
||||
// resources — a class, a ttl, and whether a disk is wanted — and the only place
|
||||
// that request is visible is here, before it reaches a cluster.
|
||||
leased []plane.LeaseIn
|
||||
}
|
||||
|
||||
func (p *pod) exec(argv []string) plane.Ran {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.ran = append(p.ran, append([]string(nil), argv...))
|
||||
if p.answer == nil {
|
||||
return plane.Ran{}
|
||||
}
|
||||
return p.answer(argv)
|
||||
}
|
||||
|
||||
// lines renders every command as one string, which is how a human reads a
|
||||
// transcript and how these assertions read it too.
|
||||
func (p *pod) lines() []string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
out := make([]string, 0, len(p.ran))
|
||||
for _, argv := range p.ran {
|
||||
out = append(out, strings.Join(argv, " "))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// did returns the first command containing want, and whether there was one.
|
||||
func (p *pod) did(want string) (string, bool) {
|
||||
for _, l := range p.lines() {
|
||||
if strings.Contains(l, want) {
|
||||
return l, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// gitVerb reports whether any command asked git to do verb — matched on the
|
||||
// argv, so "push" the verb is not confused with "push" inside a URL or a prompt.
|
||||
func (p *pod) gitVerb(verb string) bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for _, argv := range p.ran {
|
||||
for i, a := range argv {
|
||||
if a == verb && i > 0 && argv[0] == "git" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// servePod stands up the two peers a sandbox run reaches — the sandbox itself and
|
||||
// the prepaid gate — on real unix sockets, so the runner under test is the
|
||||
// production one with a production transport.
|
||||
func servePod(t *testing.T, p *pod) {
|
||||
t.Helper()
|
||||
t.Setenv("ZIP_RUNTIME_DIR", t.TempDir())
|
||||
|
||||
sandboxes := zip.New(zip.Config{AppName: "sandboxes", DisableStartupMessage: true})
|
||||
zip.Post[plane.LeaseIn, plane.Leased](sandboxes, "/sandbox/lease",
|
||||
func(_ context.Context, in *plane.LeaseIn) (*plane.Leased, error) {
|
||||
p.mu.Lock()
|
||||
p.leased = append(p.leased, *in)
|
||||
p.mu.Unlock()
|
||||
return &plane.Leased{ID: "sbx_1", Class: in.Class, Status: "ready", Workdir: "/work"}, nil
|
||||
}, zip.WithOperationID(plane.SandboxLease))
|
||||
zip.Post[plane.RunIn, plane.Ran](sandboxes, "/sandbox/run",
|
||||
func(_ context.Context, in *plane.RunIn) (*plane.Ran, error) {
|
||||
r := p.exec(in.Argv)
|
||||
return &r, nil
|
||||
}, zip.WithOperationID(plane.SandboxRun))
|
||||
zip.Post[plane.EndIn, struct{}](sandboxes, "/sandbox/end",
|
||||
func(_ context.Context, _ *plane.EndIn) (*struct{}, error) {
|
||||
return &struct{}{}, nil
|
||||
}, zip.WithOperationID(plane.SandboxEnd))
|
||||
|
||||
commerce := zip.New(zip.Config{AppName: "commerce", DisableStartupMessage: true})
|
||||
zip.Post[plane.AuthorizeIn, plane.Verdict](commerce, "/commerce/authorize",
|
||||
func(_ context.Context, _ *plane.AuthorizeIn) (*plane.Verdict, error) {
|
||||
return &plane.Verdict{OK: true}, nil
|
||||
}, zip.WithOperationID(plane.FinanceAuthorize))
|
||||
|
||||
for name, app := range map[string]*zip.App{"sandboxes": sandboxes, "commerce": commerce} {
|
||||
app := app
|
||||
plane.Bind()
|
||||
go func(path string) { _ = app.Listen(path) }(zip.SocketPath(name))
|
||||
t.Cleanup(func() { _ = app.Shutdown() })
|
||||
waitListening(t, name)
|
||||
}
|
||||
}
|
||||
|
||||
// aRun is the request a real dispatch builds: a checkout, the branch cloud issued
|
||||
// for the session, and the grant that may write exactly that one ref.
|
||||
func aRun() RunRequest {
|
||||
return RunRequest{
|
||||
CloneURL: cleanURL, BaseBranch: "main", Branch: "agent/abc123def456",
|
||||
Prompt: "fix the flake", SessionID: "sess_abc123def456",
|
||||
RunTimeoutSeconds: 60, CredUser: "x-access-token", CredToken: theGrant,
|
||||
}
|
||||
}
|
||||
|
||||
// steps collects what the run said out loud, which is what a person watching in
|
||||
// Slack actually reads.
|
||||
func steps(out *[]string) func(Step) {
|
||||
return func(s Step) { *out = append(*out, s.Step+" "+s.Message+" "+s.Status) }
|
||||
}
|
||||
|
||||
// A RUN ASKS FOR NO PROJECT, so it leaves no disk behind.
|
||||
//
|
||||
// A project names a per-(org, project) 20Gi PVC that apps/sandbox deliberately
|
||||
// KEEPS when a lease ends, so a checkout survives between sessions. A run used to
|
||||
// pass its SESSION id there — a name minted per dispatch and never reopened — so
|
||||
// each run created a disk that could never be addressed again and was then kept
|
||||
// forever. That is the whole mechanism of the leak, and it is visible only here,
|
||||
// in what the lease ASKS for; the run's own result looks identical either way.
|
||||
//
|
||||
// The assertion is on Project and not on some later cleanup on purpose. A purge on
|
||||
// the way out cannot cover a run that is killed, OOM'd, or evicted before it says
|
||||
// goodbye, whereas a lease that never asks for a disk cannot strand one. The pod's
|
||||
// emptyDir already has exactly the lifetime a run wants.
|
||||
func TestSandboxRun_LeasesNoDiskBecauseARunIsNotAProject(t *testing.T) {
|
||||
p := &pod{answer: func(argv []string) plane.Ran {
|
||||
if has(argv, "rev-parse") {
|
||||
return plane.Ran{Stdout: theSHA + "\n"}
|
||||
}
|
||||
return plane.Ran{}
|
||||
}}
|
||||
servePod(t, p)
|
||||
|
||||
if _, err := (sandboxRunner{}).Run(context.Background(), "acme", "u_1", aRun(), nil); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if len(p.leased) != 1 {
|
||||
t.Fatalf("a run took %d leases, want exactly 1", len(p.leased))
|
||||
}
|
||||
if got := p.leased[0].Project; got != "" {
|
||||
t.Fatalf("the run asked for project %q, which mints a 20Gi disk keyed to a name "+
|
||||
"no later lease can ever reuse — a run must ask for no project at all", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A run that edited NOTHING says so, and touches no ref. This is the first test
|
||||
// because the false PR is the worse failure: a branch with no commits, filed as
|
||||
// work, teaches a reviewer to ignore the agent.
|
||||
func TestSandboxRun_NoEditsReportNoChangesAndWriteNoRef(t *testing.T) {
|
||||
p := &pod{answer: func(argv []string) plane.Ran {
|
||||
switch {
|
||||
case has(argv, "rev-parse"):
|
||||
return plane.Ran{Stdout: theSHA + "\n"} // the tip never moves
|
||||
case has(argv, "commit"):
|
||||
return plane.Ran{ExitCode: 1, Stdout: "nothing to commit, working tree clean\n"}
|
||||
}
|
||||
return plane.Ran{}
|
||||
}}
|
||||
servePod(t, p)
|
||||
|
||||
var said []string
|
||||
res, err := sandboxRunner{}.Run(context.Background(), "acme", "u_1", aRun(), steps(&said))
|
||||
if err != nil {
|
||||
t.Fatalf("a clean run is not an error: %v", err)
|
||||
}
|
||||
if !res.OK {
|
||||
t.Fatalf("a run that changed nothing still succeeded: %+v", res)
|
||||
}
|
||||
if res.Changed || res.CommitSha != "" || res.Diffstat != "" {
|
||||
t.Fatalf(`"no changes" must mean no changes: %+v`, res)
|
||||
}
|
||||
if p.gitVerb("push") {
|
||||
t.Fatalf("nothing changed and something was pushed:\n%s", strings.Join(p.lines(), "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// A run that edited something commits it, pushes it to its own ref, and reports
|
||||
// what it actually did — the tip it created and the diffstat it measured.
|
||||
func TestSandboxRun_EditsAreCommittedPushedAndReportedHonestly(t *testing.T) {
|
||||
tip := theSHA
|
||||
p := &pod{}
|
||||
p.answer = func(argv []string) plane.Ran {
|
||||
switch {
|
||||
case has(argv, "commit"):
|
||||
tip = newSHA // the commit is what moves it
|
||||
return plane.Ran{}
|
||||
case has(argv, "rev-parse"):
|
||||
return plane.Ran{Stdout: tip + "\n"}
|
||||
case has(argv, "diff"):
|
||||
return plane.Ran{Stdout: " 2 files changed, 9 insertions(+), 1 deletion(-)\n"}
|
||||
}
|
||||
return plane.Ran{}
|
||||
}
|
||||
servePod(t, p)
|
||||
|
||||
var said []string
|
||||
res, err := sandboxRunner{}.Run(context.Background(), "acme", "u_1", aRun(), steps(&said))
|
||||
if err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
if !res.OK || !res.Changed {
|
||||
t.Fatalf("the run changed something and must say so: %+v", res)
|
||||
}
|
||||
if res.CommitSha != newSHA {
|
||||
t.Fatalf("the reported tip is not the one that was created: %q", res.CommitSha)
|
||||
}
|
||||
if !strings.Contains(res.Diffstat, "2 files changed") {
|
||||
t.Fatalf("the diffstat was not measured: %q", res.Diffstat)
|
||||
}
|
||||
|
||||
// THE BRANCH IS THE RUN'S OWN, and it is created before the first edit so
|
||||
// nothing is ever committed onto the base.
|
||||
if _, ok := p.did("switch -c agent/abc123def456"); !ok {
|
||||
t.Fatalf("the run never left the base branch:\n%s", strings.Join(p.lines(), "\n"))
|
||||
}
|
||||
push, ok := p.did(" push ")
|
||||
if !ok {
|
||||
t.Fatalf("nothing was pushed:\n%s", strings.Join(p.lines(), "\n"))
|
||||
}
|
||||
// ONE ref, named in full, never a branch name git could resolve to something
|
||||
// else and never a force.
|
||||
if !strings.HasSuffix(push, "HEAD:refs/heads/agent/abc123def456") {
|
||||
t.Fatalf("the push does not name exactly one ref: %q", push)
|
||||
}
|
||||
for _, forbidden := range []string{"--force", "-f ", "--mirror", "--all", "--delete"} {
|
||||
if strings.Contains(push, forbidden) {
|
||||
t.Fatalf("the push carries %q: %q", forbidden, push)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The branch is the one cloud issued, and the runner will not write anywhere
|
||||
// else even when it is handed a name that says otherwise. The forge refuses this
|
||||
// too (apps/git/refpolicy.go); neither is the other's backstop.
|
||||
func TestSandboxRun_RefusesToWriteOutsideTheAgentNamespace(t *testing.T) {
|
||||
p := &pod{}
|
||||
servePod(t, p)
|
||||
|
||||
req := aRun()
|
||||
req.Branch = "main"
|
||||
r := sandboxRunner{}
|
||||
if _, err := r.Run(context.Background(), "acme", "u_1", req, nil); err == nil {
|
||||
t.Fatal("a run pointed at main must be refused")
|
||||
}
|
||||
if len(p.lines()) != 0 {
|
||||
t.Fatalf("a refused run still did work:\n%s", strings.Join(p.lines(), "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// A tool that failed has produced nothing anyone should review, so its checkout
|
||||
// stays in the sandbox and dies there.
|
||||
func TestSandboxRun_AFailedToolWritesNoRef(t *testing.T) {
|
||||
p := &pod{answer: func(argv []string) plane.Ran {
|
||||
if has(argv, "rev-parse") {
|
||||
return plane.Ran{Stdout: theSHA + "\n"}
|
||||
}
|
||||
if has(argv, "dev") {
|
||||
return plane.Ran{ExitCode: 3, Stderr: "the model gave up\n"}
|
||||
}
|
||||
return plane.Ran{}
|
||||
}}
|
||||
servePod(t, p)
|
||||
|
||||
res, err := sandboxRunner{}.Run(context.Background(), "acme", "u_1", aRun(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("a failed tool is a reported failure, not a transport error: %v", err)
|
||||
}
|
||||
if res.OK || res.Changed {
|
||||
t.Fatalf("a failed run must not report work: %+v", res)
|
||||
}
|
||||
if p.gitVerb("push") {
|
||||
t.Fatalf("a failed run pushed:\n%s", strings.Join(p.lines(), "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// THE GRANT NEVER TOUCHES DISK AND IS NEVER SAID OUT LOUD.
|
||||
//
|
||||
// It may appear in exactly one place — the git option that applies it to a single
|
||||
// invocation — and nowhere else: not as a URL operand (git writes those into
|
||||
// .git/config), not in a config write, and not in a step, a log line or the tail
|
||||
// a run hands back for a person to read.
|
||||
func TestSandboxRun_TheGrantStaysOffDiskAndOutOfWhatIsSaid(t *testing.T) {
|
||||
p := &pod{answer: func(argv []string) plane.Ran {
|
||||
if has(argv, "rev-parse") {
|
||||
return plane.Ran{Stdout: theSHA + "\n"}
|
||||
}
|
||||
return plane.Ran{Stdout: "done\n"}
|
||||
}}
|
||||
servePod(t, p)
|
||||
|
||||
var said []string
|
||||
res, err := sandboxRunner{}.Run(context.Background(), "acme", "u_1", aRun(), steps(&said))
|
||||
if err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
|
||||
// The grant travels as base64, which is how git presents basic auth. Both
|
||||
// spellings are asked about: the literal must appear NOWHERE, and the one
|
||||
// that actually travels must appear only where it is confined.
|
||||
basic := base64.StdEncoding.EncodeToString([]byte("x-access-token:" + theGrant))
|
||||
wantOpt := "http." + cleanURL + ".extraHeader=Authorization: Basic " + basic
|
||||
|
||||
for _, argv := range p.ran {
|
||||
for i, a := range argv {
|
||||
if strings.Contains(a, theGrant) {
|
||||
t.Fatalf("the grant appears verbatim in argv[%d]: %q", i, strings.Join(argv, " "))
|
||||
}
|
||||
if !strings.Contains(a, basic) {
|
||||
continue
|
||||
}
|
||||
// Exactly one shape: an option applied to THIS invocation, scoped to the
|
||||
// ONE url it is for. `git -c` before the subcommand is not written into
|
||||
// the new repository's config, so the checkout the model then edits holds
|
||||
// no credential; the url scope is what stops git attaching it to a
|
||||
// redirect or another host the repository's own contents point at.
|
||||
if i == 0 || argv[i-1] != "-c" || a != wantOpt {
|
||||
t.Fatalf("the credential is in argv[%d] as %q, want the confined option %q", i, a, wantOpt)
|
||||
}
|
||||
}
|
||||
// Nothing may persist it. A `git config` write, a credential helper with a
|
||||
// store behind it, or a redirect into a file each survive the command.
|
||||
line := strings.Join(argv, " ")
|
||||
for _, persists := range []string{"git config", "credential.helper=store", "credential.helper=cache", ".git/config"} {
|
||||
if strings.Contains(line, persists) {
|
||||
t.Fatalf("a command persists a credential: %q", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The URL OPERAND — what git records as the remote — is the clean one.
|
||||
for _, argv := range p.ran {
|
||||
for _, a := range argv {
|
||||
if strings.HasPrefix(a, "https://") && strings.Contains(a, "@") {
|
||||
t.Fatalf("a credential rode a URL operand: %q", a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing a person reads carries it, in either spelling.
|
||||
for _, s := range said {
|
||||
if strings.Contains(s, theGrant) || strings.Contains(s, basic) {
|
||||
t.Fatalf("the grant was narrated: %q", s)
|
||||
}
|
||||
}
|
||||
if strings.Contains(res.LogTail, theGrant) || strings.Contains(res.LogTail, basic) {
|
||||
t.Fatalf("the grant came back in the log tail: %q", res.LogTail)
|
||||
}
|
||||
}
|
||||
|
||||
// A command's output is scrubbed of BOTH spellings on the way out, so a git that
|
||||
// ever echoed what it was given cannot put a live credential in a session event,
|
||||
// a span or a Slack thread.
|
||||
func TestSandboxRun_AnEchoedCredentialIsScrubbedOnTheWayOut(t *testing.T) {
|
||||
basic := base64.StdEncoding.EncodeToString([]byte("x-access-token:" + theGrant))
|
||||
p := &pod{answer: func(argv []string) plane.Ran {
|
||||
if has(argv, "rev-parse") {
|
||||
return plane.Ran{Stdout: theSHA + "\n"}
|
||||
}
|
||||
if has(argv, "dev") {
|
||||
return plane.Ran{Stdout: "used " + theGrant + " and header " + basic + "\n"}
|
||||
}
|
||||
return plane.Ran{}
|
||||
}}
|
||||
servePod(t, p)
|
||||
|
||||
res, err := sandboxRunner{}.Run(context.Background(), "acme", "u_1", aRun(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
if strings.Contains(res.LogTail, theGrant) || strings.Contains(res.LogTail, basic) {
|
||||
t.Fatalf("an echoed credential survived: %q", res.LogTail)
|
||||
}
|
||||
if !strings.Contains(res.LogTail, "[redacted]") {
|
||||
t.Fatalf("nothing was scrubbed: %q", res.LogTail)
|
||||
}
|
||||
}
|
||||
|
||||
func has(argv []string, want string) bool {
|
||||
for _, a := range argv {
|
||||
if a == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// The three properties of argvFor that a run actually dies of, each pinned
|
||||
// against what the harness was MEASURED to do in a dev-class pod
|
||||
// (oci.hanzo.ai/hanzoai/sandbox 1.0.1: dev 0.6.91, claude 2.1.223, codex 0.146.1).
|
||||
func TestArgvForNonInteractiveVerb(t *testing.T) {
|
||||
// `-p` is --profile to dev, not "prompt". `dev -p "<task>"` answered
|
||||
// `config profile "<task>" not found` and exited before asking a model
|
||||
// anything — which is what every coding run cloud dispatched used to do.
|
||||
argv := argvFor("", "add a README")
|
||||
if len(argv) < 2 || argv[0] != "dev" || argv[1] != "exec" {
|
||||
t.Fatalf("the default harness must start with `dev exec`, got %q", argv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArgvForEndsOptionsBeforeThePrompt(t *testing.T) {
|
||||
// A prompt is caller text in an argv position. Without `--`, a prompt that
|
||||
// begins with a dash is a FLAG: all three agent harnesses printed their own
|
||||
// version and exited when handed "--version" as the task.
|
||||
for _, tool := range []string{"", "dev", "claude", "codex"} {
|
||||
argv := argvFor(tool, "--version")
|
||||
last := argv[len(argv)-1]
|
||||
if last != "--version" {
|
||||
t.Fatalf("%s: prompt must be last, got %q", tool, argv)
|
||||
}
|
||||
if argv[len(argv)-2] != "--" {
|
||||
t.Fatalf("%s: prompt must be preceded by `--` or it parses as a flag, got %q", tool, argv)
|
||||
}
|
||||
}
|
||||
// python3 -c / node -e take the prompt as a flag VALUE, which no parser
|
||||
// reinterprets, so they neither need nor take a separator.
|
||||
for tool, flag := range map[string]string{"python": "-c", "node": "-e"} {
|
||||
argv := argvFor(tool, "--version")
|
||||
if len(argv) != 3 || argv[1] != flag || argv[2] != "--version" {
|
||||
t.Fatalf("%s: want [%s %s --version], got %q", tool, argv[0], flag, argv)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckToolRefusesAHarnessWeDoNotCarry(t *testing.T) {
|
||||
// Empty is dev, which is the whole default path, so it is not an error.
|
||||
if err := CheckTool(""); err != nil {
|
||||
t.Fatalf("empty tool must be accepted as dev: %v", err)
|
||||
}
|
||||
for _, ok := range []string{"dev", "claude", "codex", "python", "node"} {
|
||||
if err := CheckTool(ok); err != nil {
|
||||
t.Fatalf("%s is carried by the dev image: %v", ok, err)
|
||||
}
|
||||
}
|
||||
// Without this, `default:` in classFor and argvFor read a typo as dev and
|
||||
// answered as though the asked-for harness had run.
|
||||
if err := CheckTool("clade"); err == nil {
|
||||
t.Fatal("an unknown harness must be refused, not silently run as dev")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassForCarriesTheBinaryTheToolNeeds(t *testing.T) {
|
||||
// Measured `command -v` in each published 1.0.1 class: exec carries dev,
|
||||
// hanzo and hanzo-mcp but NOT claude/codex/gh; dev and desktop carry all.
|
||||
for tool, want := range map[string]string{
|
||||
"": "dev", "dev": "dev", "claude": "dev", "codex": "dev",
|
||||
"python": "exec", "node": "exec",
|
||||
} {
|
||||
if got := classFor(tool, false); got != want {
|
||||
t.Fatalf("classFor(%q): want %s, got %s", tool, want, got)
|
||||
}
|
||||
}
|
||||
// A screen is an image variant, so it wins over every harness — desktop is
|
||||
// dev plus an X server and therefore carries every binary dev does.
|
||||
for _, tool := range []string{"", "dev", "claude", "python"} {
|
||||
if got := classFor(tool, true); got != "desktop" {
|
||||
t.Fatalf("classFor(%q, desktop): want desktop, got %s", tool, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
package coding
|
||||
|
||||
// start.go is the ONE way a coding run begins.
|
||||
//
|
||||
// Every door — the Slack `code:` trigger, `POST /v1/coding`, and anything added
|
||||
// later — arrives here. That is not tidiness: a door that assembled its own
|
||||
// Dispatcher would be a second ENGINE with its own pool and its own in-flight
|
||||
// set, and a run started from chat would be invisible to the app that shares
|
||||
// its name. One Start, one pool, one process.
|
||||
//
|
||||
// # The tenant, and the bug that made every run fail
|
||||
//
|
||||
// A run is a long chain of cross-process calls: open the session (agents), read
|
||||
// the clone URL (git), dispatch the sandbox (bot), verify the pushed ref (git),
|
||||
// file the PR (tracker). Every one of those authorizes on the CALLER's org,
|
||||
// never on an argument, because a caller able to name the org could name
|
||||
// somebody else's.
|
||||
//
|
||||
// So the org has to ride the caller. `cloud.For` states it — but zip reads a
|
||||
// stated caller only where there is NO REQUEST behind the context
|
||||
// (caller.go:352-356), deliberately, so that stating an identity can never
|
||||
// override an authenticated one. Applied to a context that has a request, it is
|
||||
// a SILENT NO-OP.
|
||||
//
|
||||
// The coding path did exactly that. The run was spawned on a bare
|
||||
// context.Background() with no tenant stated at all, and the routed path's
|
||||
// target lookup ran on the inbound webhook's request context, where a statement
|
||||
// would have been discarded anyway. Every seam call in every coding run
|
||||
// therefore answered `authorize: no org on the call`: no session, an empty clone
|
||||
// URL the dispatcher reads as "git is not available", and a run dead before a
|
||||
// model was ever asked anything. The chat turn died of this shape one file over
|
||||
// and was fixed there (bridgeRunContext); this is the same fix for the other
|
||||
// half, and runContext below is the only place a coding run's context is made.
|
||||
//
|
||||
// It is not a laundering hole. For SUPPLIES a tenant where there is none and
|
||||
// cannot override one, and the org it supplies was resolved server-side — from a
|
||||
// Slack-signature-verified team_id, or from the authenticated caller of
|
||||
// /v1/coding — never from a field a client can set.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/plane"
|
||||
)
|
||||
|
||||
const (
|
||||
// runBudget bounds one coding run end to end. Far longer than a chat turn: a
|
||||
// real run clones, drives a model edit loop, runs tests, and pushes.
|
||||
defaultRunBudget = 25 * time.Minute
|
||||
|
||||
// The pool bounds SANDBOXES, not model turns — a different resource with a
|
||||
// different lifetime from the chat pool, which is why it is sized separately.
|
||||
// The per-org cap is the availability isolation: one tenant cannot starve the
|
||||
// others out of sandbox capacity.
|
||||
defaultConcurrency = 8
|
||||
defaultOrgConcurrency = 2
|
||||
|
||||
// agentCredUser is the basic-auth username a git client must send beside the
|
||||
// grant. git ignores it — the password is the whole credential — but it must
|
||||
// be something, and naming it here means the sandbox is not inventing one.
|
||||
agentCredUser = "x-access-token"
|
||||
|
||||
// maxRunBudget is the longest run the engine will admit, whatever a caller
|
||||
// asks for. Without it TimeoutSeconds was unbounded: a caller could hold a
|
||||
// pool slot — one of two its org has — for a day, and ask the forge to
|
||||
// delegate a push for just as long. A budget nobody bounds is a resource
|
||||
// nobody bounds.
|
||||
maxRunBudget = 30 * time.Minute
|
||||
)
|
||||
|
||||
// Accepted is what a door returns the instant a run is admitted. A run takes
|
||||
// minutes; the door answers in milliseconds and hands back the session that is
|
||||
// the run's record, its live stream, and the handle for every later question.
|
||||
type Accepted struct {
|
||||
SessionID string
|
||||
Branch string
|
||||
Repo string
|
||||
Routed bool
|
||||
TargetID string
|
||||
}
|
||||
|
||||
// ErrBusy is the honest refusal when the pool is full. It is separated from a
|
||||
// validation failure because a caller should retry this one and only this one.
|
||||
var ErrBusy = fmt.Errorf("coding: at capacity")
|
||||
|
||||
var (
|
||||
engineOnce sync.Once
|
||||
engine Dispatcher
|
||||
pool *limiter
|
||||
)
|
||||
|
||||
// Engine returns the process's ONE Dispatcher, assembled on first use. log
|
||||
// carries the run's best-effort failures (a dropped session mirror, a PR that
|
||||
// would not file) into the host's log rather than dropping them.
|
||||
func Engine(log func(msg string, kv ...any)) Dispatcher {
|
||||
engineOnce.Do(func() {
|
||||
engine = NewDispatcher(log)
|
||||
pool = newLimiter(concurrency(), orgConcurrency())
|
||||
})
|
||||
return engine
|
||||
}
|
||||
|
||||
// Start admits one coding run and returns its handle.
|
||||
//
|
||||
// org is the CALLER's tenant, read off the caller by the door and never taken
|
||||
// from the request body. Everything the run then does happens in that org and
|
||||
// nowhere else: its session, its repo, its credential, its PR.
|
||||
//
|
||||
// It is synchronous up to the point the run is admitted — validate, resolve the
|
||||
// credential, open the session — and detached after it. That split is what lets
|
||||
// a door answer immediately with a real handle instead of an empty promise, and
|
||||
// it is why the session is opened HERE rather than inside Run.
|
||||
func Start(ctx context.Context, org string, in plane.CodingStartIn, log func(msg string, kv ...any)) (Accepted, error) {
|
||||
org = strings.TrimSpace(org)
|
||||
if !OrgRE.MatchString(org) {
|
||||
// Shape-checked, not merely non-empty. The org becomes a git namespace and
|
||||
// the tenant every seam call authorizes on, so an org carrying a separator
|
||||
// or a dot segment would address another tenant. It arrives from the
|
||||
// gateway or the plane already validated; this is the second lock, on the
|
||||
// side that would actually be harmed if the first ever failed.
|
||||
return Accepted{}, fmt.Errorf("coding: a run needs a tenant")
|
||||
}
|
||||
subject := strings.TrimSpace(in.Subject)
|
||||
if subject == "" {
|
||||
// A run that lost its human must not execute AS THE ORG: that bills the
|
||||
// tenant for an unattributable act and hands an unlinked caller the org's
|
||||
// agent and its repos. Refused, never defaulted.
|
||||
return Accepted{}, fmt.Errorf("coding: a run needs a linked subject")
|
||||
}
|
||||
repo := strings.TrimSpace(in.Repo)
|
||||
prompt := strings.TrimSpace(in.Prompt)
|
||||
if repo == "" || prompt == "" {
|
||||
return Accepted{}, fmt.Errorf("coding: repo and task are required")
|
||||
}
|
||||
if !RepoRE.MatchString(repo) {
|
||||
// A hostile repo token could otherwise smuggle a path segment and address
|
||||
// another org's namespace. Same rule the git subsystem's own name check uses.
|
||||
return Accepted{}, fmt.Errorf("coding: %q is not a repo name", repo)
|
||||
}
|
||||
// Base and Project are shape-checked for the same reason Repo and Org are,
|
||||
// and they were the two that were not.
|
||||
//
|
||||
// Base is the worse of the two. It travels to RunRequest.BaseBranch and, on
|
||||
// the routed path, onto a CUSTOMER'S machine, where it lands in a `git clone
|
||||
// -b <base>` argument position. A value beginning with '-' is then not a
|
||||
// branch but a FLAG — `--upload-pack=...` or `--config=core.fsmonitor=...`
|
||||
// makes git run a command of the caller's choosing on the executor. BaseRE
|
||||
// is git's own branch shape, which is alnum-led and therefore cannot begin
|
||||
// with a dash; that is the property doing the work, not the length bound.
|
||||
base := strings.TrimSpace(in.Base)
|
||||
if base != "" && !BaseRE.MatchString(base) {
|
||||
return Accepted{}, fmt.Errorf("coding: %q is not a branch name", base)
|
||||
}
|
||||
project := strings.TrimSpace(in.Project)
|
||||
if project != "" && !RepoRE.MatchString(project) {
|
||||
return Accepted{}, fmt.Errorf("coding: %q is not a project name", project)
|
||||
}
|
||||
if err := CheckTool(in.Tool); err != nil {
|
||||
return Accepted{}, err
|
||||
}
|
||||
if len(prompt) > maxPromptLen {
|
||||
prompt = prompt[:maxPromptLen]
|
||||
}
|
||||
|
||||
d := Engine(log)
|
||||
if !pool.acquire(org) {
|
||||
return Accepted{}, ErrBusy
|
||||
}
|
||||
// Bind this run's narration, if the door gave it somewhere to talk. The
|
||||
// Dispatcher is a VALUE, so attaching a per-run watcher is a copy and never a
|
||||
// mutation of the shared engine — two concurrent runs cannot end up narrating
|
||||
// into each other's threads.
|
||||
if pr := newProgress(org, in.ReplyChannel, in.ReplyThread); pr != nil {
|
||||
d.Watch = pr.watch
|
||||
}
|
||||
|
||||
req := Req{
|
||||
Org: org, UserID: subject, AgentRef: in.AgentRef, Repo: repo,
|
||||
Project: project, Base: base, Tool: strings.TrimSpace(in.Tool), Desktop: in.Desktop,
|
||||
Prompt: prompt, TimeoutSeconds: in.TimeoutSeconds, TargetID: strings.TrimSpace(in.TargetID),
|
||||
}
|
||||
|
||||
// The session is opened on the DOOR's context, which already carries the
|
||||
// tenant (the door stated it, or it arrived on the wire). It is the one
|
||||
// synchronous seam call, and it is what makes the handle real.
|
||||
sessionID, err := d.Sessions.OpenOn(ctx, org, subject, agentRefOr(in.AgentRef),
|
||||
codingTitle(repo, prompt), req.TargetID)
|
||||
if err != nil {
|
||||
pool.release(org)
|
||||
return Accepted{}, fmt.Errorf("coding: could not start a session: %w", err)
|
||||
}
|
||||
req.SessionID = sessionID
|
||||
branch := BranchFor(sessionID)
|
||||
|
||||
// THE CREDENTIAL IS RESOLVED HERE AND NOWHERE ELSE, and it is resolved AFTER
|
||||
// the session, because the session id is what names the branch and the branch
|
||||
// is what the grant is FOR. A credential that had to be fetched before we knew
|
||||
// what it was for is a credential that could not have been bounded.
|
||||
//
|
||||
// A routed run needs none — the machine authenticates git with its own — so
|
||||
// the request is skipped and a workspace whose repo the forge does not hold
|
||||
// can still route. A sandbox run without one fails closed: an empty token is
|
||||
// an error, never a run that proceeds and discovers at push time it cannot
|
||||
// write.
|
||||
var credHandle string
|
||||
if req.TargetID == "" {
|
||||
// The grant lasts exactly as long as the run may — the run's own bounded
|
||||
// budget, plus a minute so a push at the very end of it still lands. Tying
|
||||
// the two together is what makes "the capability dies with the run" true
|
||||
// even when nothing gets to withdraw it.
|
||||
token, handle, err := agentCredential(ctx, repo, project, "refs/heads/"+branch,
|
||||
budget(req.TimeoutSeconds)+time.Minute)
|
||||
if err != nil {
|
||||
_ = d.Sessions.Close(ctx, org, sessionID, statusError)
|
||||
pool.release(org)
|
||||
return Accepted{}, err
|
||||
}
|
||||
req.CredUser, req.CredToken, credHandle = agentCredUser, token, handle
|
||||
}
|
||||
|
||||
// Detach, and state the tenant again on the way out.
|
||||
//
|
||||
// Both halves are load-bearing. DETACHED because the run outlives the door's
|
||||
// request by minutes — on the door's context the model call is cancelled the
|
||||
// instant we answer. STATED because detaching drops the request the tenant was
|
||||
// riding on, and every seam call left in the run authorizes on the caller.
|
||||
// This is the exact pairing the chat bridge uses, and the exact one whose
|
||||
// absence made every coding run fail.
|
||||
runCtx, cancel := runContext(org, req.TimeoutSeconds)
|
||||
go func() {
|
||||
defer cancel()
|
||||
defer pool.release(org)
|
||||
// The grant dies with the run rather than with its TTL. Registered before
|
||||
// the panic guard so it runs after it — a run that panicked still gives
|
||||
// the capability back — and on a cancel-immune context, because the run
|
||||
// that most needs its credential withdrawn is the one that hit its
|
||||
// deadline, and that is exactly when runCtx is already dead.
|
||||
defer releaseCredential(context.WithoutCancel(runCtx), credHandle)
|
||||
defer func() {
|
||||
// A run executes untrusted model output through a long seam chain. An
|
||||
// unrecovered panic here would take down every tenant sharing this
|
||||
// process, so it is contained — registered last so it runs first.
|
||||
if r := recover(); r != nil && log != nil {
|
||||
log("coding: run panic (recovered)", "org", org, "repo", repo, "err", r)
|
||||
}
|
||||
}()
|
||||
// Run is its own terminal: it mirrors the outcome into the session and
|
||||
// closes it, on a cancel-immune context, whether it succeeded or failed.
|
||||
// There is nothing left to report here and nobody left to report it to —
|
||||
// the door answered minutes ago, and the session is the record.
|
||||
d.Run(runCtx, req)
|
||||
}()
|
||||
|
||||
return Accepted{
|
||||
SessionID: sessionID, Branch: branch, Repo: repo,
|
||||
Routed: req.TargetID != "", TargetID: req.TargetID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// runContext is the context ONE coding run executes on: detached from the door's
|
||||
// request, carrying the tenant it acts for, bounded by the run budget.
|
||||
//
|
||||
// It takes an org and a budget and NOTHING ELSE — deliberately. A ctx parameter
|
||||
// here would be an invitation to pass the door's, which both cancels the run
|
||||
// when the door answers and silently discards the tenant. The signature is the
|
||||
// guard; bridgeRunContext is the same shape for the same reason.
|
||||
func runContext(org string, timeoutSeconds int) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(cloud.For(context.Background(), org), budget(timeoutSeconds))
|
||||
}
|
||||
|
||||
// agentCredential asks the forge to delegate ONE ref write, and returns the
|
||||
// bearer plus the handle that withdraws it.
|
||||
//
|
||||
// It used to read the org's sealed `agent` git token out of KMS. That token was
|
||||
// an ordinary IAM secret key, so IAM resolved it to a user and cloud minted a
|
||||
// full org principal from it: the process running untrusted model output held a
|
||||
// credential that opened /v1/kms/secrets — every other secret the org has,
|
||||
// including the one that posts to its Slack — and every other org-scoped API.
|
||||
// The push was confined and the credential was not, and the credential is what a
|
||||
// compromised run actually holds.
|
||||
//
|
||||
// A grant is not an identity. It resolves to no principal at all, so every gate
|
||||
// in the platform refuses it by default, and the one exception is the pack
|
||||
// protocol on the single repository it names (apps/git/grant.go). It also
|
||||
// removes an org-wide standing secret from the world rather than guarding it
|
||||
// better: there is nothing left for an operator to seal, and nothing left to
|
||||
// leak.
|
||||
//
|
||||
// Fail-closed, exactly as the KMS read was: an unreachable forge, a repository
|
||||
// that is not there, or an empty token each return an error and never a value.
|
||||
//
|
||||
// The org is NOT an argument. plane.GrantIn has no org field, on purpose: the
|
||||
// tenant rides the caller, so this delegates within the caller's own namespace
|
||||
// and a run can never reach another tenant's repository by naming it.
|
||||
func agentCredential(ctx context.Context, repo, project, ref string, ttl time.Duration) (token, handle string, err error) {
|
||||
g, err := plane.Ask[plane.GrantIn, plane.Granted](ctx, "git", plane.GitGrant,
|
||||
&plane.GrantIn{Repo: repo, Project: project, Ref: ref, TTLSeconds: int(ttl.Seconds())})
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("coding: the forge would not delegate a push for %s: %w", repo, err)
|
||||
}
|
||||
if g == nil || strings.TrimSpace(g.Token) == "" {
|
||||
return "", "", fmt.Errorf("coding: the forge returned no push grant for %s", repo)
|
||||
}
|
||||
return g.Token, g.Handle, nil
|
||||
}
|
||||
|
||||
// releaseCredential withdraws the grant when the run is over, so a grant's life
|
||||
// is the RUN's life and not its TTL. Best-effort: the TTL is what makes this
|
||||
// safe to miss, and a run that already finished must not fail because the forge
|
||||
// was slow to hear about it.
|
||||
func releaseCredential(ctx context.Context, handle string) {
|
||||
if strings.TrimSpace(handle) == "" {
|
||||
return
|
||||
}
|
||||
_, _ = plane.Ask[plane.RevokeIn, plane.Revoked](ctx, "git", plane.GitRevoke,
|
||||
&plane.RevokeIn{Handle: handle})
|
||||
}
|
||||
|
||||
// ---- the bounded pool ------------------------------------------------------
|
||||
|
||||
// limiter bounds concurrent runs two ways: a GLOBAL cap across all orgs, and a
|
||||
// PER-ORG cap. Tenant isolation of data already holds via the resolved org; this
|
||||
// is the AVAILABILITY isolation that stops one workspace exhausting the sandbox
|
||||
// capacity every other workspace shares.
|
||||
type limiter struct {
|
||||
mu sync.Mutex
|
||||
inflight map[string]int
|
||||
perOrg int
|
||||
global chan struct{}
|
||||
}
|
||||
|
||||
func newLimiter(global, perOrg int) *limiter {
|
||||
if global < 1 {
|
||||
global = 1
|
||||
}
|
||||
if perOrg < 1 {
|
||||
perOrg = 1
|
||||
}
|
||||
if perOrg > global {
|
||||
perOrg = global
|
||||
}
|
||||
return &limiter{inflight: make(map[string]int), perOrg: perOrg, global: make(chan struct{}, global)}
|
||||
}
|
||||
|
||||
// acquire takes one global and one per-org slot, non-blocking. It returns false
|
||||
// with NOTHING acquired when the org is at its cap or the pool is full — the
|
||||
// per-org check precedes the global take, and the count only rises on a
|
||||
// successful send, so a refusal can never leak a slot.
|
||||
func (l *limiter) acquire(org string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.inflight[org] >= l.perOrg {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case l.global <- struct{}{}:
|
||||
l.inflight[org]++
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (l *limiter) release(org string) {
|
||||
l.mu.Lock()
|
||||
if n := l.inflight[org]; n > 1 {
|
||||
l.inflight[org] = n - 1
|
||||
} else {
|
||||
delete(l.inflight, org)
|
||||
}
|
||||
l.mu.Unlock()
|
||||
<-l.global
|
||||
}
|
||||
|
||||
// ---- config (env, read at call time — operator-injected from KMS) ----------
|
||||
|
||||
// budget is how long ONE run may take, and therefore how long its delegated
|
||||
// push stays usable. Capped at maxRunBudget: a caller names a timeout, it does
|
||||
// not name an unbounded one.
|
||||
func budget(timeoutSeconds int) time.Duration {
|
||||
d := defaultRunBudget
|
||||
switch {
|
||||
case timeoutSeconds > 0:
|
||||
d = time.Duration(timeoutSeconds) * time.Second
|
||||
default:
|
||||
if v, err := strconv.Atoi(strings.TrimSpace(os.Getenv("CODING_TIMEOUT_SEC"))); err == nil && v > 0 {
|
||||
d = time.Duration(v) * time.Second
|
||||
}
|
||||
}
|
||||
return min(d, maxRunBudget)
|
||||
}
|
||||
|
||||
func concurrency() int {
|
||||
if v, err := strconv.Atoi(strings.TrimSpace(os.Getenv("CODING_CONCURRENCY"))); err == nil && v > 0 {
|
||||
return v
|
||||
}
|
||||
return defaultConcurrency
|
||||
}
|
||||
|
||||
func orgConcurrency() int {
|
||||
if v, err := strconv.Atoi(strings.TrimSpace(os.Getenv("CODING_ORG_CONCURRENCY"))); err == nil && v > 0 {
|
||||
return v
|
||||
}
|
||||
return defaultOrgConcurrency
|
||||
}
|
||||
|
||||
func agentRefOr(ref string) string {
|
||||
if r := strings.TrimSpace(ref); r != "" {
|
||||
return r
|
||||
}
|
||||
return "hanzo"
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package coding
|
||||
|
||||
// The tenant a coding run acts for has to travel ON THE WIRE, and these pin the
|
||||
// two halves of why that is not obvious — the same pair apps/integrations keeps
|
||||
// for the chat turn, kept here because this is the other place it shipped wrong.
|
||||
//
|
||||
// Every seam a run touches (session, git, tracker, the balance gate behind them)
|
||||
// authorizes on the CALLER's org and never on an argument, so no caller can name
|
||||
// the tenant it acts for. The org therefore rides the caller. But zip reads a
|
||||
// STATED caller only where there is NO REQUEST behind the context
|
||||
// (caller.go:352-356) — otherwise CallerOf reads the request's own headers — so
|
||||
// cloud.For applied to an inbound request is a SILENT NO-OP.
|
||||
//
|
||||
// The coding path had neither half: the run was spawned on a bare
|
||||
// context.Background() with no statement at all, and the routed target lookup
|
||||
// ran on the webhook's request context where a statement would have been
|
||||
// discarded anyway. Every seam call in every run answered "authorize: no org on
|
||||
// the call", which is a run that dies before a model is asked anything.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/plane"
|
||||
)
|
||||
|
||||
// A run's context must name the tenant, or every seam it touches refuses it.
|
||||
func TestRunContextStatesTheTenant(t *testing.T) {
|
||||
ctx, cancel := runContext("acme", 0)
|
||||
defer cancel()
|
||||
if got := cloud.Who(ctx).Org; got != "acme" {
|
||||
t.Fatalf("a run must act for a named tenant, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The statement is only READABLE off a context with no request behind it. This
|
||||
// is the regression that shipped twice: the same call, the wrong base context,
|
||||
// silently no org.
|
||||
func TestStatingOnARequestContextIsANoOp(t *testing.T) {
|
||||
if got := cloud.Who(cloud.For(context.Background(), "acme")).Org; got != "acme" {
|
||||
t.Fatalf("a stated tenant must be readable off a background context, got %q", got)
|
||||
}
|
||||
// runContext must not be derivable from a caller-supplied context: it takes
|
||||
// an org and a budget and nothing else, so there is no parameter through
|
||||
// which a request could be threaded back in. If this ever grows a
|
||||
// context.Context argument the bug returns — the SIGNATURE is the guard.
|
||||
var _ func(string, int) (context.Context, context.CancelFunc) = runContext
|
||||
}
|
||||
|
||||
// An empty org states nothing rather than a blank tenant: downstream must refuse
|
||||
// on "no org" rather than act for an account named "".
|
||||
func TestEmptyTenantIsNotStated(t *testing.T) {
|
||||
ctx, cancel := runContext("", 0)
|
||||
defer cancel()
|
||||
if got := cloud.Who(ctx).Org; got != "" {
|
||||
t.Errorf("an empty org must not become a tenant, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A run outlives the door that started it by minutes, so its deadline must come
|
||||
// from the run budget and not from a request that has already been answered.
|
||||
func TestRunContextIsBoundedAndNotAlreadyDone(t *testing.T) {
|
||||
ctx, cancel := runContext("acme", 60)
|
||||
defer cancel()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal("a fresh run context is already done; the run would be cancelled before it starts")
|
||||
default:
|
||||
}
|
||||
d, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
t.Fatal("a run must be bounded")
|
||||
}
|
||||
if left := time.Until(d); left > 2*time.Minute {
|
||||
t.Errorf("the caller's budget was ignored: %v left on a 60s run", left)
|
||||
}
|
||||
}
|
||||
|
||||
// The dispatch door must not accept a tenant, a repo that is a path, or a run
|
||||
// with no human behind it. Each of these is refused BEFORE a slot is taken, so a
|
||||
// bad request cannot consume capacity.
|
||||
func TestStartRefusesWhatItMustRefuse(t *testing.T) {
|
||||
for name, tc := range map[string]struct{ org, subject, repo, prompt string }{
|
||||
"no tenant": {"", "u", "api", "do a thing"},
|
||||
"tenant is a path": {"../other", "u", "api", "do a thing"},
|
||||
"tenant has a dot segment": {"a/../b", "u", "api", "do a thing"},
|
||||
"no subject": {"acme", "", "api", "do a thing"},
|
||||
"no repo": {"acme", "u", "", "do a thing"},
|
||||
"no task": {"acme", "u", "api", ""},
|
||||
"repo is a path": {"acme", "u", "../other-org/api", "do a thing"},
|
||||
"repo escapes": {"acme", "u", "a/b", "do a thing"},
|
||||
} {
|
||||
_, err := Start(context.Background(), tc.org, startIn(tc.subject, tc.repo, tc.prompt), nil)
|
||||
if err == nil {
|
||||
t.Errorf("%s: accepted; it must be refused before a slot is spent", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The pool is the availability isolation: one tenant may not exhaust the
|
||||
// sandbox capacity every other tenant shares.
|
||||
func TestOneTenantCannotStarveTheOthers(t *testing.T) {
|
||||
l := newLimiter(4, 2)
|
||||
if !l.acquire("acme") || !l.acquire("acme") {
|
||||
t.Fatal("an org could not reach its own cap")
|
||||
}
|
||||
if l.acquire("acme") {
|
||||
t.Fatal("an org exceeded its per-org cap; one workspace could take the whole pool")
|
||||
}
|
||||
if !l.acquire("other") {
|
||||
t.Fatal("a second org was starved by the first")
|
||||
}
|
||||
// A refusal must not leak a slot, or the pool bleeds down to nothing.
|
||||
l.release("acme")
|
||||
if !l.acquire("acme") {
|
||||
t.Fatal("a released slot was not reusable; refusals leak capacity")
|
||||
}
|
||||
}
|
||||
|
||||
// A run narrates itself into a chat thread, and every value in that narration is
|
||||
// derived from model output or from repo content the model read. Unescaped, a
|
||||
// run could page an entire workspace with <!channel>, or post a link with an
|
||||
// arbitrary URL under Hanzo's name. THIS IS AN INJECTION TEST.
|
||||
func TestARunCannotInjectSlackMarkup(t *testing.T) {
|
||||
hostile := `<!channel> <https://evil.example|click here> & <@U123>`
|
||||
for name, payload := range map[string][]byte{
|
||||
"in an error": []byte(`{"status":"error","error":` + q(hostile) + `}`),
|
||||
"in a branch": []byte(`{"status":"done","changed":true,"branch":` + q(hostile) + `}`),
|
||||
"in a PR key": []byte(`{"status":"done","changed":true,"branch":"b","pr":` + q(hostile) + `}`),
|
||||
"in a log line": []byte(`{"message":` + q(hostile) + `}`),
|
||||
"in a step": []byte(`{"step":` + q(hostile) + `}`),
|
||||
} {
|
||||
line, _ := renderLine(kindToolCall, payload)
|
||||
if line == "" {
|
||||
t.Errorf("%s: rendered nothing; the case is not being exercised", name)
|
||||
continue
|
||||
}
|
||||
if strings.ContainsAny(line, "<>") {
|
||||
t.Errorf("%s: raw markup reached the message — a run can broadcast or forge a link: %q", name, line)
|
||||
}
|
||||
if strings.Contains(line, "&lt;") {
|
||||
t.Errorf("%s: double-escaped, the & pass must run first: %q", name, line)
|
||||
}
|
||||
}
|
||||
// One enormous value must not become the whole message.
|
||||
long := []byte(`{"message":"` + strings.Repeat("x", 50000) + `"}`)
|
||||
if line, _ := renderLine(kindLog, long); len(line) > maxLine+32 {
|
||||
t.Errorf("an unbounded value reached the message: %d chars", len(line))
|
||||
}
|
||||
}
|
||||
|
||||
func q(s string) string { b, _ := json.Marshal(s); return string(b) }
|
||||
|
||||
// startIn builds the door's request. The tenant is deliberately NOT a field of
|
||||
// it — that is the whole point of the contract — so it is passed to Start
|
||||
// separately, exactly as a real door passes what it read off the caller.
|
||||
func startIn(subject, repo, prompt string) plane.CodingStartIn {
|
||||
return plane.CodingStartIn{Subject: subject, Repo: repo, Prompt: prompt}
|
||||
}
|
||||
|
||||
// A finished run must always get the last word, and it must say which ending it
|
||||
// was. A terminal that rendered empty would leave the thread reading "working…"
|
||||
// forever on a run that is over.
|
||||
func TestEveryTerminalGetsTheLastWord(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
payload []byte
|
||||
want string
|
||||
}{
|
||||
"failed": {[]byte(`{"status":"error","error":"it broke"}`), "it broke"},
|
||||
"no changes": {[]byte(`{"status":"done","changed":false}`), "No changes"},
|
||||
"pushed": {[]byte(`{"status":"done","changed":true,"branch":"agent/abc","pr":"ENG-1"}`), "agent/abc"},
|
||||
} {
|
||||
line, terminal := renderLine(kindStatus, tc.payload)
|
||||
if !terminal {
|
||||
t.Errorf("%s: not reported as terminal; the edit budget could swallow the run's ending", name)
|
||||
}
|
||||
if !strings.Contains(line, tc.want) {
|
||||
t.Errorf("%s: the thread would not say what happened: %q", name, line)
|
||||
}
|
||||
}
|
||||
// A run in flight is NOT terminal, or the first step would spend the reserve.
|
||||
if _, terminal := renderLine(kindStatus, []byte(`{"status":"started","branch":"agent/abc"}`)); terminal {
|
||||
t.Error("a started run was treated as terminal")
|
||||
}
|
||||
}
|
||||
|
||||
// The last word carries the ADDRESS. A run that pushed a branch and filed a PR
|
||||
// is finished work, and a thread that names it without saying where to read it
|
||||
// leaves the person who asked to go hunting for their own change.
|
||||
func TestTheLastWordSaysWhereToReadIt(t *testing.T) {
|
||||
line, _ := renderLine(kindStatus, []byte(
|
||||
`{"status":"done","changed":true,"branch":"agent/abc","pr":"ENG-1","url":"https://github.com/hanzo-inc/api/pull/7"}`))
|
||||
if !strings.Contains(line, "https://github.com/hanzo-inc/api/pull/7") {
|
||||
t.Fatalf("the address never reached the thread: %q", line)
|
||||
}
|
||||
// BARE, so Slack links it on its own. A `<url|text>` element is the one piece
|
||||
// of mrkdwn that carries an arbitrary destination, and nothing a run emits may
|
||||
// construct one.
|
||||
if strings.Contains(line, "<") || strings.Contains(line, ">") {
|
||||
t.Fatalf("the address was wrapped in a link element: %q", line)
|
||||
}
|
||||
// And a run with no address still gets its last word.
|
||||
if l, _ := renderLine(kindStatus, []byte(`{"status":"done","changed":true,"branch":"agent/abc"}`)); !strings.Contains(l, "agent/abc") {
|
||||
t.Fatalf("a run without an address lost its ending: %q", l)
|
||||
}
|
||||
}
|
||||
|
||||
// The sink reads untrusted payloads. A malformed one must be ignored, never
|
||||
// panic a run that has already pushed a branch.
|
||||
func TestProgressSurvivesAHostilePayload(t *testing.T) {
|
||||
for _, payload := range [][]byte{
|
||||
nil, []byte(""), []byte("not json"), []byte("[]"), []byte(`{"step":123}`), []byte(`{`),
|
||||
} {
|
||||
if line, _ := renderLine(kindLog, payload); line != "" {
|
||||
t.Errorf("an unreadable payload rendered %q; it must change nothing", line)
|
||||
}
|
||||
}
|
||||
// No address means no sink, and a nil sink is a no-op so no caller branches.
|
||||
var none *progress
|
||||
none.watch(context.Background(), kindLog, []byte(`{"message":"hi"}`))
|
||||
if newProgress("acme", "", "") != nil {
|
||||
t.Error("a sink was built with nowhere to post")
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
package coding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/hanzoai/cloud/apps/bots"
|
||||
)
|
||||
|
||||
// task.go is coding's WIRE CONTRACT with the bot runtime — the stub behind the
|
||||
// Runner seam. It says WHAT coding asks the runtime to do (run one coding job)
|
||||
// and how a progress line is shaped; how the bytes get there is runtime's problem.
|
||||
//
|
||||
// This file is the ONE place in clients/coding that knows the runtime exists.
|
||||
// coding.go (the orchestrator) is pure and never sees it.
|
||||
//
|
||||
// The runtime answers a stream: one message per step/log as the job progresses
|
||||
// (clone → dev exec → commit → push), then a terminal result (or error). Coding
|
||||
// mirrors every step into the agent session live, so the run is watchable at
|
||||
// GET /v1/agents/sessions/:id/stream, and returns the terminal outcome.
|
||||
//
|
||||
// CREDENTIAL CUSTODY: the per-org agent git credential travels in the request
|
||||
// BODY (never a URL, never argv, never a log). That is why the call declares
|
||||
// Secret — the transport then refuses to carry it over a cleartext hop.
|
||||
|
||||
// taskOp addresses the runtime's coding-task operation.
|
||||
const taskOp = "/v1/coding-tasks"
|
||||
|
||||
// credential is the per-org agent git credential the sandbox presents to native
|
||||
// git. Token is the secret (an sk- key); Username is the basic-auth user label.
|
||||
// Encoded into the request body only — never logged.
|
||||
type credential struct {
|
||||
Username string `json:"username"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// taskRequest is the cloud→runtime body for one coding run.
|
||||
type taskRequest struct {
|
||||
CloneURL string `json:"cloneUrl"` // https://<domain>/v1/git/<org>/<repo>.git
|
||||
BaseBranch string `json:"baseBranch"` // branch to start from (default repo default)
|
||||
Branch string `json:"branch"` // branch to create + push (e.g. agent/<sessionid>)
|
||||
Prompt string `json:"prompt"` // the engineering task
|
||||
SessionID string `json:"sessionId"` // cloud session id (correlation)
|
||||
RunTimeoutSeconds int `json:"runTimeoutSeconds"` // sandbox run budget
|
||||
Credential credential `json:"credential"` // agent git credential (write-only)
|
||||
}
|
||||
|
||||
// message is the discriminated shape of one streamed line: step/log while the job
|
||||
// runs, result/error to end it.
|
||||
type message struct {
|
||||
Type string `json:"type"` // step | log | result | error
|
||||
Step string `json:"step,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
CommitSha string `json:"commitSha,omitempty"`
|
||||
Diffstat string `json:"diffstat,omitempty"`
|
||||
Changed bool `json:"changed,omitempty"`
|
||||
OK bool `json:"ok,omitempty"`
|
||||
LogTail string `json:"logTail,omitempty"`
|
||||
}
|
||||
|
||||
// runner is the Runner seam over the real runtime. Its fake twin in coding_test.go
|
||||
// is what the orchestrator is tested against.
|
||||
type runner struct{}
|
||||
|
||||
// Run hands one coding job to the runtime and streams its progress, invoking
|
||||
// onStep for each line as it arrives, then returns the terminal result. A
|
||||
// transport failure, or a stream that ends without a terminal message, is an
|
||||
// error — no partial success is fabricated. org/userID are the tenant context the
|
||||
// runtime trusts AFTER its own bearer gate.
|
||||
func (runner) Run(ctx context.Context, org, userID string, req RunRequest, onStep func(Step)) (RunResult, error) {
|
||||
var out RunResult
|
||||
var terminal bool
|
||||
err := bots.Stream(ctx, bots.Call{
|
||||
Op: taskOp,
|
||||
Org: org,
|
||||
User: userID,
|
||||
Body: taskRequest{
|
||||
CloneURL: req.CloneURL, BaseBranch: req.BaseBranch, Branch: req.Branch,
|
||||
Prompt: req.Prompt, SessionID: req.SessionID, RunTimeoutSeconds: req.RunTimeoutSeconds,
|
||||
Credential: credential{Username: req.CredUser, Token: req.CredToken},
|
||||
},
|
||||
Secret: true, // the body carries the org's git credential
|
||||
}, func(msg []byte) {
|
||||
var m message
|
||||
if json.Unmarshal(msg, &m) != nil {
|
||||
return // skip a malformed message rather than abort the whole run
|
||||
}
|
||||
switch m.Type {
|
||||
case "result":
|
||||
out = RunResult{
|
||||
Branch: m.Branch, CommitSha: m.CommitSha, Diffstat: m.Diffstat,
|
||||
Changed: m.Changed, OK: m.OK, LogTail: m.LogTail,
|
||||
}
|
||||
terminal = true
|
||||
case "error":
|
||||
out = RunResult{OK: false, LogTail: m.LogTail, Error: nonEmpty(m.Message, "coding task failed")}
|
||||
terminal = true
|
||||
default: // step | log — mirror live
|
||||
if onStep != nil {
|
||||
onStep(Step{Type: m.Type, Step: m.Step, Message: m.Message, Status: m.Status})
|
||||
}
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return RunResult{}, fmt.Errorf("coding: run task: %w", err)
|
||||
}
|
||||
if !terminal {
|
||||
return RunResult{}, fmt.Errorf("coding: stream ended without a result")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
package coding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// These pin coding's wire contract with the runtime through the REAL transport
|
||||
// (apps/bots' transport) against a stub server — so the credential-custody and
|
||||
// fail-closed properties are proven end to end over the seam, not against a fake
|
||||
// of it.
|
||||
|
||||
// ndjsonServer streams the given lines as application/x-ndjson and records the
|
||||
// request it received, so a test can assert the wire contract (path, headers,
|
||||
// body) AND that the credential travels only in the body.
|
||||
func ndjsonServer(t *testing.T, lines []string, capture *http.Request, capBody *[]byte) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if capture != nil {
|
||||
*capture = *r
|
||||
}
|
||||
if capBody != nil {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
*capBody = b
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
for _, ln := range lines {
|
||||
_, _ = io.WriteString(w, ln+"\n")
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func TestTask_StreamsStepsAndResult(t *testing.T) {
|
||||
lines := []string{
|
||||
`{"type":"step","step":"clone","status":"ok"}`,
|
||||
`{"type":"log","message":"editing handler.go"}`,
|
||||
`{"type":"step","step":"push","status":"ok"}`,
|
||||
`{"type":"result","branch":"agent/x","commitSha":"deadbeef","diffstat":"1 file changed","changed":true,"ok":true,"logTail":"done"}`,
|
||||
}
|
||||
var gotReq http.Request
|
||||
var gotBody []byte
|
||||
srv := ndjsonServer(t, lines, &gotReq, &gotBody)
|
||||
defer srv.Close()
|
||||
t.Setenv("BOT_GATEWAY_URL", srv.URL)
|
||||
t.Setenv("BOT_GATEWAY_ALLOW_PLAINTEXT", "1") // httptest is http://; assert-plaintext guard tested separately
|
||||
t.Setenv("BOT_GATEWAY_TOKEN", "svc-token-xyz")
|
||||
|
||||
var steps []Step
|
||||
res, err := runner{}.Run(context.Background(), "acme", "u-1", RunRequest{
|
||||
CloneURL: "https://git.test/v1/git/acme/api.git", Branch: "agent/x", Prompt: "fix",
|
||||
CredUser: "x-access-token", CredToken: "sk-SECRETtoken",
|
||||
}, func(s Step) { steps = append(steps, s) })
|
||||
if err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
|
||||
// Result parsed.
|
||||
if !res.OK || !res.Changed || res.Branch != "agent/x" || res.CommitSha != "deadbeef" {
|
||||
t.Fatalf("result wrong: %+v", res)
|
||||
}
|
||||
// Steps mirrored (2 steps + 1 log; result/terminal not delivered as a step).
|
||||
if len(steps) != 3 {
|
||||
t.Fatalf("want 3 progress steps, got %d: %+v", len(steps), steps)
|
||||
}
|
||||
if steps[0].Type != "step" || steps[0].Step != "clone" || steps[1].Type != "log" {
|
||||
t.Fatalf("step vocabulary wrong: %+v", steps)
|
||||
}
|
||||
|
||||
// Wire contract: POST /v1/coding-tasks, identity + bearer headers set.
|
||||
if gotReq.Method != http.MethodPost || !strings.HasSuffix(gotReq.URL.Path, "/v1/coding-tasks") {
|
||||
t.Fatalf("bad request line: %s %s", gotReq.Method, gotReq.URL.Path)
|
||||
}
|
||||
if gotReq.Header.Get("X-Org-Id") != "acme" || gotReq.Header.Get("X-User-Id") != "u-1" {
|
||||
t.Fatalf("identity headers wrong: %v", gotReq.Header)
|
||||
}
|
||||
if gotReq.Header.Get("Authorization") != "Bearer svc-token-xyz" {
|
||||
t.Fatalf("service bearer missing/wrong: %q", gotReq.Header.Get("Authorization"))
|
||||
}
|
||||
// The credential travels in the BODY only (never a URL/header).
|
||||
if strings.Contains(gotReq.URL.RawQuery, "SECRETtoken") || strings.Contains(gotReq.Header.Get("Authorization"), "SECRETtoken") {
|
||||
t.Fatal("credential leaked onto URL/header")
|
||||
}
|
||||
var body taskRequest
|
||||
if err := json.Unmarshal(gotBody, &body); err != nil {
|
||||
t.Fatalf("body decode: %v", err)
|
||||
}
|
||||
if body.Credential.Token != "sk-SECRETtoken" || body.Credential.Username != "x-access-token" {
|
||||
t.Fatalf("credential not carried in body: %+v", body.Credential)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTask_ErrorLine(t *testing.T) {
|
||||
srv := ndjsonServer(t, []string{`{"type":"error","message":"dev exec failed","logTail":"boom"}`}, nil, nil)
|
||||
defer srv.Close()
|
||||
t.Setenv("BOT_GATEWAY_URL", srv.URL)
|
||||
t.Setenv("BOT_GATEWAY_ALLOW_PLAINTEXT", "1") // httptest is http://; assert-plaintext guard tested separately
|
||||
|
||||
res, err := runner{}.Run(context.Background(), "acme", "u", RunRequest{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("a clean error line is a terminal result, not a transport error: %v", err)
|
||||
}
|
||||
if res.OK || res.Error != "dev exec failed" || res.LogTail != "boom" {
|
||||
t.Fatalf("error result wrong: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTask_Non2xxIsError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
}))
|
||||
defer srv.Close()
|
||||
t.Setenv("BOT_GATEWAY_URL", srv.URL)
|
||||
t.Setenv("BOT_GATEWAY_ALLOW_PLAINTEXT", "1") // httptest is http://; assert-plaintext guard tested separately
|
||||
if _, err := (runner{}).Run(context.Background(), "acme", "u", RunRequest{}, nil); err == nil {
|
||||
t.Fatal("a 401 must be an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTask_NoTerminalIsError(t *testing.T) {
|
||||
srv := ndjsonServer(t, []string{`{"type":"step","step":"clone"}`}, nil, nil)
|
||||
defer srv.Close()
|
||||
t.Setenv("BOT_GATEWAY_URL", srv.URL)
|
||||
t.Setenv("BOT_GATEWAY_ALLOW_PLAINTEXT", "1") // httptest is http://; assert-plaintext guard tested separately
|
||||
if _, err := (runner{}).Run(context.Background(), "acme", "u", RunRequest{}, nil); err == nil {
|
||||
t.Fatal("a stream with no result/error line must be an error (no fabricated success)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTask_RefusesCleartextByDefault(t *testing.T) {
|
||||
// The credential-bearing POST must not go cleartext without an explicit mesh
|
||||
// opt-in: an http target with BOT_GATEWAY_ALLOW_PLAINTEXT unset fails closed and
|
||||
// never dials.
|
||||
srv := ndjsonServer(t, []string{`{"type":"result","ok":true}`}, nil, nil)
|
||||
defer srv.Close()
|
||||
t.Setenv("BOT_GATEWAY_URL", srv.URL) // http://
|
||||
t.Setenv("BOT_GATEWAY_ALLOW_PLAINTEXT", "")
|
||||
_, err := runner{}.Run(context.Background(), "acme", "u", RunRequest{
|
||||
CredUser: "x", CredToken: "sk-SECRET",
|
||||
}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("cleartext coding POST must fail closed by default")
|
||||
}
|
||||
if strings.Contains(err.Error(), "sk-SECRET") {
|
||||
t.Fatalf("error must not leak the credential: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package coding
|
||||
|
||||
// What a run may say about itself, and what it may not.
|
||||
//
|
||||
// A coding run has two channels back into cloud that are NOT the git protocol:
|
||||
// the fields the caller sets when starting it, and the fields the sandbox
|
||||
// reports when it finishes. Both were partly trusted. The sandbox is the thing
|
||||
// assumed compromised, and the starting caller is only as trustworthy as the
|
||||
// prompt that reached them — so both are inputs, and both are shaped here.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The base branch travels to a `git clone -b <base>` argument on a machine we do
|
||||
// not own. A value beginning with '-' is not a branch there but a FLAG, and
|
||||
// `--upload-pack=` / `--config=core.fsmonitor=` each make git run a command of
|
||||
// the caller's choosing on that machine. Repo and Org were shape-checked; Base
|
||||
// and Project were not, and Base is the one that reaches an argv.
|
||||
func TestStartRefusesABaseThatIsNotABranch(t *testing.T) {
|
||||
for name, base := range map[string]string{
|
||||
"an upload-pack flag": "--upload-pack=touch /tmp/pwned",
|
||||
"a config flag": "--config=core.fsmonitor=touch /tmp/pwned",
|
||||
"a bare dash": "-",
|
||||
"an option-looking -o": "-o",
|
||||
"a traversal": "../../etc/passwd",
|
||||
"a space": "main branch",
|
||||
"a newline": "main\nrm -rf /",
|
||||
"a semicolon": "main;id",
|
||||
"a leading dot": ".hidden",
|
||||
} {
|
||||
in := startIn("u", "api", "do a thing")
|
||||
in.Base = base
|
||||
if _, err := Start(context.Background(), "acme", in, nil); err == nil {
|
||||
t.Errorf("%s (%q): accepted; it reaches a git argv on a customer's machine", name, base)
|
||||
} else if !strings.Contains(err.Error(), "branch name") {
|
||||
t.Errorf("%s (%q): refused for the wrong reason: %v", name, base, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The control must not refuse ordinary work: a real base, including a nested
|
||||
// one, has to pass. A branch rule that rejects `release/2.1` gets removed.
|
||||
func TestStartAcceptsARealBase(t *testing.T) {
|
||||
for _, base := range []string{"main", "develop", "release/2.1", "v1.2.3", "feature/JIRA-42_thing"} {
|
||||
if !BaseRE.MatchString(base) {
|
||||
t.Errorf("%q is an ordinary branch and must be accepted", base)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Project becomes a git scope and a tracker key, so it is shaped for the same
|
||||
// reason Repo is: a value carrying a separator addresses another namespace.
|
||||
func TestStartRefusesAProjectThatIsAPath(t *testing.T) {
|
||||
for _, project := range []string{"../other", "a/b", ".", "..", "a/../b"} {
|
||||
in := startIn("u", "api", "do a thing")
|
||||
in.Project = project
|
||||
if _, err := Start(context.Background(), "acme", in, nil); err == nil {
|
||||
t.Errorf("project %q: accepted; it is a path", project)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// THE ATTACK: a compromised sandbox reports that it pushed `main`.
|
||||
//
|
||||
// The branch was cloud's to decide — BranchFor(sessionID), named after a session
|
||||
// the sandbox did not choose. Adopting the sandbox's self-report meant the claim
|
||||
// flowed into VerifyRef, which only asks whether a ref EXISTS (and main does),
|
||||
// and out the other side as PR.Open{Head: "main"}: a pull request headed at the
|
||||
// trunk, filed by us, on behalf of a run that never had permission to write
|
||||
// there.
|
||||
func TestACompromisedSandboxCannotRenameItsOwnBranch(t *testing.T) {
|
||||
sessions := &fakeSessions{id: "sess_abc123def456"}
|
||||
tracker := &fakePR{ref: PRRef{Identifier: "API-1"}}
|
||||
verified := map[string]bool{}
|
||||
|
||||
d := Dispatcher{
|
||||
Sessions: sessions,
|
||||
PR: tracker,
|
||||
Runner: &fakeRunner{result: RunResult{
|
||||
OK: true, Changed: true, CommitSha: "deadbeef",
|
||||
Branch: "main", // the lie
|
||||
}},
|
||||
CloneURL: func(context.Context, string, string) string { return "https://git.test/acme/api.git" },
|
||||
VerifyRef: func(_ context.Context, _, _, branch string) (string, bool) {
|
||||
verified[branch] = true
|
||||
return "deadbeef", true
|
||||
},
|
||||
}
|
||||
res := d.Run(context.Background(), Req{
|
||||
Org: "acme", Repo: "api", Prompt: "do a thing", CredToken: "hgg_x", UserID: "u",
|
||||
})
|
||||
|
||||
want := BranchFor("sess_abc123def456")
|
||||
if res.Branch != want {
|
||||
t.Fatalf("the run's branch became %q; it is cloud's to decide and must stay %q", res.Branch, want)
|
||||
}
|
||||
if verified["main"] {
|
||||
t.Fatal("the integrity check was pointed at main by the sandbox's own claim")
|
||||
}
|
||||
if len(tracker.inputs) != 1 {
|
||||
t.Fatalf("want one PR, got %d", len(tracker.inputs))
|
||||
}
|
||||
if head := tracker.inputs[0].Head; head != want {
|
||||
t.Fatalf("A PR WAS FILED HEADED AT %q — the sandbox chose the head", head)
|
||||
}
|
||||
t.Logf("the sandbox said %q; the PR is headed at %q", "main", head(tracker))
|
||||
}
|
||||
|
||||
// The same lie on the ROUTED path, where the reporter is a customer's machine
|
||||
// rather than our sandbox — a strictly less trusted place.
|
||||
func TestARoutedMachineCannotRenameItsOwnBranch(t *testing.T) {
|
||||
sessions := &fakeSessions{id: "sess_abc123def456"}
|
||||
tracker := &fakePR{ref: PRRef{Identifier: "API-2"}}
|
||||
d := Dispatcher{
|
||||
Sessions: sessions,
|
||||
PR: tracker,
|
||||
VerifyRef: func(context.Context, string, string, string) (string, bool) { return "deadbeef", true },
|
||||
}
|
||||
issuedBranch := BranchFor("sess_abc123def456")
|
||||
d.finalizeRouted(context.Background(),
|
||||
RoutedRun{Org: "acme", Repo: "api", SessionID: "sess_abc123def456", Branch: issuedBranch, Actor: "u"},
|
||||
RoutedResult{OK: true, Changed: true, Branch: "main", CommitSha: "deadbeef"})
|
||||
|
||||
if len(tracker.inputs) != 1 {
|
||||
t.Fatalf("want one PR, got %d", len(tracker.inputs))
|
||||
}
|
||||
if got := tracker.inputs[0].Head; got != issuedBranch {
|
||||
t.Fatalf("A ROUTED PR WAS FILED HEADED AT %q, want %q", got, issuedBranch)
|
||||
}
|
||||
t.Logf("the machine said %q; the PR is headed at %q", "main", issuedBranch)
|
||||
}
|
||||
|
||||
func head(f *fakePR) string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.inputs[0].Head
|
||||
}
|
||||
@@ -141,6 +141,61 @@ func describeBilling() {
|
||||
"/v1/billing/credit, which no browser can reach. Reading an empty balance is an empty "+
|
||||
"array, not an error.")
|
||||
|
||||
// ---- the four reads the billing app's own tabs are built on ----
|
||||
//
|
||||
// All four take GetTier's chain, and the middleware that makes them safe is
|
||||
// PinBillingSubject: it OVERWRITES the subject key each handler filters on
|
||||
// with the caller's own account.Payer subject, so the ?user and ?userId these
|
||||
// handlers read can never name anybody else. The prose below says so, because
|
||||
// a reader who believes those parameters are theirs to choose has misread the
|
||||
// operation in the direction that matters.
|
||||
openapi.Describe("/v1/billing/transactions", http.MethodGet,
|
||||
"List the movements on your own balance, newest first",
|
||||
"Returns the caller's own ledger movements — every credit and debit against the subject "+
|
||||
"the usage gate charges — newest first, with a count and the subject they belong to, "+
|
||||
"so a customer can reconcile a bill against the acts that produced it. Paging is "+
|
||||
"limit and offset, and the currency can be narrowed.\n\n"+
|
||||
"The subject is NOT the caller's to choose. The handler filters on a user parameter, "+
|
||||
"and that parameter is overwritten with the caller's own billing subject before the "+
|
||||
"handler runs — so naming another subject returns your own rows rather than theirs, "+
|
||||
"and the read can never disagree with the wallet it describes. An unauthenticated "+
|
||||
"call is 401 rather than 403, because a browser re-authenticates on the first and "+
|
||||
"only reports the second. No movements is an empty list, not an error.")
|
||||
|
||||
openapi.Describe("/v1/billing/credit-balance", http.MethodGet,
|
||||
"What is left of your credit, as one number",
|
||||
"Returns the total credit still available to the caller's own subject — the sum of what "+
|
||||
"the grants have left, which is the figure the console shows above the usage meter. "+
|
||||
"It is the balance a metered act draws down, so it answers the one question a "+
|
||||
"customer asks before spending: how much is there.\n\n"+
|
||||
"Like every read in this family the subject is pinned to the caller before the "+
|
||||
"handler runs, so the userId parameter the handler reads can never name another "+
|
||||
"tenant. For the grants BEHIND this number — each with its original amount and its "+
|
||||
"expiry — read /v1/billing/credits. A subject with no credit is zero, which is an "+
|
||||
"answer and not an error.")
|
||||
|
||||
openapi.Describe("/v1/billing/accounts", http.MethodGet,
|
||||
"The billing account you are signed in to",
|
||||
"Returns the billing accounts visible to the caller. One organisation is exactly one "+
|
||||
"billing account here, so an authenticated caller sees precisely one: their own. "+
|
||||
"The list shape is the honest one — it is what a caller with access to several "+
|
||||
"would receive — rather than a promise that more will ever appear for a token "+
|
||||
"scoped to a single org.\n\n"+
|
||||
"The account is derived from the validated org claim and from nothing the caller "+
|
||||
"sends, so there is no account parameter and a cross-tenant read is not "+
|
||||
"expressible. An unauthenticated call is 401.")
|
||||
|
||||
openapi.Describe("/v1/billing/accounts/:id/members", http.MethodGet,
|
||||
"Who is on a billing account",
|
||||
"Returns the members of one billing account. The id must be the caller's OWN account — "+
|
||||
"the handler compares it against the org resolved from the token and answers 403 "+
|
||||
"when they differ, which is what guards this route: unlike its siblings it carries "+
|
||||
"no subject key for the pin to overwrite, so it checks the path segment itself.\n\n"+
|
||||
"The roster it can answer is currently the requesting user alone. Membership lives "+
|
||||
"in IAM, not in the ledger, and this operation reports what commerce actually holds "+
|
||||
"rather than inventing a roster from a source it does not read. An unauthenticated "+
|
||||
"call is 401.")
|
||||
|
||||
openapi.Describe("/v1/billing/payouts", http.MethodGet,
|
||||
"List your org's payouts, newest first",
|
||||
"Returns the caller org's payout records ordered by creation time descending, read from "+
|
||||
|
||||
+26
-7
@@ -39,6 +39,13 @@ var _ creditledger.CreditLedger = ledger{}
|
||||
// with (account.Payer, via principal), so a credit and the spend it funds land on one
|
||||
// wallet by construction. An org whose members share one balance names no subject and
|
||||
// is byte-for-byte unchanged.
|
||||
//
|
||||
// AND Test IS THE THIRD PART OF THAT ADDRESS. finance keeps sandbox money in a
|
||||
// separate file per org, so which books to write is not a mode this adapter can leave
|
||||
// unsaid — it was saying "live" for every credit that came through here, which put a
|
||||
// sandbox tenant's grant in the books the gate spends real inference from. It is the
|
||||
// input's, unchanged, because the caller is the one that knows whether the charge it
|
||||
// is crediting was a sandbox charge.
|
||||
func (ledger) Credit(ctx context.Context, in creditledger.CreditInput) (string, int64, error) {
|
||||
fin := finance.Current()
|
||||
if fin == nil {
|
||||
@@ -64,22 +71,31 @@ func (ledger) Credit(ctx context.Context, in creditledger.CreditInput) (string,
|
||||
Notes: in.Reason,
|
||||
Tags: tag,
|
||||
Ref: in.IdempotencyKey,
|
||||
Test: in.Test,
|
||||
})
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
// Read back the account that was CREDITED, never the pool — reporting a pool
|
||||
// balance after crediting a member is how a caller concludes the grant vanished.
|
||||
bal, berr := fin.Balance(ctx, in.Org, subject, cur, false)
|
||||
// Read back the account that was CREDITED, never the pool and never the other
|
||||
// books — reporting a pool balance after crediting a member, or a live balance
|
||||
// after crediting the sandbox, is how a caller concludes the grant vanished. The
|
||||
// read repeats the deposit's whole address, one value at a time.
|
||||
bal, berr := fin.Balance(ctx, in.Org, subject, cur, in.Test)
|
||||
if berr != nil {
|
||||
return id, 0, berr
|
||||
}
|
||||
return id, bal.Cents(), nil
|
||||
}
|
||||
|
||||
// Balance returns the org pool's available balance in cents for currency — the
|
||||
// same read the AI gate performs, so GET /v1/billing/balance and the gate agree.
|
||||
func (ledger) Balance(ctx context.Context, org, currency string) (int64, error) {
|
||||
// Balance returns the available balance in cents held at (org, subject) in currency,
|
||||
// from the sandbox books when test — the same read the AI gate performs, so
|
||||
// GET /v1/billing/balance and the gate agree.
|
||||
//
|
||||
// It takes the whole address for the reason Credit writes it: an empty subject is the
|
||||
// org's pool, and a named one is the member's own wallet. Answering the pool for a
|
||||
// member's read is answering about a different account, and it answers without
|
||||
// erroring — which is the shape of a balance bug nobody notices.
|
||||
func (ledger) Balance(ctx context.Context, org, subject, currency string, test bool) (int64, error) {
|
||||
fin := finance.Current()
|
||||
if fin == nil {
|
||||
return 0, fmt.Errorf("commerce balance: no finance ledger co-resident")
|
||||
@@ -87,7 +103,10 @@ func (ledger) Balance(ctx context.Context, org, currency string) (int64, error)
|
||||
if currency == "" {
|
||||
currency = "usd"
|
||||
}
|
||||
bal, err := fin.Balance(ctx, org, org, currency, false)
|
||||
if subject == "" {
|
||||
subject = org // pooled org: the slug IS the pool account the gate reads
|
||||
}
|
||||
bal, err := fin.Balance(ctx, org, subject, currency, test)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// Copyright © 2026 Hanzo AI. MIT License.
|
||||
|
||||
package commerce
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
accountclient "github.com/hanzoai/cloud/apps/account"
|
||||
commercebilling "github.com/hanzoai/commerce/api/billing"
|
||||
commercemid "github.com/hanzoai/commerce/middleware"
|
||||
"github.com/hanzoai/commerce/middleware/iammiddleware"
|
||||
)
|
||||
|
||||
// The customer's own ledger reads — transactions, credit balance, and the
|
||||
// billing account (with its members). billing.hanzo.ai calls all four; none was
|
||||
// mounted, so all four answered 404 and the Transactions, Credits, Team and
|
||||
// Settings tabs were permanently empty.
|
||||
//
|
||||
// What makes that failure worth a test is that it was INVISIBLE to the obvious
|
||||
// check. commerce declares these routes itself, on its api.Route() `user` group
|
||||
// (api/billing/handlers.go), so grepping the module finds them wired and the
|
||||
// deployment looks merely stale. It is not: the co-resident embed registers on
|
||||
// the HOST's router and never compiles that table, so a commerce route exists in
|
||||
// production only if Mount names it. This test asserts the naming, which is the
|
||||
// thing that was actually missing — a library-side grep cannot.
|
||||
//
|
||||
// The assertion is 401-not-404, and the distinction is the whole point. Both are
|
||||
// "no data" to a browser, but 404 means the gate never ran and 401 means it ran
|
||||
// and refused. Only the second proves the route reached its middleware.
|
||||
|
||||
// ledgerApp mounts the four reads exactly as Mount does — same middleware, same
|
||||
// order. A test that assembles a different chain proves only that the chain it
|
||||
// invented behaves.
|
||||
func ledgerApp(t *testing.T) *zip.App {
|
||||
t.Helper()
|
||||
app := zip.New(zip.Config{})
|
||||
app.Get("/v1/billing/transactions",
|
||||
commercemid.RequestContext(),
|
||||
iammiddleware.IAMTokenRequired(),
|
||||
accountclient.PinBillingSubject(),
|
||||
commercemid.TokenRequired(),
|
||||
commercebilling.ListTransactions,
|
||||
)
|
||||
app.Get("/v1/billing/credit-balance",
|
||||
commercemid.RequestContext(),
|
||||
iammiddleware.IAMTokenRequired(),
|
||||
accountclient.PinBillingSubject(),
|
||||
commercemid.TokenRequired(),
|
||||
commercebilling.GetCreditBalance,
|
||||
)
|
||||
app.Get("/v1/billing/accounts",
|
||||
commercemid.RequestContext(),
|
||||
iammiddleware.IAMTokenRequired(),
|
||||
accountclient.PinBillingSubject(),
|
||||
commercemid.TokenRequired(),
|
||||
commercebilling.ListBillingAccounts,
|
||||
)
|
||||
app.Get("/v1/billing/accounts/:id/members",
|
||||
commercemid.RequestContext(),
|
||||
iammiddleware.IAMTokenRequired(),
|
||||
accountclient.PinBillingSubject(),
|
||||
commercemid.TokenRequired(),
|
||||
commercebilling.ListAccountMembers,
|
||||
)
|
||||
return app
|
||||
}
|
||||
|
||||
// TestLedgerReads_MountedAndFailClosed — each read must be reachable and must
|
||||
// refuse an anonymous caller. 404 is the production bug this replaces: the route
|
||||
// absent, the gate never reached, the tab empty with nothing to explain it.
|
||||
func TestLedgerReads_MountedAndFailClosed(t *testing.T) {
|
||||
app := ledgerApp(t)
|
||||
for _, path := range []string{
|
||||
"/v1/billing/transactions",
|
||||
"/v1/billing/credit-balance",
|
||||
"/v1/billing/accounts",
|
||||
"/v1/billing/accounts/acme/members",
|
||||
} {
|
||||
resp, err := app.Test(httptest.NewRequest("GET", path, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", path, err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == 404 {
|
||||
t.Fatalf("GET %s answered 404 — the route is not mounted, so the auth gate never ran", path)
|
||||
}
|
||||
if resp.StatusCode != 401 {
|
||||
t.Fatalf("GET %s: want 401 for an anonymous caller, got %d (%s)", path, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLedgerReads_ForgedSubjectIsRefused — the two reads that filter on a
|
||||
// caller-supplied subject key (ListTransactions on ?user, GetCreditBalance on
|
||||
// ?userId) must not answer a request that names someone else. Unpinned, both
|
||||
// return that subject's rows out of the org namespace, which is the leak the
|
||||
// chain exists to close; anonymous, the pin fail-closes before the handler runs,
|
||||
// so a forged subject buys nothing. Asserted against a hostile request rather
|
||||
// than a well-formed one, because a well-formed one cannot fail this way.
|
||||
func TestLedgerReads_ForgedSubjectIsRefused(t *testing.T) {
|
||||
app := ledgerApp(t)
|
||||
for _, path := range []string{
|
||||
"/v1/billing/transactions?user=victim",
|
||||
"/v1/billing/credit-balance?userId=victim",
|
||||
} {
|
||||
resp, err := app.Test(httptest.NewRequest("GET", path, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", path, err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 401 {
|
||||
t.Fatalf("GET %s: a forged subject must be refused with 401, got %d (%s)",
|
||||
path, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// Copyright © 2026 Hanzo AI. MIT License.
|
||||
|
||||
package commerce
|
||||
|
||||
// ledger_test.go pins what the creditledger ADAPTER translates: commerce names an
|
||||
// address, and this is the one place that address becomes a finance call.
|
||||
//
|
||||
// The address has three parts — (Org, Subject, Test) — and the adapter used to drop
|
||||
// the third on the floor. `Test` was never passed to Deposit and both balance reads
|
||||
// hardcoded `false`, so every credit commerce minted through this seam landed in the
|
||||
// LIVE books whatever the caller meant, and a sandbox tenant's grant became money the
|
||||
// AI spend gate buys real inference with. finance keeps sandbox money in a separate
|
||||
// file per org, so this is not a flag being ignored — it is a different ledger.
|
||||
//
|
||||
// It asserts against a RECORDING finance client rather than a real one on purpose.
|
||||
// What changed here is the translation, and a recording seam states the arguments
|
||||
// exactly; that finance then routes (org, test) to the right file is finance's own
|
||||
// property and is covered where it lives (apps/finance).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/commerce/billing/creditledger"
|
||||
|
||||
"github.com/hanzoai/cloud/apps/finance"
|
||||
"github.com/hanzoai/cloud/money"
|
||||
"github.com/hanzoai/cloud/types"
|
||||
)
|
||||
|
||||
// recorder is a finance client that answers plausibly and remembers exactly what it
|
||||
// was asked. Balances are held per WHOLE address, so a deposit into one book is not
|
||||
// visible from a read of the other — the property the adapter has to get right.
|
||||
type recorder struct {
|
||||
deposits []types.DepositInput
|
||||
reads []readArgs
|
||||
bal map[readArgs]int64
|
||||
}
|
||||
|
||||
// readArgs is one balance read's whole address. It is also the deposits' key, so the
|
||||
// two can only meet when every component agrees.
|
||||
type readArgs struct {
|
||||
org string
|
||||
subject string
|
||||
currency string
|
||||
test bool
|
||||
}
|
||||
|
||||
var _ types.FinanceClient = (*recorder)(nil)
|
||||
|
||||
func newRecorder() *recorder { return &recorder{bal: map[readArgs]int64{}} }
|
||||
|
||||
func (r *recorder) Deposit(_ context.Context, in types.DepositInput) (string, error) {
|
||||
r.deposits = append(r.deposits, in)
|
||||
k := readArgs{in.Org, in.Subject, in.Currency, in.Test}
|
||||
r.bal[k] += in.Amount.Cents()
|
||||
return "dep_1", nil
|
||||
}
|
||||
|
||||
func (r *recorder) Balance(_ context.Context, org, subject, currency string, test bool) (money.Amount, error) {
|
||||
k := readArgs{org, subject, currency, test}
|
||||
r.reads = append(r.reads, k)
|
||||
return money.FromCents(r.bal[k]), nil
|
||||
}
|
||||
|
||||
func (r *recorder) RecordUsage(context.Context, types.UsageInput) error { return nil }
|
||||
|
||||
func (r *recorder) SumUsageSince(context.Context, string, bool, int64) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// seam is the adapter under test, bound once. A composite literal cannot start an
|
||||
// if-statement's expression, and naming it also says what it is: the ONE value
|
||||
// commerce is handed as its credit ledger.
|
||||
var seam = ledger{}
|
||||
|
||||
// recording publishes the recorder as the process finance client for one test.
|
||||
func recording(t *testing.T) *recorder {
|
||||
t.Helper()
|
||||
r := newRecorder()
|
||||
prev := finance.Current()
|
||||
finance.Publish(r)
|
||||
t.Cleanup(func() { finance.Publish(prev) })
|
||||
return r
|
||||
}
|
||||
|
||||
// A sandbox credit writes the SANDBOX books, and a live credit the live ones — the
|
||||
// deposit AND the balance read the caller is answered with.
|
||||
//
|
||||
// The read-back matters as much as the write: it is the number the credit door hands
|
||||
// back to its caller, and reading the live books after crediting the sandbox ones
|
||||
// reports a balance that has nothing to do with the credit just made. That was the
|
||||
// state of both call sites.
|
||||
func TestLedgerAdapter_TestRoutesToTheSandboxBooks(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
test bool
|
||||
}{
|
||||
{"a sandbox credit", true},
|
||||
{"a live credit", false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rec := recording(t)
|
||||
|
||||
_, cents, err := seam.Credit(context.Background(), creditledger.CreditInput{
|
||||
Org: "acme",
|
||||
Subject: "acme/alice",
|
||||
Currency: "usd",
|
||||
AmountCents: 2500,
|
||||
IdempotencyKey: "pay_1",
|
||||
Test: tc.test,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("credit: %v", err)
|
||||
}
|
||||
|
||||
if len(rec.deposits) != 1 {
|
||||
t.Fatalf("deposits=%d, want 1", len(rec.deposits))
|
||||
}
|
||||
if got := rec.deposits[0].Test; got != tc.test {
|
||||
t.Errorf("DepositInput.Test=%v, want %v — the credit went into the wrong books", got, tc.test)
|
||||
}
|
||||
if got := rec.deposits[0].Org; got != "acme" {
|
||||
t.Errorf("DepositInput.Org=%q, want acme", got)
|
||||
}
|
||||
if got := rec.deposits[0].Subject; got != "acme/alice" {
|
||||
t.Errorf("DepositInput.Subject=%q, want acme/alice", got)
|
||||
}
|
||||
|
||||
// The read-back repeats the deposit's WHOLE address.
|
||||
if len(rec.reads) != 1 {
|
||||
t.Fatalf("balance reads=%d, want 1", len(rec.reads))
|
||||
}
|
||||
want := readArgs{"acme", "acme/alice", "usd", tc.test}
|
||||
if rec.reads[0] != want {
|
||||
t.Errorf("balance read %+v, want %+v — the credit was read back from a different account", rec.reads[0], want)
|
||||
}
|
||||
if cents != 2500 {
|
||||
t.Errorf("returned balance=%d, want 2500 — the caller was answered from books it did not credit", cents)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The two books do not see each other. A sandbox credit leaves the live balance at
|
||||
// zero, which is the whole reason `test` is part of the address and not a flag.
|
||||
func TestLedgerAdapter_SandboxMoneyIsNotSpendable(t *testing.T) {
|
||||
recording(t)
|
||||
|
||||
if _, _, err := seam.Credit(context.Background(), creditledger.CreditInput{
|
||||
Org: "acme", Subject: "acme/alice", Currency: "usd",
|
||||
AmountCents: 9900, IdempotencyKey: "pay_sandbox", Test: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("credit: %v", err)
|
||||
}
|
||||
|
||||
live, err := seam.Balance(context.Background(), "acme", "acme/alice", "usd", false)
|
||||
if err != nil {
|
||||
t.Fatalf("live balance: %v", err)
|
||||
}
|
||||
if live != 0 {
|
||||
t.Fatalf("LIVE balance=%d after a SANDBOX credit, want 0 — sandbox money became spendable", live)
|
||||
}
|
||||
|
||||
sandbox, err := seam.Balance(context.Background(), "acme", "acme/alice", "usd", true)
|
||||
if err != nil {
|
||||
t.Fatalf("sandbox balance: %v", err)
|
||||
}
|
||||
if sandbox != 9900 {
|
||||
t.Fatalf("sandbox balance=%d, want 9900", sandbox)
|
||||
}
|
||||
}
|
||||
|
||||
// Balance reads the account it is ASKED for, and an empty subject is the org's pool —
|
||||
// the same default Credit applies, so the two halves address one wallet.
|
||||
//
|
||||
// Its signature used to be (org, currency) with the org passed for the subject too,
|
||||
// which meant a member-scoped read opened a LEDGER named after the member: a
|
||||
// different file, holding nothing, answering a funded customer with a confident zero.
|
||||
func TestLedgerAdapter_BalanceReadsTheAccountItIsAsked(t *testing.T) {
|
||||
rec := recording(t)
|
||||
|
||||
// The pool holds one amount and a member another, in the same org and books.
|
||||
for _, seed := range []struct {
|
||||
subject string
|
||||
cents int64
|
||||
}{{"acme", 100}, {"acme/alice", 700}} {
|
||||
if _, _, err := seam.Credit(context.Background(), creditledger.CreditInput{
|
||||
Org: "acme", Subject: seed.subject, Currency: "usd",
|
||||
AmountCents: seed.cents, IdempotencyKey: "seed:" + seed.subject,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed %s: %v", seed.subject, err)
|
||||
}
|
||||
}
|
||||
rec.reads = nil
|
||||
|
||||
pool, err := seam.Balance(context.Background(), "acme", "", "usd", false)
|
||||
if err != nil {
|
||||
t.Fatalf("pool balance: %v", err)
|
||||
}
|
||||
if pool != 100 {
|
||||
t.Errorf("pool balance=%d, want 100 — an empty subject must be the org's own account", pool)
|
||||
}
|
||||
if got := rec.reads[0]; got.subject != "acme" {
|
||||
t.Errorf("pool read addressed subject %q, want acme", got.subject)
|
||||
}
|
||||
|
||||
member, err := seam.Balance(context.Background(), "acme", "acme/alice", "usd", false)
|
||||
if err != nil {
|
||||
t.Fatalf("member balance: %v", err)
|
||||
}
|
||||
if member != 700 {
|
||||
t.Errorf("member balance=%d, want 700 — the read answered a different account than the credit funded", member)
|
||||
}
|
||||
if got := rec.reads[1]; got.org != "acme" || got.subject != "acme/alice" {
|
||||
t.Errorf("member read addressed (%q,%q), want (acme, acme/alice) — the subject was used as the ledger", got.org, got.subject)
|
||||
}
|
||||
}
|
||||
@@ -795,6 +795,71 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
|
||||
commercebilling.DeleteSpendAlert,
|
||||
)
|
||||
|
||||
// THE CUSTOMER'S OWN LEDGER — the four reads billing.hanzo.ai's Transactions,
|
||||
// Credits, Team and Settings tabs call, and which NOTHING in this binary served.
|
||||
//
|
||||
// commerce declares them on its api.Route() `user` group (api/billing/handlers.go),
|
||||
// but that route table is never compiled here: the co-resident embed registers on
|
||||
// the HOST's router, so a commerce route reaches production only if this file names
|
||||
// it. The library had the handlers all along — so the symptom read as a stale image
|
||||
// and was not one. Every unnamed route falls through to the account bridge's
|
||||
// /v1/billing/* wildcard, whose allowlist does not include these four, so all four
|
||||
// answered a bare 404 and four tabs of the billing app rendered empty forever.
|
||||
//
|
||||
// THE CHAIN IS GetTier's, and each link earns its place on a MONEY READ:
|
||||
// - IAMTokenRequired resolves the org from the gateway-validated X-User-Id +
|
||||
// X-Org-Id into Locals("organization") — the namespace every handler reads.
|
||||
// It FALLS THROUGH when there is no validated principal rather than refusing.
|
||||
// - PinBillingSubject is therefore both the gate and the IDOR control. It
|
||||
// fail-closes that fall-through with 401 "sign in to view billing" (401, not
|
||||
// 403 — a browser re-authenticates on 401 and merely reports 403), and it
|
||||
// OVERWRITES every billing subject key {user,userId,customerId} with the
|
||||
// caller's own account.Payer subject. That is load-bearing here and not
|
||||
// decoration: ListTransactions filters on ?user and GetCreditBalance on
|
||||
// ?userId, both unpinned client values — an unpinned read returns every
|
||||
// subject's rows in the org namespace. Because the pin SETS the key rather
|
||||
// than merely validating it, the handlers' "required parameter" 400 can never
|
||||
// fire for a real caller, and the subject is exactly the one account.Payer
|
||||
// debits, so a read can never disagree with the wallet it describes.
|
||||
// - TokenRequired (no masks) runs AFTER the pin so the trusted S2S reader still
|
||||
// resolves an org — IAMTokenRequired admits only IAM principals and would
|
||||
// leave GetOrganization unset for a service token, which panics on a nil
|
||||
// Locals assertion. With no masks it passes any authenticated principal, so
|
||||
// the browser path is unchanged.
|
||||
//
|
||||
// accounts/:id/members carries no subject key and guards itself (:id must equal
|
||||
// the resolved org, else 403), but it takes the same chain: one chain for the
|
||||
// family, and the pin is what turns an anonymous call into 401 instead of a panic.
|
||||
// None is typed yet — module work, per the module-handler note.
|
||||
app.Get("/v1/billing/transactions",
|
||||
commercemid.RequestContext(),
|
||||
iammiddleware.IAMTokenRequired(),
|
||||
accountclient.PinBillingSubject(),
|
||||
commercemid.TokenRequired(),
|
||||
commercebilling.ListTransactions,
|
||||
)
|
||||
app.Get("/v1/billing/credit-balance",
|
||||
commercemid.RequestContext(),
|
||||
iammiddleware.IAMTokenRequired(),
|
||||
accountclient.PinBillingSubject(),
|
||||
commercemid.TokenRequired(),
|
||||
commercebilling.GetCreditBalance,
|
||||
)
|
||||
app.Get("/v1/billing/accounts",
|
||||
commercemid.RequestContext(),
|
||||
iammiddleware.IAMTokenRequired(),
|
||||
accountclient.PinBillingSubject(),
|
||||
commercemid.TokenRequired(),
|
||||
commercebilling.ListBillingAccounts,
|
||||
)
|
||||
app.Get("/v1/billing/accounts/:id/members",
|
||||
commercemid.RequestContext(),
|
||||
iammiddleware.IAMTokenRequired(),
|
||||
accountclient.PinBillingSubject(),
|
||||
commercemid.TokenRequired(),
|
||||
commercebilling.ListAccountMembers,
|
||||
)
|
||||
|
||||
// POST /v1/billing/topup/token — the INLINE Square card top-up (the console's
|
||||
// "Billing → Credits → add credits": the Square Web Payments SDK tokenizes the card
|
||||
// IN THE BROWSER → a single-use nonce → this endpoint charges it and credits the
|
||||
|
||||
@@ -112,11 +112,14 @@ package commerce
|
||||
// in the same one — a sandbox charge can never fund live inference, and a live
|
||||
// charge is never parked in books no gate reads.
|
||||
//
|
||||
// This is why the credit is posted through finance's own Deposit rather than
|
||||
// through commerce's injected creditledger adapter (ledger.go): CreditInput has no
|
||||
// test field, so that seam cannot express which books to write, and routing a
|
||||
// sandbox settlement through it would put unspendable sandbox money in the live
|
||||
// ledger. Same ledger, same idempotency, one field the seam cannot carry.
|
||||
// The credit is posted through finance's own Deposit rather than through commerce's
|
||||
// injected creditledger adapter (ledger.go) because of the ADDRESS, not the books:
|
||||
// the seam carries the test bit now, but it is commerce's door onto its own credit,
|
||||
// and the address this file deposits at is (p.ledger, p.subject) — the payer the
|
||||
// SCREEN resolved from the request's principal, which is a value commerce cannot
|
||||
// compute for itself. Going through the adapter would mean handing it back the
|
||||
// answer it exists to ask for. Same ledger, same idempotency, one address resolved
|
||||
// where the identity is.
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
+43
-1
@@ -144,6 +144,7 @@ var (
|
||||
errIllegalTransition = errors.New("content: illegal transition")
|
||||
errModuleNotInstalled = errors.New("content: marketing module not installed for org")
|
||||
errInvalidSource = errors.New("content: invalid source_media")
|
||||
errPublishBusy = errors.New("content: another publisher holds this item")
|
||||
)
|
||||
|
||||
// ---- typed ops ----
|
||||
@@ -449,6 +450,40 @@ func Transition(ctx context.Context, org, doctype, name, to, scheduleAt string)
|
||||
return TransitionResult{}, fmt.Errorf("%w: %q", errUnknownStatus, to)
|
||||
}
|
||||
|
||||
// On the ONE edge that distributes, the item's publish lease covers this WHOLE
|
||||
// function — read, edge-check, status write and fan-out — not just the fan-out.
|
||||
//
|
||||
// The status write below carries the entire document, external_ids included, and
|
||||
// it cannot carry less: UpdateData replaces the document, so a field left out of
|
||||
// the map is deleted rather than preserved. The snapshot it writes is read at the
|
||||
// top of this function, BEFORE any fan-out. Two concurrent transitions to
|
||||
// published therefore interleave as: both read an empty skip-set, A publishes and
|
||||
// records its external_ids, B's stale snapshot writes that skip-set back to empty,
|
||||
// and B's fan-out — itself correctly leased, correctly re-reading — finds nothing
|
||||
// to skip and posts the item a second time. The lease inside Publish cannot see
|
||||
// this, because the erasure happens outside it. Measured at ~7% of runs before
|
||||
// this widened, which is a gate that reddens at random rather than a bug anyone
|
||||
// could reproduce on demand.
|
||||
//
|
||||
// Holding it here makes the loser's read happen after the winner's record: it sees
|
||||
// status=published (a legal no-op edge, CanTransition returns true for from==to),
|
||||
// re-stamps the same status, and its fan-out skips every channel already on record.
|
||||
// One post, both callers succeed.
|
||||
if entersDistribution(to) {
|
||||
lease, ok, err := framework.AcquireLease(ctx, org, publishLeaseKey(doctype, name), publishLeaseTTL, publishLeaseWait)
|
||||
if err != nil {
|
||||
return TransitionResult{}, err
|
||||
}
|
||||
if !ok {
|
||||
// A live publisher held the item for the whole wait window. Refusing is the
|
||||
// honest answer and the safe one: this call cannot write the document without
|
||||
// erasing ids that publisher is still recording, so it writes nothing and says
|
||||
// so. 409, retryable — the caller re-transitions and takes the no-op path.
|
||||
return TransitionResult{}, errPublishBusy
|
||||
}
|
||||
defer func() { _ = lease.Release(ctx) }()
|
||||
}
|
||||
|
||||
doc, err := framework.Get(ctx, org, doctype, name)
|
||||
if err != nil {
|
||||
return TransitionResult{}, err
|
||||
@@ -474,7 +509,9 @@ func Transition(ctx context.Context, org, doctype, name, to, scheduleAt string)
|
||||
|
||||
res := TransitionResult{DocType: doctype, Name: name, From: from, To: to}
|
||||
if entersDistribution(to) {
|
||||
pr, perr := Publish(ctx, org, PublishInput{DocType: doctype, Name: name, ScheduleAt: scheduleAt})
|
||||
// publishHeld, not Publish: this call already holds the item's publish lease
|
||||
// (above), and the lease is not reentrant.
|
||||
pr, perr := publishHeld(ctx, org, PublishInput{DocType: doctype, Name: name, ScheduleAt: scheduleAt})
|
||||
if perr != nil {
|
||||
// Never fatal — the status IS updated; distribution can be retried.
|
||||
s.Log.Warn("distribution on transition failed (status updated)",
|
||||
@@ -610,6 +647,11 @@ func opErr(err error) error {
|
||||
return zip.ErrBadRequest(err.Error())
|
||||
case errors.Is(err, errIllegalTransition):
|
||||
return zip.Errorf(http.StatusConflict, "%v", err)
|
||||
case errors.Is(err, errPublishBusy):
|
||||
// Same 409 the illegal edge answers with, for the same reason: the request
|
||||
// conflicts with the item's current state. Nothing was written and nothing was
|
||||
// posted, so retrying is safe and is the expected response.
|
||||
return zip.Errorf(http.StatusConflict, "%v", err)
|
||||
case errors.Is(err, framework.ErrNotFound):
|
||||
return zip.ErrNotFound("content item not found")
|
||||
case errors.Is(err, framework.ErrConflict):
|
||||
|
||||
@@ -176,6 +176,29 @@ func Publish(ctx context.Context, org string, in PublishInput) (PublishResult, e
|
||||
}
|
||||
defer func() { _ = lease.Release(ctx) }()
|
||||
|
||||
return publishHeld(ctx, org, in)
|
||||
}
|
||||
|
||||
// publishHeld is the fan-out itself: read the skip-set, post what is missing, record
|
||||
// what came back. THE CALLER HOLDS the item's publish lease (publishLeaseKey) for the
|
||||
// whole of it — that is the precondition the idempotency argument above rests on, and
|
||||
// it is a precondition rather than something this function takes itself because the
|
||||
// section that must be serialized is LARGER than the fan-out for one caller.
|
||||
//
|
||||
// Transition is that caller. Its status write carries the whole document (UpdateData
|
||||
// replaces it — a field left out of the map is dropped, so external_ids cannot simply
|
||||
// be omitted), and the snapshot it writes is read BEFORE the fan-out. Leasing only the
|
||||
// fan-out therefore cannot see the erasure: publisher A records external_ids and
|
||||
// releases, B's already-stale snapshot writes the skip-set back to empty, and B's
|
||||
// fan-out — correctly leased, correctly re-reading — finds nothing to skip and posts
|
||||
// the item a second time. Whoever must serialize the write serializes the fan-out with
|
||||
// it, on the same key, in one section.
|
||||
func publishHeld(ctx context.Context, org string, in PublishInput) (PublishResult, error) {
|
||||
s := mounted
|
||||
if s == nil {
|
||||
return PublishResult{}, errNotMounted
|
||||
}
|
||||
|
||||
doc, err := framework.Get(ctx, org, in.DocType, in.Name)
|
||||
if err != nil {
|
||||
return PublishResult{}, err
|
||||
|
||||
+17
-6
@@ -9,10 +9,14 @@ package crawl
|
||||
// than one that fails, because nothing upstream can tell.
|
||||
//
|
||||
// So: static first, and if what came back is too thin to be the page, ask Hanzo
|
||||
// Crawl (headless Chromium, ghcr.io/hanzoai/crawl) for the rendered version.
|
||||
// Escalation is one-way and best-effort — if the browser is absent, slow or
|
||||
// unhappy, the static Page still stands. That keeps a working crawl working
|
||||
// while the browser is not deployed, which is the state this ships in.
|
||||
// Crawl (headless Chromium, ghcr.io/hanzoai/crawl) for the rendered version. It
|
||||
// runs at crawl.hanzo.svc:11235 — the address browserEndpoint returns — and it
|
||||
// requires the bearer token below.
|
||||
//
|
||||
// Escalation is one-way and best-effort: if the browser is slow or unhappy, the
|
||||
// static Page still stands. That is a property worth keeping even though the
|
||||
// service is up, because "the render failed" must never turn a page we already
|
||||
// have into an error.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -183,8 +187,15 @@ func browse(ctx context.Context, raw string) (*Page, error) {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// Token if the deployment has one; the network policy is the real boundary,
|
||||
// so its absence is not a reason to skip rendering.
|
||||
// The service REQUIRES this: unauthenticated it answers
|
||||
// {"detail":"Authentication required"}, so a missing token is not a degraded
|
||||
// render, it is no render at all. It reaches both pods from KMS as the
|
||||
// crawl-secrets/CRAWL_API_TOKEN key.
|
||||
//
|
||||
// Sent when present rather than demanded up front because the failure is
|
||||
// already well reported — the request returns a non-2xx, escalate() keeps the
|
||||
// static Page, and websearch counts the engine blind. One boundary, one
|
||||
// refusal, read in one place.
|
||||
if tok := strings.TrimSpace(os.Getenv("CRAWL_API_TOKEN")); tok != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
}
|
||||
|
||||
+8
-6
@@ -3,14 +3,16 @@
|
||||
// Hanzo Crawl: fetch one URL and return its readable content as markdown,
|
||||
// in-process, in Go.
|
||||
//
|
||||
// It replaces the dial to a separate Crawl4AI deployment. That service was named
|
||||
// in config but did not exist — crawl.hanzo.svc.cluster.local was NXDOMAIN — so
|
||||
// every scrape returned {success:false, "no such host"} while the surface in front
|
||||
// of it answered 200. This package is the same move already made for the SEARCH
|
||||
// half in clients/websearch/search.go, which replaced a SearXNG pod with in-process
|
||||
// Go: one fewer non-Go dependency, one fewer thing that can be down, and no network
|
||||
// The FETCH is in-process Go rather than a dial to a separate service, which is
|
||||
// the same move already made for the SEARCH half in apps/websearch/search.go:
|
||||
// one fewer non-Go dependency, one fewer thing that can be down, and no network
|
||||
// hop for work that is a fetch and a parse.
|
||||
//
|
||||
// A browser still lives out of process, because rendering is not a fetch and a
|
||||
// parse. `crawl` (ghcr.io/hanzoai/crawl, headless Chromium) is deployed at
|
||||
// crawl.hanzo.svc:11235 and browser.go escalates to it for the pages this file
|
||||
// cannot read alone.
|
||||
//
|
||||
// The engine is three orthogonal steps, each in its own file and each testable
|
||||
// without the others:
|
||||
//
|
||||
|
||||
+249
-117
@@ -1,17 +1,16 @@
|
||||
package crawl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/apps/principal"
|
||||
"github.com/hanzoai/cloud/openapi"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
@@ -37,55 +36,59 @@ type crawlRequest struct {
|
||||
// error handling honest — a 200 means the surface worked, and Success says what it
|
||||
// found.
|
||||
type crawlResult struct {
|
||||
Success bool `json:"success"`
|
||||
Data *crawlDocument `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// Success is whether the page was fetched and read. FALSE with an Error is a
|
||||
// complete answer, not a fault — check this before reading Data.
|
||||
Success bool `json:"success"`
|
||||
// Data is the page, present exactly when Success.
|
||||
Data *crawlDocument `json:"data,omitempty"`
|
||||
// Error says what stopped the fetch: the host was refused, unreachable, or
|
||||
// served something that is not a document.
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// StatusCode is how this answer states the ONE non-2xx it carries as a domain
|
||||
// body: a request with no url is 400 `{"success":false,"error":"missing url"}`,
|
||||
// which is what the untyped handler answered and what its callers parse. Every
|
||||
// other outcome — including a fetch that failed — is 200, because the surface
|
||||
// worked and Success says what it found.
|
||||
//
|
||||
// It is the sanctioned way to spell this (zip typed.go, StatusCoder): the status
|
||||
// rides the value the handler already returns, so the document publishes 400 with
|
||||
// THIS schema and a generated client expects the body it will actually get. The
|
||||
// alternative, returning zip.ErrBadRequest, renders the flat {status,code,error}
|
||||
// envelope — a different body for a refusal this route has always answered in its
|
||||
// own shape.
|
||||
func (r *crawlResult) StatusCode() int {
|
||||
if r.Success {
|
||||
return http.StatusOK
|
||||
}
|
||||
if r.Error == missingURL {
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
return http.StatusOK
|
||||
}
|
||||
|
||||
// missingURL is the refusal spelled once, because [crawlResult.StatusCode]
|
||||
// compares against it and the handler produces it.
|
||||
const missingURL = "missing url"
|
||||
|
||||
// crawlDocument is the crawled page.
|
||||
type crawlDocument struct {
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Markdown string `json:"markdown"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// This route is NOT a typed op, and it cannot become one without moving the wire.
|
||||
// Two independent facts keep it out, both measured by TestCrawlRefusalIsTheWire:
|
||||
//
|
||||
// 1. It is deliberately BODY-TOLERANT, and its refusal carries a DOMAIN body. A
|
||||
// malformed body and an empty url are the SAME answer here — 400 with
|
||||
// `{"success":false,"error":"missing url"}`, this package's own shape. zip's
|
||||
// op.invoke 400s on any unparseable non-empty body before a handler runs, with
|
||||
// zip's flat HTTPError (`{status,code,error}`) — a different body — and a typed
|
||||
// op's only way to refuse is to RETURN an error, which takes that same shape.
|
||||
// So neither branch of today's 400 is expressible.
|
||||
// 2. The body is read through io.LimitReader(r.Body, 1<<20). A typed op receives
|
||||
// its DECODED In and never sees the raw length, so the 1 MiB bound would be
|
||||
// silently dropped — cloud's global zip BodyLimit is far larger, and this
|
||||
// surface fetches a caller-chosen URL from inside the cluster.
|
||||
//
|
||||
// Staying untyped costs exactly three things — the prose, the MCP tool and the CLI
|
||||
// command zip's registry supplies — and it must not also cost a document that says
|
||||
// this route takes no body. The declaration below is what buys that back: it is the
|
||||
// same reflection over the same structs the handler binds, so it cannot drift.
|
||||
func init() {
|
||||
openapi.Register("/v1/crawl", http.MethodPost, crawlRequest{}, crawlResult{})
|
||||
openapi.Describe("/v1/crawl", http.MethodPost,
|
||||
"Fetch one URL and read it back as markdown",
|
||||
"Fetches a single URL from inside the cluster and answers with the page's title, its "+
|
||||
"content rendered to markdown, and whatever metadata the document carried.\n\n"+
|
||||
"A page that could not be fetched is a NORMAL outcome, not a fault: an unreachable "+
|
||||
"host, a refused address or a non-document content type all answer 200 with "+
|
||||
"`success:false` and the reason in `error`. Non-2xx is reserved for a caller "+
|
||||
"problem — 401 for a bad key, 400 for a missing url, 503 when the surface is "+
|
||||
"unconfigured — so error handling can trust the status.\n\n"+
|
||||
"Admission is either a validated principal or the shared service key, presented as "+
|
||||
"X-API-Key or a Bearer; neither is refused, and an unset key fails closed rather "+
|
||||
"than opening the fetcher to the private network. Crawled pages are archived under "+
|
||||
"the scope of the VERIFIED principal, never a scope named in the body; a service "+
|
||||
"caller has no org and its pages land in the shared corpus. One URL per call, and "+
|
||||
"the request body is bounded at 1 MiB.")
|
||||
// URL is the address actually read, after redirects.
|
||||
URL string `json:"url"`
|
||||
// Title is the document's title, when it carried one.
|
||||
Title string `json:"title,omitempty"`
|
||||
// Markdown is the page's content, extracted and rendered to markdown. This is
|
||||
// the field to read.
|
||||
Markdown string `json:"markdown"`
|
||||
// Metadata is whatever the document said about itself — description, og:*,
|
||||
// language — plus the response status, the final URL and the content type. It
|
||||
// is an OPEN key space, so it is carried as raw JSON: `map[string]any`
|
||||
// publishes `additionalProperties:{"type":"object"}`, which the integer
|
||||
// `status` inside it refutes, while raw JSON publishes `{}` — "any JSON",
|
||||
// which is true. The bytes are identical either way (encoding/json writes a
|
||||
// map's keys sorted, and this IS that marshalling).
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// serviceKey is the shared key a service caller presents. It is deliberately the
|
||||
@@ -99,6 +102,29 @@ func init() {
|
||||
// inside this one would put a rename in the path of a fix.
|
||||
func serviceKey() string { return strings.TrimSpace(os.Getenv("WEBSEARCH_API_KEY")) }
|
||||
|
||||
// Go drops comments at compile time, so cmd/zipdoc is the ONLY path from the
|
||||
// handler's prose to the published document, the SDKs and the MCP tool
|
||||
// description. Its output is committed; `make zipdoc-check` fails on drift.
|
||||
//
|
||||
// Without this directive the package builds, the tests pass, and the typed op
|
||||
// below publishes a summary with no description — openapi.Complete accepts
|
||||
// either, so the gap is invisible to every gate and visible in every SDK.
|
||||
//
|
||||
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
|
||||
|
||||
// Path is the address this API is called on, spelled once so the registration,
|
||||
// the admission middleware and the prose cannot disagree.
|
||||
//
|
||||
// It used to be registered twice — `g.Post("", serve)` and `g.Post("/", serve)`
|
||||
// on a Group("/v1/crawl") — and they were the SAME route: zip normalises an empty
|
||||
// leaf to "/", so both composed to "/v1/crawl/" and the second was dead. The
|
||||
// consequence was not on the wire (the router is non-strict, so both URLs are
|
||||
// served either way) but in the ARTIFACTS: op.Path is the identity every
|
||||
// projection reads, so the published document, the operation id, the MCP tool and
|
||||
// the URL every generated SDK calls all carried a trailing slash for a path this
|
||||
// API's callers do not use.
|
||||
const Path = "/v1/crawl"
|
||||
|
||||
// Mount registers /v1/crawl.
|
||||
//
|
||||
// The gate mirrors /v1/websearch/search exactly, and it is not optional here. This
|
||||
@@ -108,6 +134,13 @@ func serviceKey() string { return strings.TrimSpace(os.Getenv("WEBSEARCH_API_KEY
|
||||
// validated principal (a signed-in user, already authenticated and metered) OR the
|
||||
// shared service key. Neither ⇒ refused. An unset key 503s rather than defaulting
|
||||
// open, so a misconfigured deploy fails closed and loudly.
|
||||
//
|
||||
// The two halves of that gate now live in two places, and they have to: a typed op
|
||||
// is also an MCP tool and a CLI command, and tools/call invokes it with no route
|
||||
// and therefore no middleware. So the KEY is checked in middleware, where a
|
||||
// request is (it is a header, which a typed op cannot see), and the DECISION is
|
||||
// made in the handler, where every door reaches it. The middleware only ever adds
|
||||
// a fact; it never admits by itself.
|
||||
func Mount(app cloud.Router, deps cloud.Deps) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("crawl.Mount: nil app")
|
||||
@@ -122,31 +155,44 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
|
||||
// with no object store keeps crawling and keeps nothing — see Bind.
|
||||
Bind(deps.VFS)
|
||||
|
||||
// The scope is resolved per request at the zip layer, where the verified
|
||||
// principal lives, and captured in the handler — it cannot be read off the
|
||||
// net/http request below, and reading it from the BODY would let a caller name
|
||||
// another tenant's corpus prefix.
|
||||
serve := func(c *zip.Ctx) error {
|
||||
s := scope(c)
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handleScoped(w, r, s) })
|
||||
if principal.Validated(c) {
|
||||
return zip.AdaptNetHTTP(h)(c)
|
||||
}
|
||||
return zip.AdaptNetHTTP(guard(h))(c)
|
||||
}
|
||||
|
||||
// ONE registration, at the address this API is called on.
|
||||
// The two request facts a typed op cannot reach, checked where a request is.
|
||||
//
|
||||
// It used to be two — `g.Post("", serve)` and `g.Post("/", serve)` on a
|
||||
// Group("/v1/crawl") — and they were the SAME route: zip normalises an empty
|
||||
// leaf to "/", so both composed to "/v1/crawl/" and the second was dead. The
|
||||
// consequence was not on the wire (the router is non-strict, so both URLs are
|
||||
// served either way) but in the ARTIFACTS: op.Path is the identity every
|
||||
// projection reads, so the published document, the operation id, the MCP tool
|
||||
// and the URL every generated SDK calls all carried a trailing slash for a path
|
||||
// this API's callers do not use. Declaring the whole path here is the fix
|
||||
// LLM.md prescribes for that class.
|
||||
app.Post("/v1/crawl", serve)
|
||||
// cloud.RoutePath, not c.Path(): fiber routes case-insensitively and ignores a
|
||||
// trailing slash, so the raw spelling is what the CLIENT sent and RoutePath is
|
||||
// the form THE ROUTER MATCHED. `POST /V1/CRAWL` would otherwise miss a
|
||||
// lowercase prefix test and reach the fetcher with no credential checked.
|
||||
app.Use(zip.H(func(c *zip.Ctx) error {
|
||||
if cloud.RoutePath(c.Path()) != Path {
|
||||
return c.Continue()
|
||||
}
|
||||
if !bounded(c) {
|
||||
return c.JSON(http.StatusBadRequest, crawlResult{Error: missingURL})
|
||||
}
|
||||
if principal.Validated(c) {
|
||||
return c.Continue()
|
||||
}
|
||||
return admitKey(c)
|
||||
}))
|
||||
|
||||
reg := cloud.ZipApp(app)
|
||||
if reg == nil {
|
||||
return fmt.Errorf("crawl.Mount: router carries no typed-op registry")
|
||||
}
|
||||
// ONE registration, at the address this API is called on. Declaring the whole
|
||||
// path here — rather than a leaf on a Group — is the fix LLM.md prescribes for
|
||||
// the trailing-slash class described on [Path].
|
||||
// Named, not derived. A POST to /v1/crawl derives `create_crawl`, which reads
|
||||
// as "make a crawl" — a job this surface does not have and cannot start. What
|
||||
// it does is read ONE page that is already addressed, and a model choosing
|
||||
// from an `op` enum picks by that name before it reads any description: the
|
||||
// derived name offered a crawler and the op is a reader. It is the verb over
|
||||
// the noun, beside search_web and research_web.
|
||||
zip.Post(reg, Path, fetch,
|
||||
zip.WithOperationID("read_page"),
|
||||
zip.WithSummary("Fetch one URL and read it back as markdown"),
|
||||
// 400 is DECLARED because this op answers it with its OWN body rather than
|
||||
// with zip's error envelope. See [crawlResult.StatusCode].
|
||||
zip.WithStatus(http.StatusOK, http.StatusBadRequest))
|
||||
|
||||
// No "archive" field: it used to log deps.VFS != nil, which is ALWAYS true —
|
||||
// deps.VFS is guaranteed non-nil by contract (R-7, so consumers never
|
||||
@@ -159,73 +205,159 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// guard admits a caller holding the shared service key, presented as either
|
||||
// maxRequest is what this route will read FROM A CALLER. One URL does not need
|
||||
// more, and this surface dials a caller-chosen address from inside the cluster.
|
||||
// (crawl.go's maxBody is the other direction — what is read from a fetched page.)
|
||||
const maxRequest = 1 << 20
|
||||
|
||||
// bounded reports whether a body is one this route will read: at most
|
||||
// [maxRequest] bytes, and JSON if there is any of it.
|
||||
//
|
||||
// Both facts are invisible to a typed op — it receives its DECODED In and never
|
||||
// sees the raw bytes — so they are asked where the bytes still are. This is not a
|
||||
// second admission gate: no credential is read here and nothing is admitted, only
|
||||
// a body that has ALWAYS been refused is refused in the shape its callers parse.
|
||||
// The untyped handler did both through one io.LimitReader(r.Body, 1<<20) whose
|
||||
// decode failure WAS this 400; zip's op.invoke would answer its own flat
|
||||
// {status,code,error} instead, a different body for a refusal that has not
|
||||
// changed.
|
||||
//
|
||||
// It is a PREDICATE and writes nothing. An earlier version answered the refusal
|
||||
// itself and returned c.Continue() otherwise — which runs the whole rest of the
|
||||
// chain from inside it, so the handler ran before the caller had been admitted
|
||||
// and then the middleware continued a second time. A middleware step either
|
||||
// continues or it does not; a helper that does both is neither.
|
||||
func bounded(c *zip.Ctx) bool {
|
||||
b := c.Body()
|
||||
return len(b) <= maxRequest && (len(b) == 0 || json.Valid(b))
|
||||
}
|
||||
|
||||
// admitKey admits a caller holding the shared service key, presented as either
|
||||
// X-API-Key or a Bearer. Both are accepted because the two clients that reach this
|
||||
// surface already differ on that point and neither is wrong; requiring one would
|
||||
// break a working caller to no benefit.
|
||||
func guard(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
want := serviceKey()
|
||||
if want == "" {
|
||||
writeJSON(w, http.StatusServiceUnavailable, crawlResult{Error: "crawl not configured"})
|
||||
return
|
||||
}
|
||||
got := strings.TrimSpace(r.Header.Get("X-API-Key"))
|
||||
if got == "" {
|
||||
got = strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
|
||||
}
|
||||
// Constant-time: a byte-at-a-time comparison leaks the key's prefix to a
|
||||
// caller willing to time enough requests.
|
||||
if subtle.ConstantTimeCompare([]byte(got), []byte(want)) != 1 {
|
||||
writeJSON(w, http.StatusUnauthorized, crawlResult{Error: "invalid api key"})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
//
|
||||
// It writes crawl's OWN refusal body, which is what its callers parse, and it
|
||||
// records the admission on the CONTEXT rather than letting the request through —
|
||||
// so the handler makes the one decision and this only ever supplies a fact.
|
||||
func admitKey(c *zip.Ctx) error {
|
||||
want := serviceKey()
|
||||
if want == "" {
|
||||
return c.JSON(http.StatusServiceUnavailable, crawlResult{Error: "crawl not configured"})
|
||||
}
|
||||
got := strings.TrimSpace(c.Header("X-API-Key"))
|
||||
if got == "" {
|
||||
got = strings.TrimSpace(strings.TrimPrefix(c.Header("Authorization"), "Bearer "))
|
||||
}
|
||||
// Constant-time: a byte-at-a-time comparison leaks the key's prefix to a
|
||||
// caller willing to time enough requests.
|
||||
if subtle.ConstantTimeCompare([]byte(got), []byte(want)) != 1 {
|
||||
return c.JSON(http.StatusUnauthorized, crawlResult{Error: "invalid api key"})
|
||||
}
|
||||
c.SetContext(admit(c.Context()))
|
||||
return c.Continue()
|
||||
}
|
||||
|
||||
// scope reads the caller's corpus scope from the VERIFIED principal.
|
||||
// admittedKey names the context slot [admitKey] records the service key in.
|
||||
// Unexported zero-size type, so only this package can mint or read one.
|
||||
type admittedKey struct{}
|
||||
|
||||
func admit(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, admittedKey{}, true)
|
||||
}
|
||||
|
||||
func isAdmitted(ctx context.Context) bool {
|
||||
ok, _ := ctx.Value(admittedKey{}).(bool)
|
||||
return ok
|
||||
}
|
||||
|
||||
// scopeOf is the ONE admission decision, and it never reads the body.
|
||||
//
|
||||
// A validated principal is admitted and scoped to its own org and project. A
|
||||
// caller with no principal is admitted only on the marker [admitKey] leaves, and
|
||||
// takes the shared corpus, which is the same thing the untyped route did through
|
||||
// an empty scope. Neither ⇒ refused, so the fetcher is closed on every door
|
||||
// including the ones with no request behind them: the CLI projection runs an op
|
||||
// with no request at all, and it lands here.
|
||||
//
|
||||
// A service caller (the chat server, holding the shared key) has no user
|
||||
// principal and therefore no org — its pages land in the shared "_" prefix that
|
||||
// seg() produces for an empty segment. That is deliberate: a service-wide corpus
|
||||
// is the honest home for pages fetched on nobody's behalf, and inventing an org
|
||||
// for it would file them under a tenant that did not ask.
|
||||
func scope(c *zip.Ctx) Scope {
|
||||
org, _ := principal.Org(c)
|
||||
return Scope{Org: org, Project: principal.Project(c)}
|
||||
//
|
||||
// It reads the CONTEXT and never the request. All three facts are server-minted
|
||||
// identity that cloud.Bridge parks in one expression, so holding the raw request
|
||||
// to re-read them would take back what typing bought for nothing: the org is the
|
||||
// tenant, the project only narrows within it, and neither is a fact a caller
|
||||
// supplies.
|
||||
func scopeOf(ctx context.Context) (Scope, error) {
|
||||
if principal.ValidatedFrom(ctx) {
|
||||
org, _ := principal.OrgFrom(ctx)
|
||||
return Scope{Org: org, Project: principal.ProjectFrom(ctx)}, nil
|
||||
}
|
||||
if isAdmitted(ctx) {
|
||||
return Scope{}, nil
|
||||
}
|
||||
return Scope{}, zip.ErrUnauthorized("crawling requires a validated principal or the service key")
|
||||
}
|
||||
|
||||
// handleScoped serves one crawl under a caller scope. The scope selects the corpus
|
||||
// prefix, so it comes from the VERIFIED principal and never from the body.
|
||||
func handleScoped(w http.ResponseWriter, r *http.Request, s Scope) {
|
||||
var req crawlRequest
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil || strings.TrimSpace(req.URL) == "" {
|
||||
writeJSON(w, http.StatusBadRequest, crawlResult{Error: "missing url"})
|
||||
return
|
||||
}
|
||||
|
||||
page, err := Read(r.Context(), s, req.URL)
|
||||
// fetch reads one URL and answers with the page as markdown.
|
||||
//
|
||||
// It fetches a single URL from inside the cluster and answers with the address it
|
||||
// actually landed on, the document's title, its content rendered to MARKDOWN, and
|
||||
// whatever the page said about itself. One URL per call: batching would make the
|
||||
// answer a partial-failure envelope every caller then has to unpack.
|
||||
//
|
||||
// A PAGE THAT COULD NOT BE FETCHED IS A NORMAL ANSWER, not a fault. An
|
||||
// unreachable host, a refused address and a content type that is not a document
|
||||
// all answer 200 with `success:false` and the reason in `error`, because the
|
||||
// caller sent a well-formed ask and gets a well-formed answer. Non-2xx is reserved
|
||||
// for a caller problem — 400 with the same body when there is no url, 401 for a
|
||||
// bad key, 503 when the surface is unconfigured — so error handling can trust the
|
||||
// status. Check `success` before reading `data`.
|
||||
//
|
||||
// Admission is either a validated principal or the shared service key, presented
|
||||
// as X-API-Key or a Bearer; neither is refused, and an unset key fails closed
|
||||
// rather than opening the fetcher to the private network. Pages are archived under
|
||||
// the scope of the VERIFIED principal and NEVER a scope named in the body, so a
|
||||
// URL already read under that scope is answered from the archive without touching
|
||||
// the network; a service caller has no org and its pages land in the shared
|
||||
// corpus.
|
||||
//
|
||||
// The URL is caller-supplied and dialled from INSIDE the cluster, which makes this
|
||||
// a request-forgery primitive by construction. Only http and https are accepted,
|
||||
// and every address actually dialled must be public unicast — loopback,
|
||||
// link-local, private and multicast are refused. The check lives in the DIALER
|
||||
// rather than on the hostname, because resolving a name to validate it and then
|
||||
// letting the transport resolve it again is a gap DNS rebinding walks straight
|
||||
// through; redirects re-enter the same dialer.
|
||||
func fetch(ctx context.Context, in *crawlRequest) (*crawlResult, error) {
|
||||
s, err := scopeOf(ctx)
|
||||
if err != nil {
|
||||
// 200 with Success:false — see the note on Response. The message is the
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(in.URL) == "" {
|
||||
return &crawlResult{Error: missingURL}, nil
|
||||
}
|
||||
page, err := Read(ctx, s, in.URL)
|
||||
if err != nil {
|
||||
// 200 with Success:false — see the note on crawlResult. The message is the
|
||||
// error verbatim: a caller debugging a failed crawl needs to know whether the
|
||||
// host was refused, unreachable, or served the wrong type.
|
||||
writeJSON(w, http.StatusOK, crawlResult{Error: err.Error()})
|
||||
return
|
||||
return &crawlResult{Error: err.Error()}, nil
|
||||
}
|
||||
writeJSON(w, http.StatusOK, crawlResult{
|
||||
meta, err := json.Marshal(page.Metadata)
|
||||
if err != nil {
|
||||
return nil, zip.ErrInternal("crawl: the page's metadata will not encode")
|
||||
}
|
||||
return &crawlResult{
|
||||
Success: true,
|
||||
Data: &crawlDocument{
|
||||
URL: page.URL,
|
||||
Title: page.Title,
|
||||
Markdown: page.Markdown,
|
||||
Metadata: page.Metadata,
|
||||
Metadata: meta,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}, nil
|
||||
}
|
||||
|
||||
+116
-31
@@ -1,6 +1,7 @@
|
||||
package crawl
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -36,18 +37,33 @@ func post(t *testing.T, app *zip.App, path, body string) (int, string) {
|
||||
return resp.StatusCode, strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
// TestCrawlRefusalIsTheWire measures the two facts that keep POST /v1/crawl an
|
||||
// untyped handler. They are named at the registration in mount.go; this is what
|
||||
// makes that prose a MEASUREMENT rather than a claim, so the day zip can express
|
||||
// either one, the conversion is a test edit away instead of a re-derivation.
|
||||
func TestCrawlRefusalIsTheWire(t *testing.T) {
|
||||
// TestCrawlWireSurvivedTyping is the conversion's whole obligation.
|
||||
//
|
||||
// POST /v1/crawl was a raw handler for two measured reasons, and both were real:
|
||||
// it is BODY-TOLERANT with a DOMAIN refusal body, and it bounds the request at
|
||||
// 1 MiB. The cost of that was the whole point of tracker #190 — a raw route is in
|
||||
// no registry, so this subsystem projected NO MCP tool and the fleet's one way to
|
||||
// read a web page was unreachable by the agent that needed it.
|
||||
//
|
||||
// It is a typed op now, and NEITHER fact moved. They moved HOUSE:
|
||||
//
|
||||
// - The 400 with `{"success":false,"error":"missing url"}` is stated by the
|
||||
// ANSWER, through zip's StatusCoder — the sanctioned way for an op that
|
||||
// refuses with its own body — so the document publishes 400 with that schema.
|
||||
// - The 1 MiB bound and the malformed-body tolerance are facts about BYTES, and
|
||||
// a typed op receives its decoded In. So they are asked in middleware, where
|
||||
// the bytes still are (mount.go, bounded).
|
||||
//
|
||||
// This test is the measurement that says so: the same three inputs, the same
|
||||
// three answers, byte for byte.
|
||||
func TestCrawlWireSurvivedTyping(t *testing.T) {
|
||||
t.Setenv("WEBSEARCH_API_KEY", "test-service-key")
|
||||
app := mount(t)
|
||||
|
||||
// (1) BODY-TOLERANT, with a DOMAIN refusal body. An unparseable body and an
|
||||
// empty url are the SAME answer, and it is this package's shape — not zip's
|
||||
// flat {status,code,error}, which is the only thing a typed op's returned
|
||||
// error can become.
|
||||
// flat {status,code,error}, which is what an op that refused by RETURNING an
|
||||
// error would send.
|
||||
for _, body := range []string{`{"url":""}`, `not json at all`, `{`} {
|
||||
code, out := post(t, app, "/v1/crawl", body)
|
||||
if code != http.StatusBadRequest {
|
||||
@@ -58,9 +74,8 @@ func TestCrawlRefusalIsTheWire(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// (2) The 1 MiB io.LimitReader bound. A body past it cannot decode, so it is
|
||||
// the same 400 — and a typed op, which receives its DECODED In, could never
|
||||
// see the length that produced it.
|
||||
// (2) The 1 MiB bound. A body past it is the same 400 — it must never become a
|
||||
// crawl that runs because the cap was dropped in the conversion.
|
||||
big := `{"url":"https://example.com","pad":"` + strings.Repeat("x", 1<<20) + `"}`
|
||||
code, out := post(t, app, "/v1/crawl", big)
|
||||
if code != http.StatusBadRequest || out != `{"success":false,"error":"missing url"}` {
|
||||
@@ -68,12 +83,38 @@ func TestCrawlRefusalIsTheWire(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrawlIsServedAtOneAddressAndPublishedAtIt pins the registration fix. The
|
||||
// mount used to declare `g.Post("", …)` and `g.Post("/", …)` on a
|
||||
// Group("/v1/crawl"), which are the SAME route ("/v1/crawl/") — so the second was
|
||||
// dead and the published path carried a trailing slash the callers do not use.
|
||||
// Both URLs must keep working (the router is non-strict) AND the document must
|
||||
// name the one without the slash.
|
||||
// TestCrawlIsClosedToAnAnonymousCaller is the gate, asked at the door a typed op
|
||||
// adds rather than at the one it already had.
|
||||
//
|
||||
// A tools/call reaches a typed op with NO route and therefore NO middleware, so a
|
||||
// gate that lived only in middleware would be no gate at all for the MCP and CLI
|
||||
// projections this conversion exists to create. The decision is in the handler
|
||||
// (scopeOf) for exactly that reason. Here it is measured from the HTTP side: no
|
||||
// principal and no key is refused, and the refusal is not a crawl.
|
||||
func TestCrawlIsClosedToAnAnonymousCaller(t *testing.T) {
|
||||
t.Setenv("WEBSEARCH_API_KEY", "test-service-key")
|
||||
app := mount(t)
|
||||
|
||||
rq := httptest.NewRequest(http.MethodPost, "/v1/crawl", strings.NewReader(`{"url":"https://example.com"}`))
|
||||
rq.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(rq)
|
||||
if err != nil {
|
||||
t.Fatalf("POST /v1/crawl: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("an anonymous crawl = %d %s, want 401 — this surface dials a caller-chosen "+
|
||||
"address from inside the cluster", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrawlIsServedAtOneAddressAndPublishedAtIt pins the registration. The mount
|
||||
// used to declare `g.Post("", …)` and `g.Post("/", …)` on a Group("/v1/crawl"),
|
||||
// which are the SAME route ("/v1/crawl/") — so the second was dead and the
|
||||
// published path carried a trailing slash the callers do not use. Both URLs must
|
||||
// keep working (the router is non-strict) AND the document must name the one
|
||||
// without the slash.
|
||||
func TestCrawlIsServedAtOneAddressAndPublishedAtIt(t *testing.T) {
|
||||
t.Setenv("WEBSEARCH_API_KEY", "test-service-key")
|
||||
app := mount(t)
|
||||
@@ -96,11 +137,58 @@ func TestCrawlIsServedAtOneAddressAndPublishedAtIt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrawlDeclaresItsBodies holds the other half of the refusal to account.
|
||||
// Staying untyped costs the prose, the MCP tool and the CLI command; it must not
|
||||
// also cost a document that says this route takes no body, or every generated SDK
|
||||
// offers a crawl with nowhere to put the URL.
|
||||
func TestCrawlDeclaresItsBodies(t *testing.T) {
|
||||
// TestCrawlProjectsAsATool is the fact the conversion was FOR.
|
||||
//
|
||||
// A raw handler appends nothing to zip's op registry, and that registry is what
|
||||
// every projection reads — so an untyped /v1/crawl is in no OpenAPI operation, no
|
||||
// SDK method, no CLI command and no MCP tool. Asserting the handler answers
|
||||
// correctly says nothing about any of that. This asks the subsystem's OWN MCP
|
||||
// door, over JSON-RPC, exactly as the fleet's door asks it, and reads the answer.
|
||||
func TestCrawlProjectsAsATool(t *testing.T) {
|
||||
t.Setenv("WEBSEARCH_API_KEY", "test-service-key")
|
||||
app := mount(t)
|
||||
|
||||
rq := httptest.NewRequest(http.MethodPost, "/mcp",
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`))
|
||||
rq.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(rq)
|
||||
if err != nil {
|
||||
t.Fatalf("POST /mcp: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var env struct {
|
||||
Result *struct {
|
||||
Tools []struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
} `json:"tools"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &env); err != nil || env.Result == nil {
|
||||
t.Fatalf("POST /mcp did not answer MCP: %d — %.200s", resp.StatusCode, raw)
|
||||
}
|
||||
for _, tool := range env.Result.Tools {
|
||||
if tool.Name != "read_page" {
|
||||
continue
|
||||
}
|
||||
if tool.Description == "" {
|
||||
t.Fatal("read_page projects with NO description — a tool a model cannot " +
|
||||
"read is a tool it will not call; run `go generate -run zipdoc ./...`")
|
||||
}
|
||||
t.Logf("read_page projects: %.90s…", tool.Description)
|
||||
return
|
||||
}
|
||||
t.Fatalf("read_page is not in this subsystem's tools/list — %.300s", raw)
|
||||
}
|
||||
|
||||
// TestCrawlPublishesItsBodies holds the document to account. It used to assert
|
||||
// that the UNTYPED route declared its shapes by hand through openapi.Register;
|
||||
// the typed op declares them by construction, off the same structs the handler
|
||||
// binds, so this now asserts the stronger fact — that the published operation
|
||||
// carries a request body, a response AND the prose only a typed op can have.
|
||||
func TestCrawlPublishesItsBodies(t *testing.T) {
|
||||
t.Setenv("WEBSEARCH_API_KEY", "test-service-key")
|
||||
doc, err := openapi.Spec(mount(t), openapi.Info{Title: "crawl", Version: "v1"})
|
||||
if err != nil {
|
||||
@@ -114,18 +202,15 @@ func TestCrawlDeclaresItsBodies(t *testing.T) {
|
||||
if op == nil || op.RequestBody == nil {
|
||||
t.Fatal("POST /v1/crawl publishes no request body — an SDK would offer a crawl with no URL")
|
||||
}
|
||||
// RequestBody/Responses are `any` on Operation: Register builds the closed
|
||||
// shapes, the typed fold reuses zip's open maps. Assert the closed ones.
|
||||
rb, ok := op.RequestBody.(*openapi.RequestBody)
|
||||
if !ok {
|
||||
t.Fatalf("request body is %T, want the declared *openapi.RequestBody", op.RequestBody)
|
||||
if op.Responses == nil {
|
||||
t.Error("POST /v1/crawl publishes no response shape")
|
||||
}
|
||||
if _, jsonBody := rb.Content["application/json"]; !jsonBody {
|
||||
t.Errorf("request body content = %v, want application/json", rb.Content)
|
||||
if strings.TrimSpace(op.Summary) == "" {
|
||||
t.Error("POST /v1/crawl publishes no summary")
|
||||
}
|
||||
resp, ok := op.Responses.(map[string]*openapi.Response)
|
||||
if !ok || resp["2XX"] == nil {
|
||||
t.Errorf("POST /v1/crawl publishes no success body (responses = %#v)", op.Responses)
|
||||
if strings.TrimSpace(op.Description) == "" {
|
||||
t.Error("POST /v1/crawl publishes no description — zipdoc lifts it from the handler's " +
|
||||
"doc comment; run `go generate -run zipdoc ./...` and commit zipdoc_gen.go")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// Code generated by zipdoc; DO NOT EDIT.
|
||||
|
||||
package crawl
|
||||
|
||||
import (
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
func init() {
|
||||
zip.Describe("POST /v1/crawl", zip.Doc{
|
||||
Description: "Reads one URL and answers with the page as markdown.\n\nIt fetches a single URL from inside the cluster and answers with the address it\nactually landed on, the document's title, its content rendered to MARKDOWN, and\nwhatever the page said about itself. One URL per call: batching would make the\nanswer a partial-failure envelope every caller then has to unpack.\n\nA PAGE THAT COULD NOT BE FETCHED IS A NORMAL ANSWER, not a fault. An\nunreachable host, a refused address and a content type that is not a document\nall answer 200 with `success:false` and the reason in `error`, because the\ncaller sent a well-formed ask and gets a well-formed answer. Non-2xx is reserved\nfor a caller problem — 400 with the same body when there is no url, 401 for a\nbad key, 503 when the surface is unconfigured — so error handling can trust the\nstatus. Check `success` before reading `data`.\n\nAdmission is either a validated principal or the shared service key, presented\nas X-API-Key or a Bearer; neither is refused, and an unset key fails closed\nrather than opening the fetcher to the private network. Pages are archived under\nthe scope of the VERIFIED principal and NEVER a scope named in the body, so a\nURL already read under that scope is answered from the archive without touching\nthe network; a service caller has no org and its pages land in the shared\ncorpus.\n\nThe URL is caller-supplied and dialled from INSIDE the cluster, which makes this\na request-forgery primitive by construction. Only http and https are accepted,\nand every address actually dialled must be public unicast — loopback,\nlink-local, private and multicast are refused. The check lives in the DIALER\nrather than on the hostname, because resolving a name to validate it and then\nletting the transport resolve it again is a gap DNS rebinding walks straight\nthrough; redirects re-enter the same dialer.",
|
||||
Fields: map[string]string{
|
||||
"crawlDocument.markdown": "Markdown is the page's content, extracted and rendered to markdown. This is\nthe field to read.",
|
||||
"crawlDocument.metadata": "Metadata is whatever the document said about itself — description, og:*,\nlanguage — plus the response status, the final URL and the content type. It\nis an OPEN key space, so it is carried as raw JSON: `map[string]any`\npublishes `additionalProperties:{\"type\":\"object\"}`, which the integer\n`status` inside it refutes, while raw JSON publishes `{}` — \"any JSON\",\nwhich is true. The bytes are identical either way (encoding/json writes a\nmap's keys sorted, and this IS that marshalling).",
|
||||
"crawlDocument.title": "Title is the document's title, when it carried one.",
|
||||
"crawlDocument.url": "URL is the address actually read, after redirects.",
|
||||
"crawlResult.data": "Data is the page, present exactly when Success.",
|
||||
"crawlResult.error": "Error says what stopped the fetch: the host was refused, unreachable, or\nserved something that is not a document.",
|
||||
"crawlResult.success": "Success is whether the page was fetched and read. FALSE with an Error is a\ncomplete answer, not a fault — check this before reading Data.",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package deploy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
@@ -170,76 +169,6 @@ func TestSyncStatus(t *testing.T) {
|
||||
|
||||
// ── ref parsing ─────────────────────────────────────────────────────────────
|
||||
|
||||
func TestParseRef(t *testing.T) {
|
||||
// The App CR resolves; the core/v1 Service (a child object) resolves distinctly.
|
||||
if _, gvr, err := parseRef("hanzo.ai:App:hanzo:iam"); err != nil || gvr != k8s.Apps {
|
||||
t.Errorf("App ref → (%v, %v), want k8s.Apps", gvr, err)
|
||||
}
|
||||
if _, gvr, err := parseRef("apps:Deployment:hanzo:iam"); err != nil || gvr != k8s.Deployments {
|
||||
t.Errorf("Deployment ref → (%v, %v), want k8s.Deployments", gvr, err)
|
||||
}
|
||||
if _, _, err := parseRef(":Service:hanzo:iam"); err != nil {
|
||||
t.Errorf("core Service ref err = %v, want nil", err)
|
||||
}
|
||||
// hanzo.ai:Service is not a kind this plane reads — the operator CR is App.
|
||||
bad := []string{"", "a:b:c", "hanzo.ai:Service:hanzo:iam", "unknown/Kind:hanzo:iam:x", "apps:Deployment:evil-ns:iam", "apps:Deployment:hanzo:Bad_Name"}
|
||||
for _, r := range bad {
|
||||
if _, _, err := parseRef(r); err == nil {
|
||||
t.Errorf("parseRef(%q) = nil err, want rejection", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── belongs-to-app membership ───────────────────────────────────────────────
|
||||
|
||||
func TestBelongsToApp(t *testing.T) {
|
||||
cr := appCR("App", "hanzo", "iam", "u1", "r", "v1.0.0", "Running", 1, 1)
|
||||
// owned by uid
|
||||
if !belongsToApp(deployment("hanzo", "x", "d1", "u1", "img:v1", 1, 1), cr, "iam") {
|
||||
t.Error("ownerRef match should belong")
|
||||
}
|
||||
// name == app
|
||||
if !belongsToApp(deployment("hanzo", "iam", "d1", "other", "img:v1", 1, 1), cr, "iam") {
|
||||
t.Error("name==app should belong")
|
||||
}
|
||||
// label instance
|
||||
lbl := &unstructured.Unstructured{Object: map[string]any{"metadata": map[string]any{"name": "z", "labels": map[string]any{"app.kubernetes.io/instance": "iam"}}}}
|
||||
if !belongsToApp(lbl, cr, "iam") {
|
||||
t.Error("instance label should belong")
|
||||
}
|
||||
// unrelated
|
||||
other := &unstructured.Unstructured{Object: map[string]any{"metadata": map[string]any{"name": "z", "uid": "zz"}}}
|
||||
if belongsToApp(other, cr, "iam") {
|
||||
t.Error("unrelated object must NOT belong")
|
||||
}
|
||||
}
|
||||
|
||||
// ── diff ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestComputeDiff(t *testing.T) {
|
||||
// No annotation → source none, not modified.
|
||||
live := deployment("hanzo", "iam", "d1", "u1", "ghcr.io/hanzoai/iam:v1", 2, 2)
|
||||
if src, mod, _ := computeDiff(live); src != "none" || mod {
|
||||
t.Errorf("no-annotation diff = (%q,%v), want (none,false)", src, mod)
|
||||
}
|
||||
// Annotation identical to live (minus status/volatile) → not modified.
|
||||
desired := map[string]any{"apiVersion": "apps/v1", "kind": "Deployment",
|
||||
"metadata": map[string]any{"name": "iam", "namespace": "hanzo"},
|
||||
"spec": map[string]any{"replicas": int64(2), "selector": map[string]any{"matchLabels": map[string]any{"app.kubernetes.io/instance": "iam"}}, "template": map[string]any{"spec": map[string]any{"containers": []any{map[string]any{"name": "app", "image": "ghcr.io/hanzoai/iam:v1"}}}}}}
|
||||
db, _ := json.Marshal(desired)
|
||||
_ = unstructured.SetNestedField(live.Object, map[string]any{lastAppliedAnnotation: string(db)}, "metadata", "annotations")
|
||||
if src, mod, _ := computeDiff(live); src != deployDesiredTODO || mod {
|
||||
t.Errorf("identical-desired diff = (%q,%v), want (%q,false)", src, mod, deployDesiredTODO)
|
||||
}
|
||||
// Annotation with a different image → modified.
|
||||
desired["spec"].(map[string]any)["template"].(map[string]any)["spec"].(map[string]any)["containers"].([]any)[0].(map[string]any)["image"] = "ghcr.io/hanzoai/iam:v2"
|
||||
db2, _ := json.Marshal(desired)
|
||||
_ = unstructured.SetNestedField(live.Object, map[string]any{lastAppliedAnnotation: string(db2)}, "metadata", "annotations")
|
||||
if _, mod, _ := computeDiff(live); !mod {
|
||||
t.Error("changed-image diff = not modified, want modified")
|
||||
}
|
||||
}
|
||||
|
||||
// ── observe mapping ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestObserveApplication(t *testing.T) {
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
// logs.go — GET /v1/deploy/{name}/logs: the app's current pod logs, streamed from
|
||||
// the newest running pod via the typed CoreV1 GetLogs subresource. The operator
|
||||
// labels the workload it renders for an App CR with
|
||||
// app.kubernetes.io/instance=<name>, so that selects the app's pods; the
|
||||
// most-recently-started pod is read (the current rollout). Optional ?container=
|
||||
// selects a container; ?tail= bounds the lines. Never fabricates output — an
|
||||
// unreachable cluster or absent pod yields an honest 200 with an empty tail + the
|
||||
// reason, not invented logs.
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
logTailDefault = int64(400)
|
||||
logTailMax = int64(5000)
|
||||
logMaxBytes = 512 << 10
|
||||
logReadDeadline = 8 * time.Second
|
||||
)
|
||||
|
||||
// appLogs streams the newest app pod's logs. It resolves the app's namespace, then
|
||||
// selects pods by the operator's instance label. 200 always (with content or an
|
||||
// honest empty note) so a dashboard poll never errors on a not-yet-scheduled pod.
|
||||
func appLogs(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
if err := ready(s); err != nil {
|
||||
return err
|
||||
}
|
||||
name := reqName(c)
|
||||
if !appNameRE.MatchString(name) {
|
||||
return zip.ErrBadRequest("name must be a DNS-1123 label")
|
||||
}
|
||||
ns, err := resolveNamespace(s, c, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tail := logTailDefault
|
||||
if q := strings.TrimSpace(c.Query("tail")); q != "" {
|
||||
if n, e := strconv.ParseInt(q, 10, 64); e == nil && n > 0 {
|
||||
if n > logTailMax {
|
||||
n = logTailMax
|
||||
}
|
||||
tail = n
|
||||
}
|
||||
}
|
||||
container := strings.TrimSpace(c.Query("container"))
|
||||
|
||||
logs, pod, ok := podLogs(s, c.Context(), ns, "app.kubernetes.io/instance="+name, container, tail)
|
||||
res := map[string]any{"application": ns + "/" + name, "pod": pod, "container": container, "logs": logs}
|
||||
if !ok {
|
||||
res["note"] = "no running pod logs available yet (pod not scheduled, or the cluster/pod is unreachable)"
|
||||
}
|
||||
return c.JSON(http.StatusOK, res)
|
||||
}
|
||||
|
||||
// podLogs finds the newest pod matching selector in ns and streams its logs
|
||||
// (tail-bounded, byte-capped, time-boxed). Returns (logs, podName, ok). ok=false on
|
||||
// no typed client / no pod / read failure — the caller states the honest fallback.
|
||||
func podLogs(s *cloud.Service[state], ctx context.Context, ns, selector, container string, tail int64) (string, string, bool) {
|
||||
if s.State.clientset == nil {
|
||||
return "", "", false
|
||||
}
|
||||
rctx, cancel := context.WithTimeout(ctx, logReadDeadline)
|
||||
defer cancel()
|
||||
|
||||
pods, err := s.State.clientset.CoreV1().Pods(ns).List(rctx, metav1.ListOptions{LabelSelector: selector})
|
||||
if err != nil || len(pods.Items) == 0 {
|
||||
return "", "", false
|
||||
}
|
||||
pod := newestPod(pods.Items)
|
||||
if pod == "" {
|
||||
return "", "", false
|
||||
}
|
||||
opts := &corev1.PodLogOptions{TailLines: &tail}
|
||||
if container != "" {
|
||||
opts.Container = container
|
||||
}
|
||||
stream, err := s.State.clientset.CoreV1().Pods(ns).GetLogs(pod, opts).Stream(rctx)
|
||||
if err != nil {
|
||||
return "", pod, false
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(stream, int64(logMaxBytes)+1))
|
||||
if err != nil && len(data) == 0 {
|
||||
return "", pod, false
|
||||
}
|
||||
out := string(data)
|
||||
if len(out) > logMaxBytes {
|
||||
out = out[len(out)-logMaxBytes:]
|
||||
if nl := strings.IndexByte(out, '\n'); nl >= 0 && nl < len(out)-1 {
|
||||
out = out[nl+1:]
|
||||
}
|
||||
out = "… (truncated to the most recent " + strconv.Itoa(logMaxBytes>>10) + " KiB)\n" + out
|
||||
}
|
||||
if strings.TrimSpace(out) == "" {
|
||||
return "", pod, false
|
||||
}
|
||||
return out, pod, true
|
||||
}
|
||||
|
||||
// newestPod returns the name of the most-recently-started pod (by startTime, then
|
||||
// creationTimestamp; ties break on name for stability).
|
||||
func newestPod(pods []corev1.Pod) string {
|
||||
sort.Slice(pods, func(i, j int) bool {
|
||||
ti, tj := podTime(pods[i]), podTime(pods[j])
|
||||
if ti.Equal(tj) {
|
||||
return pods[i].Name > pods[j].Name
|
||||
}
|
||||
return ti.After(tj)
|
||||
})
|
||||
return pods[0].Name
|
||||
}
|
||||
|
||||
func podTime(p corev1.Pod) time.Time {
|
||||
if p.Status.StartTime != nil {
|
||||
return p.Status.StartTime.Time
|
||||
}
|
||||
return p.CreationTimestamp.Time
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
// resource.go — GET /v1/deploy/{name}/resource/{ref}: one tree node's live
|
||||
// manifest plus a desired-vs-live diff.
|
||||
//
|
||||
// {ref} is the canonical "group:kind:namespace:name" token the tree emits on each
|
||||
// node, so the console round-trips it back verbatim. The kind must be in the
|
||||
// closed registry (kindGVR) and the namespace a platform namespace, and the object
|
||||
// must belong to {name}'s tree (it IS the CR, or carries an ownerRef/label tying it
|
||||
// to the app) — so the endpoint can never be steered at an arbitrary cluster
|
||||
// object. Secrets are not in the registry, so their manifests are never returned.
|
||||
//
|
||||
// desiredSource: today "last-applied" (the object's kubectl last-applied-config
|
||||
// annotation) or "none". When git.hanzo.ai becomes the manifest source of truth
|
||||
// (RegisterPushBuilder → commit → engine sync), desiredSource becomes "git" with
|
||||
// the SAME diff shape. P2b replaces the field-strip diff with gitops-engine
|
||||
// pkg/diff (three-way) for exact ArgoCD parity.
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
)
|
||||
|
||||
const lastAppliedAnnotation = "kubectl.kubernetes.io/last-applied-configuration"
|
||||
|
||||
// parseRef parses a "group:kind:namespace:name" token into its ResourceRef + GVR.
|
||||
// Group may be empty (core). The kind must be in the closed registry; anything
|
||||
// else is refused, so a ref can only address a known operator-owned kind.
|
||||
func parseRef(ref string) (ResourceRef, schema.GroupVersionResource, error) {
|
||||
parts := strings.SplitN(ref, ":", 4)
|
||||
if len(parts) != 4 {
|
||||
return ResourceRef{}, schema.GroupVersionResource{}, zip.ErrBadRequest("ref must be group:kind:namespace:name")
|
||||
}
|
||||
group, kind, ns, name := parts[0], parts[1], parts[2], parts[3]
|
||||
gvr, ok := kindGVR[group+"/"+kind]
|
||||
if !ok {
|
||||
return ResourceRef{}, schema.GroupVersionResource{}, zip.ErrBadRequest("unsupported resource kind " + group + "/" + kind)
|
||||
}
|
||||
if _, ok := nsEnv[ns]; !ok {
|
||||
return ResourceRef{}, schema.GroupVersionResource{}, zip.ErrBadRequest("namespace must be a platform namespace")
|
||||
}
|
||||
if !appNameRE.MatchString(name) {
|
||||
return ResourceRef{}, schema.GroupVersionResource{}, zip.ErrBadRequest("resource name must be a DNS-1123 label")
|
||||
}
|
||||
return makeRef(group, gvr.Version, kind, ns, name), gvr, nil
|
||||
}
|
||||
|
||||
// appResource returns the live manifest + diff for one node of {name}'s tree.
|
||||
func appResource(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
if err := ready(s); err != nil {
|
||||
return err
|
||||
}
|
||||
app := reqName(c)
|
||||
if !appNameRE.MatchString(app) {
|
||||
return zip.ErrBadRequest("name must be a DNS-1123 label")
|
||||
}
|
||||
ref, gvr, perr := parseRef(c.Param("ref"))
|
||||
if perr != nil {
|
||||
return perr
|
||||
}
|
||||
// Resolve the app CR first (for the membership check + namespace consistency).
|
||||
crNS, err := resolveNamespace(s, c, app)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ref.Namespace != crNS {
|
||||
return zip.ErrNotFound("resource is not in this application's namespace")
|
||||
}
|
||||
cr, _, err := getAppCR(s, c.Context(), crNS, app)
|
||||
if err != nil {
|
||||
return k8sErr(s, "get", err)
|
||||
}
|
||||
live, err := s.State.dyn.Resource(gvr).Namespace(ref.Namespace).Get(c.Context(), ref.Name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
return zip.ErrNotFound("resource not found")
|
||||
}
|
||||
return k8sErr(s, "get", err)
|
||||
}
|
||||
if !belongsToApp(live, cr, app) {
|
||||
return zip.ErrNotFound("resource is not part of this application")
|
||||
}
|
||||
|
||||
health, hmsg := resourceHealth(live)
|
||||
desiredSource, modified, desired := computeDiff(live)
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"ref": ref,
|
||||
"health": health,
|
||||
"healthMessage": hmsg,
|
||||
"liveManifest": live.Object,
|
||||
"desiredSource": desiredSource,
|
||||
"diff": map[string]any{
|
||||
"modified": modified,
|
||||
"desiredManifest": desired,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// belongsToApp reports whether live is part of app's tree: it IS the CR, or it
|
||||
// carries an ownerRef to the CR, or its name equals app, or a standard app label
|
||||
// ties it to app. The membership boundary that keeps this endpoint scoped.
|
||||
func belongsToApp(live, cr *unstructured.Unstructured, app string) bool {
|
||||
if live.GetUID() == cr.GetUID() && live.GetUID() != "" {
|
||||
return true
|
||||
}
|
||||
if ownedBy(live, string(cr.GetUID())) {
|
||||
return true
|
||||
}
|
||||
if live.GetName() == app {
|
||||
return true
|
||||
}
|
||||
labels := live.GetLabels()
|
||||
return labels["app.kubernetes.io/instance"] == app || labels["app.kubernetes.io/part-of"] == app
|
||||
}
|
||||
|
||||
// computeDiff derives (desiredSource, modified, desired) for a live object from its
|
||||
// last-applied-configuration annotation. modified is true when the normalized live
|
||||
// object differs from the normalized desired (server-set noise stripped from
|
||||
// both). Absent the annotation, the source is "none" and modified is false (no
|
||||
// desired to compare) — honest, never a fabricated diff.
|
||||
func computeDiff(live *unstructured.Unstructured) (source string, modified bool, desired map[string]any) {
|
||||
raw := live.GetAnnotations()[lastAppliedAnnotation]
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return "none", false, nil
|
||||
}
|
||||
var d map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &d); err != nil {
|
||||
return "none", false, nil
|
||||
}
|
||||
modified = !jsonEqual(normalizeForDiff(live.Object), normalizeForDiff(d))
|
||||
return deployDesiredTODO, modified, d
|
||||
}
|
||||
|
||||
// normalizeForDiff strips server-set / volatile fields so a diff reflects only
|
||||
// intent: status, metadata.managedFields/resourceVersion/uid/generation/
|
||||
// creationTimestamp, and the last-applied annotation itself.
|
||||
func normalizeForDiff(in map[string]any) map[string]any {
|
||||
out := deepCopyMap(in)
|
||||
delete(out, "status")
|
||||
if md, ok := out["metadata"].(map[string]any); ok {
|
||||
// Strip every field the server/operator sets that the last-applied intent
|
||||
// never carries (ownerReferences included — the operator owns those), so the
|
||||
// coarse two-way compare reflects intent, not server bookkeeping. The precise
|
||||
// three-way merge arrives with the gitops-engine diff (P2b).
|
||||
for _, k := range []string{"managedFields", "resourceVersion", "uid", "generation", "creationTimestamp", "selfLink", "ownerReferences"} {
|
||||
delete(md, k)
|
||||
}
|
||||
if ann, ok := md["annotations"].(map[string]any); ok {
|
||||
delete(ann, lastAppliedAnnotation)
|
||||
if len(ann) == 0 {
|
||||
delete(md, "annotations")
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// jsonEqual compares two maps by canonical JSON (encoding/json sorts string keys),
|
||||
// so field order never produces a false diff.
|
||||
func jsonEqual(a, b map[string]any) bool {
|
||||
ab, _ := json.Marshal(a)
|
||||
bb, _ := json.Marshal(b)
|
||||
return string(ab) == string(bb)
|
||||
}
|
||||
|
||||
func deepCopyMap(in map[string]any) map[string]any {
|
||||
b, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(b, &out)
|
||||
return out
|
||||
}
|
||||
@@ -15,10 +15,8 @@ package deploy
|
||||
import (
|
||||
"context"
|
||||
"github.com/hanzoai/cloud/apps/k8s"
|
||||
"net/http"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
@@ -49,30 +47,6 @@ type Node struct {
|
||||
ParentRefs []ResourceRef `json:"parentRefs,omitempty"`
|
||||
}
|
||||
|
||||
// appTree returns the flat node tree for one Application.
|
||||
func appTree(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
if err := ready(s); err != nil {
|
||||
return err
|
||||
}
|
||||
name := reqName(c)
|
||||
if !appNameRE.MatchString(name) {
|
||||
return zip.ErrBadRequest("name must be a DNS-1123 label")
|
||||
}
|
||||
ns, err := resolveNamespace(s, c, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cr, _, err := getAppCR(s, c.Context(), ns, name)
|
||||
if err != nil {
|
||||
return k8sErr(s, "get", err)
|
||||
}
|
||||
nodes := buildTree(s, c.Context(), ns, name, cr)
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"application": observeApplication(cr, ns, ""),
|
||||
"nodes": nodes,
|
||||
})
|
||||
}
|
||||
|
||||
// depth1GVRs are the operator-owned kinds scanned directly under the Service CR.
|
||||
var depth1GVRs = []schema.GroupVersionResource{
|
||||
k8s.Deployments, coreSvcGVR, ingressGVR, hpaGVR, pdbGVR, configMapsGVR,
|
||||
|
||||
@@ -439,7 +439,7 @@ func TestLinkedInBuild(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestXBuildAndScaffold(t *testing.T) {
|
||||
func TestXBuild(t *testing.T) {
|
||||
convs := xBuild([]Conversion{{Standard: EventPurchase, Value: 9, Currency: "USD", User: UserData{Email: "a@b.com", Clicks: map[string]string{"twclid": "tw1"}}}})
|
||||
if len(convs[0].Identifiers) != 2 || convs[0].Identifiers[0].HashedEmail != sha("a@b.com") {
|
||||
t.Errorf("identifiers: %+v", convs[0].Identifiers)
|
||||
@@ -447,16 +447,21 @@ func TestXBuildAndScaffold(t *testing.T) {
|
||||
if convs[0].Value != "9.00" {
|
||||
t.Errorf("value = %q", convs[0].Value)
|
||||
}
|
||||
// Send is an honest scaffold: it refuses (OAuth1 not wired) rather than faking.
|
||||
// The four OAuth1 parts ride as ONE composite JSON secret; a non-JSON blob is refused.
|
||||
if _, err := (xDest{}).Send(context.Background(), Config{"pixelId": "o1"}, "tok",
|
||||
[]Conversion{{Standard: EventPurchase, User: UserData{Email: "a@b.com"}}}); err == nil {
|
||||
t.Fatal("x Send must return the honest not-enabled error")
|
||||
t.Fatal("x Send must reject a non-JSON credential blob")
|
||||
}
|
||||
// Incomplete OAuth1 credentials are refused rather than sent unsigned.
|
||||
if _, err := (xDest{}).Send(context.Background(), Config{"pixelId": "o1"}, `{"consumer_key":"ck"}`,
|
||||
[]Conversion{{Standard: EventPurchase, User: UserData{Email: "a@b.com"}}}); err == nil {
|
||||
t.Fatal("x Send must reject incomplete OAuth1 credentials")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistryComplete asserts every platform self-registered with a coherent Spec.
|
||||
func TestRegistryComplete(t *testing.T) {
|
||||
want := []string{"ga4", "meta", "tiktok", "linkedin", "x", "reddit", "analytics", "insights"}
|
||||
want := []string{"ga4", "meta", "tiktok", "linkedin", "x", "reddit", "analytics", "insights", "pinterest", "google-ads"}
|
||||
m := snapshot()
|
||||
for _, id := range want {
|
||||
d, ok := m[id]
|
||||
|
||||
@@ -163,6 +163,8 @@ func routes(app cloud.Router, zapp *zip.App, s *cloud.Service[state]) {
|
||||
g.Post("/:platform", cloud.Handle(s, connect))
|
||||
zip.Delete(zapp, "/v1/destinations/:platform", o.disconnect)
|
||||
zip.Post(zapp, "/v1/destinations/:platform/test", o.test)
|
||||
// GET /v1/tags moved to the projects app — it must be served by the process that
|
||||
// owns the project store (production runs ~25 single-app processes).
|
||||
}
|
||||
|
||||
// The one untyped route DECLARES what it carries. Its request is the map the
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
package destinations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// googleads.go forwards conversions to Google Ads via the Ads API offline-conversion
|
||||
// import (uploadClickConversions) — this is GOOGLE ADS proper, not GA4. GA4 (ga4.go) is
|
||||
// web analytics over the Measurement Protocol; this uploads real conversions against a
|
||||
// configured conversion action, attributed by gclid and/or enhanced-conversion hashed
|
||||
// identifiers, so Google Ads bidding optimizes on them.
|
||||
//
|
||||
// Config (non-secret): customerId (the Ads account), conversionActionId (the conversion
|
||||
// action to import against), and an optional loginCustomerId (a manager account).
|
||||
// Secret: a composite JSON blob {developer_token, client_id, client_secret,
|
||||
// refresh_token} — the fan-out resolves ONE primary secret per destination, and Google
|
||||
// Ads needs four OAuth2 app/refresh values, so they ride together. Send exchanges the
|
||||
// refresh token for a short-lived access token, then uploads.
|
||||
//
|
||||
// Only CONVERSION-class events with a Google match key (a gclid, or a hashed email/phone
|
||||
// for enhanced conversions) are uploaded; a pageview or a keyless event is skipped —
|
||||
// offline conversion import is for conversions, not page traffic.
|
||||
|
||||
const googleadsID = "google-ads"
|
||||
|
||||
// googleAdsAPI / googleOAuthURL are the Ads API base and the OAuth2 token endpoint —
|
||||
// package vars so tests point them at mock servers; never mutated in production.
|
||||
var (
|
||||
googleAdsAPI = "https://googleads.googleapis.com/v18"
|
||||
googleOAuthURL = "https://oauth2.googleapis.com/token"
|
||||
)
|
||||
|
||||
// googleConversionEvents is the conversion-class subset of the taxonomy Google Ads
|
||||
// accepts as offline conversions. Traffic events (page_view/view_content/search) are not
|
||||
// conversions and are skipped.
|
||||
var googleConversionEvents = map[StandardEvent]bool{
|
||||
EventPurchase: true,
|
||||
EventLead: true,
|
||||
EventSignUp: true,
|
||||
EventStartCheckout: true,
|
||||
EventAddToCart: true,
|
||||
EventContact: true,
|
||||
}
|
||||
|
||||
type googleads struct{}
|
||||
|
||||
func init() { register(googleads{}) }
|
||||
|
||||
func (googleads) ID() string { return googleadsID }
|
||||
func (googleads) Name() string { return "Google Ads" }
|
||||
func (googleads) Category() string { return categoryAdvertising }
|
||||
|
||||
func (googleads) Spec() Spec {
|
||||
return Spec{
|
||||
Fields: []DestinationField{
|
||||
{Key: "customerId", Label: "Customer ID", Required: true, Example: "1234567890"},
|
||||
{Key: "conversionActionId", Label: "Conversion Action ID", Required: true, Example: "987654321"},
|
||||
{Key: "loginCustomerId", Label: "Login Customer ID (manager)", Required: false, Example: "1112223333"},
|
||||
},
|
||||
// One composite secret: the OAuth2 app + refresh credentials, as JSON.
|
||||
Secrets: []string{"oauth2"},
|
||||
}
|
||||
}
|
||||
|
||||
// googleCreds are the OAuth2 developer/app/refresh credentials, parsed from the single
|
||||
// composite KMS secret the connect flow seals for Google Ads.
|
||||
type googleCreds struct {
|
||||
DeveloperToken string `json:"developer_token"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
func (c googleCreds) complete() bool {
|
||||
return c.DeveloperToken != "" && c.ClientID != "" && c.ClientSecret != "" && c.RefreshToken != ""
|
||||
}
|
||||
|
||||
type googleUploadBody struct {
|
||||
Conversions []googleConversion `json:"conversions"`
|
||||
PartialFailure bool `json:"partialFailure"`
|
||||
}
|
||||
|
||||
type googleConversion struct {
|
||||
ConversionAction string `json:"conversionAction"`
|
||||
ConversionDateTime string `json:"conversionDateTime"`
|
||||
ConversionValue float64 `json:"conversionValue,omitempty"`
|
||||
CurrencyCode string `json:"currencyCode,omitempty"`
|
||||
OrderID string `json:"orderId,omitempty"`
|
||||
Gclid string `json:"gclid,omitempty"`
|
||||
Gbraid string `json:"gbraid,omitempty"`
|
||||
Wbraid string `json:"wbraid,omitempty"`
|
||||
UserIdentifiers []googleUserIdentifier `json:"userIdentifiers,omitempty"`
|
||||
}
|
||||
|
||||
type googleUserIdentifier struct {
|
||||
HashedEmail string `json:"hashedEmail,omitempty"`
|
||||
HashedPhoneNumber string `json:"hashedPhoneNumber,omitempty"`
|
||||
}
|
||||
|
||||
// googleadsBuild renders the batch into the uploadClickConversions body. Pure — tests
|
||||
// assert the conversion-action resource, the filtering (conversion-class + match key),
|
||||
// hashed identifiers, and the datetime format without a network call. Events that are
|
||||
// not conversions, or carry no Google match key, are dropped.
|
||||
func googleadsBuild(cfg Config, batch []Conversion) googleUploadBody {
|
||||
action := "customers/" + cfg.get("customerId") + "/conversionActions/" + cfg.get("conversionActionId")
|
||||
out := make([]googleConversion, 0, len(batch))
|
||||
for _, cv := range batch {
|
||||
if !googleConversionEvents[cv.Standard] {
|
||||
continue // not a conversion — Google Ads offline import is not page tracking
|
||||
}
|
||||
gclid := cv.User.click("gclid")
|
||||
ids := googleIdentifiers(cv.User)
|
||||
if gclid == "" && cv.User.click("gbraid") == "" && cv.User.click("wbraid") == "" && len(ids) == 0 {
|
||||
continue // no match key Google can attribute — skip rather than send a blind row
|
||||
}
|
||||
gc := googleConversion{
|
||||
ConversionAction: action,
|
||||
ConversionDateTime: googleTime(cv.Time),
|
||||
ConversionValue: cv.Value,
|
||||
OrderID: cv.EventID,
|
||||
Gclid: gclid,
|
||||
Gbraid: cv.User.click("gbraid"),
|
||||
Wbraid: cv.User.click("wbraid"),
|
||||
UserIdentifiers: ids,
|
||||
}
|
||||
if cv.Value > 0 {
|
||||
gc.CurrencyCode = cv.Currency
|
||||
}
|
||||
out = append(out, gc)
|
||||
}
|
||||
return googleUploadBody{Conversions: out, PartialFailure: true}
|
||||
}
|
||||
|
||||
// googleIdentifiers builds the enhanced-conversion user identifiers: hashed email and/or
|
||||
// phone. Google requires each as a separate UserIdentifier. Empty when neither present.
|
||||
func googleIdentifiers(u UserData) []googleUserIdentifier {
|
||||
var ids []googleUserIdentifier
|
||||
if h := hashEmail(u.Email); h != "" {
|
||||
ids = append(ids, googleUserIdentifier{HashedEmail: h})
|
||||
}
|
||||
if h := hashPhone(u.Phone); h != "" {
|
||||
ids = append(ids, googleUserIdentifier{HashedPhoneNumber: h})
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// googleTime formats an event time as Google Ads' required "yyyy-mm-dd hh:mm:ss+00:00"
|
||||
// (a space separator and a colon in the zone offset), in UTC. Zero time clamps to now.
|
||||
func googleTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
t = time.Now()
|
||||
}
|
||||
return t.UTC().Format("2006-01-02 15:04:05-07:00")
|
||||
}
|
||||
|
||||
// googleAccessToken exchanges the refresh token for a short-lived access token. The
|
||||
// OAuth2 token endpoint is FORM-encoded (not JSON), so it does not go through postJSON;
|
||||
// the error is credential-free (endpoint is a constant, never carries the secret).
|
||||
func googleAccessToken(ctx context.Context, c googleCreds) (string, error) {
|
||||
form := url.Values{
|
||||
"client_id": {c.ClientID},
|
||||
"client_secret": {c.ClientSecret},
|
||||
"refresh_token": {c.RefreshToken},
|
||||
"grant_type": {"refresh_token"},
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, googleOAuthURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("google-ads: build token request")
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := sendHTTP.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("google-ads: token request failed")
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxSendRespBody))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("google-ads: read token response")
|
||||
}
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return "", fmt.Errorf("google-ads: token http %d: %s", resp.StatusCode, truncate(raw, 256))
|
||||
}
|
||||
var tr struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &tr); err != nil || tr.AccessToken == "" {
|
||||
return "", fmt.Errorf("google-ads: token response had no access_token")
|
||||
}
|
||||
return tr.AccessToken, nil
|
||||
}
|
||||
|
||||
func (d googleads) Send(ctx context.Context, cfg Config, secret string, batch []Conversion) (Result, error) {
|
||||
customer := cfg.get("customerId")
|
||||
if customer == "" {
|
||||
return Result{}, fmt.Errorf("google-ads: customerId is required")
|
||||
}
|
||||
if cfg.get("conversionActionId") == "" {
|
||||
return Result{}, fmt.Errorf("google-ads: conversionActionId is required")
|
||||
}
|
||||
var c googleCreds
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(secret)), &c); err != nil {
|
||||
return Result{}, fmt.Errorf("google-ads: credentials must be a JSON object {developer_token, client_id, client_secret, refresh_token}")
|
||||
}
|
||||
if !c.complete() {
|
||||
return Result{}, fmt.Errorf("google-ads: OAuth2 credentials are incomplete")
|
||||
}
|
||||
body := googleadsBuild(cfg, batch)
|
||||
if len(body.Conversions) == 0 {
|
||||
return Result{}, nil // nothing conversion-class with a match key — send nothing
|
||||
}
|
||||
token, err := googleAccessToken(ctx, c)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
headers := map[string]string{
|
||||
"Authorization": "Bearer " + token,
|
||||
"developer-token": c.DeveloperToken,
|
||||
}
|
||||
// The manager account, when the upload runs under an MCC; else the customer itself.
|
||||
if login := cfg.get("loginCustomerId"); login != "" {
|
||||
headers["login-customer-id"] = login
|
||||
}
|
||||
endpoint := googleAdsAPI + "/customers/" + customer + ":uploadClickConversions"
|
||||
var resp struct {
|
||||
Results []struct {
|
||||
Gclid string `json:"gclid"`
|
||||
} `json:"results"`
|
||||
}
|
||||
if err := postJSON(ctx, googleadsID, endpoint, headers, body, &resp); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
sent := len(resp.Results)
|
||||
if sent == 0 {
|
||||
sent = len(body.Conversions)
|
||||
}
|
||||
return Result{Sent: sent}, nil
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package destinations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGoogleAdsBuild(t *testing.T) {
|
||||
cfg := Config{"customerId": "123", "conversionActionId": "456"}
|
||||
body := googleadsBuild(cfg, []Conversion{
|
||||
{Standard: EventPurchase, Name: "order_completed", Value: 49.5, Currency: "USD", EventID: "ord-1",
|
||||
Time: time.Unix(1700000000, 0),
|
||||
User: UserData{Email: " Bob@Example.COM ", Phone: "+1 (555) 000-1111", Clicks: map[string]string{"gclid": "G1"}}},
|
||||
{Standard: EventPageView, Name: "$pageview", User: UserData{Clicks: map[string]string{"gclid": "G2"}}}, // not a conversion → dropped
|
||||
{Standard: EventLead, Name: "plan_clicked", User: UserData{}}, // conversion but NO match key → dropped
|
||||
{Standard: EventSignUp, Name: "signup_completed", User: UserData{Email: "c@d.com"}}, // enhanced-only match key
|
||||
})
|
||||
if !body.PartialFailure {
|
||||
t.Error("partialFailure must be true")
|
||||
}
|
||||
if len(body.Conversions) != 2 {
|
||||
t.Fatalf("want 2 conversions (pageview + keyless lead dropped), got %d", len(body.Conversions))
|
||||
}
|
||||
c0 := body.Conversions[0]
|
||||
if c0.ConversionAction != "customers/123/conversionActions/456" {
|
||||
t.Errorf("conversionAction = %q", c0.ConversionAction)
|
||||
}
|
||||
if c0.Gclid != "G1" || c0.OrderID != "ord-1" || c0.ConversionValue != 49.5 || c0.CurrencyCode != "USD" {
|
||||
t.Errorf("conversion: %+v", c0)
|
||||
}
|
||||
// Google's required "yyyy-mm-dd hh:mm:ss+00:00" UTC format.
|
||||
if c0.ConversionDateTime != "2023-11-14 22:13:20+00:00" {
|
||||
t.Errorf("conversionDateTime = %q", c0.ConversionDateTime)
|
||||
}
|
||||
// Email + phone → two hashed enhanced-conversion identifiers.
|
||||
if len(c0.UserIdentifiers) != 2 ||
|
||||
c0.UserIdentifiers[0].HashedEmail != sha("bob@example.com") ||
|
||||
c0.UserIdentifiers[1].HashedPhoneNumber != sha("15550001111") {
|
||||
t.Errorf("userIdentifiers: %+v", c0.UserIdentifiers)
|
||||
}
|
||||
// The enhanced-only signup carries no gclid but a hashed email.
|
||||
c1 := body.Conversions[1]
|
||||
if c1.Gclid != "" || len(c1.UserIdentifiers) != 1 || c1.UserIdentifiers[0].HashedEmail != sha("c@d.com") {
|
||||
t.Errorf("enhanced-only conversion: %+v", c1)
|
||||
}
|
||||
// No raw PII in the marshalled payload.
|
||||
raw, _ := json.Marshal(body)
|
||||
if strings.Contains(strings.ToLower(string(raw)), "bob@example.com") {
|
||||
t.Fatal("raw email leaked into the Google Ads payload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleAdsSendEndToEnd(t *testing.T) {
|
||||
// Token endpoint (form-encoded) → returns a short-lived access token.
|
||||
var gotGrant string
|
||||
tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
gotGrant = r.Form.Get("grant_type")
|
||||
_, _ = w.Write([]byte(`{"access_token":"ya29.tok","expires_in":3600}`))
|
||||
}))
|
||||
defer tokenSrv.Close()
|
||||
// Upload endpoint → captures headers + body.
|
||||
var gotAuth, gotDevTok, gotLogin, gotPath string
|
||||
var gotBody googleUploadBody
|
||||
uploadSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
gotDevTok = r.Header.Get("developer-token")
|
||||
gotLogin = r.Header.Get("login-customer-id")
|
||||
gotPath = r.URL.Path
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(b, &gotBody)
|
||||
_, _ = w.Write([]byte(`{"results":[{"gclid":"G1"}]}`))
|
||||
}))
|
||||
defer uploadSrv.Close()
|
||||
|
||||
ot, ou := googleOAuthURL, googleAdsAPI
|
||||
googleOAuthURL, googleAdsAPI = tokenSrv.URL, uploadSrv.URL
|
||||
defer func() { googleOAuthURL, googleAdsAPI = ot, ou }()
|
||||
|
||||
creds := `{"developer_token":"DEV","client_id":"CID","client_secret":"CSEC","refresh_token":"RT"}`
|
||||
res, err := googleads{}.Send(context.Background(),
|
||||
Config{"customerId": "123", "conversionActionId": "456", "loginCustomerId": "999"}, creds,
|
||||
[]Conversion{{Standard: EventPurchase, Name: "order_completed", Value: 10, Currency: "USD", EventID: "e1",
|
||||
User: UserData{Clicks: map[string]string{"gclid": "G1"}}}})
|
||||
if err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
if res.Sent != 1 {
|
||||
t.Fatalf("sent = %d", res.Sent)
|
||||
}
|
||||
if gotGrant != "refresh_token" {
|
||||
t.Errorf("grant_type = %q, want refresh_token", gotGrant)
|
||||
}
|
||||
if gotAuth != "Bearer ya29.tok" || gotDevTok != "DEV" || gotLogin != "999" {
|
||||
t.Errorf("upload headers: auth=%q dev-token=%q login=%q", gotAuth, gotDevTok, gotLogin)
|
||||
}
|
||||
if gotPath != "/customers/123:uploadClickConversions" {
|
||||
t.Errorf("path = %q", gotPath)
|
||||
}
|
||||
if len(gotBody.Conversions) != 1 || gotBody.Conversions[0].Gclid != "G1" || !gotBody.PartialFailure {
|
||||
t.Errorf("body: %+v", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleAdsRequiresConfig(t *testing.T) {
|
||||
if _, err := (googleads{}).Send(context.Background(), Config{}, "{}", nil); err == nil {
|
||||
t.Fatal("missing customerId must error")
|
||||
}
|
||||
if _, err := (googleads{}).Send(context.Background(), Config{"customerId": "1"}, "{}", nil); err == nil {
|
||||
t.Fatal("missing conversionActionId must error")
|
||||
}
|
||||
if _, err := (googleads{}).Send(context.Background(), Config{"customerId": "1", "conversionActionId": "2"}, "not-json", nil); err == nil {
|
||||
t.Fatal("non-JSON credentials must error")
|
||||
}
|
||||
if _, err := (googleads{}).Send(context.Background(), Config{"customerId": "1", "conversionActionId": "2"}, `{"developer_token":"d"}`, nil); err == nil {
|
||||
t.Fatal("incomplete credentials must error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package destinations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// pinterest.go forwards conversions to the Pinterest Conversions API v5 (server-side)
|
||||
// — the server-side complement of the Pinterest tag, sharing the event_id so a browser
|
||||
// tag event and this server event DEDUPLICATE. Config: adAccountId (the Pinterest Ads
|
||||
// account the events file under). Secret: access_token — its own token, or the org's
|
||||
// pinterest_ads OAuth token via the integrations fallback. The token rides the
|
||||
// Authorization: Bearer header; email/phone/external id are SHA-256 hashed per
|
||||
// Pinterest's advanced-matching contract; the epik click id and the ip/user-agent ride
|
||||
// as Pinterest specifies. Same interface as GA4/Meta.
|
||||
|
||||
const pinterestID = "pinterest"
|
||||
|
||||
// pinterestAPI is the v5 base. A package var so a test points it at a mock server;
|
||||
// never mutated in production.
|
||||
var pinterestAPI = "https://api.pinterest.com/v5"
|
||||
|
||||
// pinterestEventName maps the normalized taxonomy onto Pinterest's conversion event
|
||||
// names — a CLOSED enum, unlike Meta's free-form custom names. A StandardEvent absent
|
||||
// here (EventCustom) forwards as the "custom" enum value: the specific canonical name
|
||||
// stays on the warehouse row while Pinterest groups it as custom, because the v5 API
|
||||
// rejects an event_name outside its enum.
|
||||
var pinterestEventName = map[StandardEvent]string{
|
||||
EventPageView: "page_visit",
|
||||
EventViewContent: "view_category",
|
||||
EventSearch: "search",
|
||||
EventLead: "lead",
|
||||
EventSignUp: "signup",
|
||||
EventStartCheckout: "checkout",
|
||||
EventAddToCart: "add_to_cart",
|
||||
EventPurchase: "checkout",
|
||||
EventContact: "lead",
|
||||
}
|
||||
|
||||
type pinterest struct{}
|
||||
|
||||
func init() { register(pinterest{}) }
|
||||
|
||||
func (pinterest) ID() string { return pinterestID }
|
||||
func (pinterest) Name() string { return "Pinterest" }
|
||||
func (pinterest) Category() string { return categoryAdvertising }
|
||||
|
||||
func (pinterest) Spec() Spec {
|
||||
return Spec{
|
||||
Fields: []DestinationField{
|
||||
{Key: "adAccountId", Label: "Ad Account ID", Required: true, Example: "549755885123"},
|
||||
},
|
||||
Secrets: []string{"access_token"},
|
||||
Fallback: "pinterest_ads", // reuse the integrations Pinterest connection's token
|
||||
}
|
||||
}
|
||||
|
||||
type pinterestBody struct {
|
||||
Data []pinterestEvent `json:"data"`
|
||||
}
|
||||
|
||||
type pinterestEvent struct {
|
||||
EventName string `json:"event_name"`
|
||||
ActionSource string `json:"action_source"`
|
||||
EventTime int64 `json:"event_time"`
|
||||
EventID string `json:"event_id,omitempty"`
|
||||
EventSourceURL string `json:"event_source_url,omitempty"`
|
||||
UserData map[string]any `json:"user_data"`
|
||||
CustomData map[string]any `json:"custom_data,omitempty"`
|
||||
}
|
||||
|
||||
// pinterestBuild renders the batch into the Conversions API body. Pure — tests assert
|
||||
// the event-name mapping, hashed match keys, and dedup event_id without a network call.
|
||||
func pinterestBuild(batch []Conversion) pinterestBody {
|
||||
data := make([]pinterestEvent, 0, len(batch))
|
||||
for _, cv := range batch {
|
||||
name := pinterestEventName[cv.Standard]
|
||||
if name == "" {
|
||||
name = "custom"
|
||||
}
|
||||
e := pinterestEvent{
|
||||
EventName: name,
|
||||
ActionSource: "web",
|
||||
EventTime: pinterestTime(cv.Time),
|
||||
EventID: cv.EventID,
|
||||
EventSourceURL: cv.URL,
|
||||
UserData: pinterestUser(cv.User),
|
||||
}
|
||||
if cd := pinterestCustomData(cv); len(cd) > 0 {
|
||||
e.CustomData = cd
|
||||
}
|
||||
data = append(data, e)
|
||||
}
|
||||
return pinterestBody{Data: data}
|
||||
}
|
||||
|
||||
// pinterestCustomData renders a conversion's commerce fields into Pinterest's
|
||||
// custom_data. Pinterest wants monetary VALUES as STRINGS (value, item_price); a
|
||||
// purchase also carries order_id, its native dedup key. Empty when the event carries
|
||||
// neither value nor items.
|
||||
func pinterestCustomData(cv Conversion) map[string]any {
|
||||
cd := map[string]any{}
|
||||
if cv.Value > 0 {
|
||||
cd["value"] = strconv.FormatFloat(cv.Value, 'f', -1, 64)
|
||||
cd["currency"] = cv.Currency
|
||||
}
|
||||
if len(cv.Items) > 0 {
|
||||
ids := make([]string, 0, len(cv.Items))
|
||||
contents := make([]map[string]any, 0, len(cv.Items))
|
||||
num := 0
|
||||
for _, it := range cv.Items {
|
||||
if it.ID != "" {
|
||||
ids = append(ids, it.ID)
|
||||
}
|
||||
c := map[string]any{}
|
||||
if it.Quantity > 0 {
|
||||
c["quantity"] = it.Quantity
|
||||
num += int(it.Quantity)
|
||||
} else {
|
||||
num++
|
||||
}
|
||||
if it.Price > 0 {
|
||||
c["item_price"] = strconv.FormatFloat(it.Price, 'f', -1, 64)
|
||||
}
|
||||
contents = append(contents, c)
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
cd["content_ids"] = ids
|
||||
}
|
||||
cd["contents"] = contents
|
||||
cd["num_items"] = num
|
||||
}
|
||||
if cv.Standard == EventPurchase && cv.EventID != "" {
|
||||
cd["order_id"] = cv.EventID
|
||||
}
|
||||
return cd
|
||||
}
|
||||
|
||||
// pinterestTime clamps to now when the event carries no timestamp.
|
||||
func pinterestTime(t time.Time) int64 {
|
||||
if t.IsZero() {
|
||||
return time.Now().Unix()
|
||||
}
|
||||
return t.Unix()
|
||||
}
|
||||
|
||||
// pinterestUser builds the advanced-matching user_data: hashed email/phone/external id
|
||||
// (arrays per Pinterest), the epik click id (un-hashed), and the un-hashed network
|
||||
// signals (ip, user agent). Only present keys are set — Pinterest requires at least one.
|
||||
func pinterestUser(u UserData) map[string]any {
|
||||
ud := map[string]any{}
|
||||
if h := hashEmail(u.Email); h != "" {
|
||||
ud["em"] = []string{h}
|
||||
}
|
||||
if h := hashPhone(u.Phone); h != "" {
|
||||
ud["ph"] = []string{h}
|
||||
}
|
||||
if h := sha256hex(strings.ToLower(strings.TrimSpace(u.ExternalID))); h != "" {
|
||||
ud["external_id"] = []string{h}
|
||||
}
|
||||
if u.IP != "" {
|
||||
ud["client_ip_address"] = u.IP
|
||||
}
|
||||
if u.UserAgent != "" {
|
||||
ud["client_user_agent"] = u.UserAgent
|
||||
}
|
||||
if ck := u.click("epik"); ck != "" {
|
||||
ud["click_id"] = ck
|
||||
}
|
||||
return ud
|
||||
}
|
||||
|
||||
// pinterestResponse is the v5 events success shape: how many events Pinterest received.
|
||||
type pinterestResponse struct {
|
||||
NumEventsReceived int `json:"num_events_received"`
|
||||
NumEventsProcessed int `json:"num_events_processed"`
|
||||
}
|
||||
|
||||
func (d pinterest) Send(ctx context.Context, cfg Config, secret string, batch []Conversion) (Result, error) {
|
||||
account := cfg.get("adAccountId")
|
||||
if account == "" {
|
||||
return Result{}, fmt.Errorf("pinterest: adAccountId is required")
|
||||
}
|
||||
if strings.TrimSpace(secret) == "" {
|
||||
return Result{}, fmt.Errorf("pinterest: access_token is required")
|
||||
}
|
||||
if len(batch) == 0 {
|
||||
return Result{}, nil
|
||||
}
|
||||
body := pinterestBuild(batch)
|
||||
endpoint := pinterestAPI + "/ad_accounts/" + account + "/events"
|
||||
headers := map[string]string{"Authorization": "Bearer " + secret}
|
||||
var resp pinterestResponse
|
||||
if err := postJSON(ctx, pinterestID, endpoint, headers, body, &resp); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
sent := resp.NumEventsReceived
|
||||
if sent == 0 {
|
||||
sent = len(batch)
|
||||
}
|
||||
return Result{Sent: sent}, nil
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package destinations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPinterestBuild(t *testing.T) {
|
||||
body := pinterestBuild([]Conversion{
|
||||
{
|
||||
Standard: EventPurchase, Name: "order_completed", Value: 20, Currency: "USD", EventID: "evt-9",
|
||||
URL: "https://shop.example/checkout", Time: time.Unix(1700000000, 0),
|
||||
User: UserData{Email: " Bob@Example.COM ", Phone: "+1 (555) 000-1111", ExternalID: "u-42",
|
||||
IP: "203.0.113.5", UserAgent: "UA", Clicks: map[string]string{"epik": "ep1"}},
|
||||
},
|
||||
{Standard: EventCustom, Name: "feature_used", User: UserData{Email: "a@b.com"}},
|
||||
})
|
||||
if len(body.Data) != 2 {
|
||||
t.Fatalf("want 2 events, got %d", len(body.Data))
|
||||
}
|
||||
e := body.Data[0]
|
||||
if e.EventName != "checkout" || e.EventID != "evt-9" || e.ActionSource != "web" {
|
||||
t.Errorf("event: %+v", e)
|
||||
}
|
||||
if e.EventTime != 1700000000 {
|
||||
t.Errorf("event_time = %d, want 1700000000", e.EventTime)
|
||||
}
|
||||
// Email/phone/external id hashed (arrays); the epik click id + ip/ua ride un-hashed.
|
||||
if em, _ := e.UserData["em"].([]string); len(em) != 1 || em[0] != sha("bob@example.com") {
|
||||
t.Errorf("em = %v, want %s", e.UserData["em"], sha("bob@example.com"))
|
||||
}
|
||||
if ph, _ := e.UserData["ph"].([]string); len(ph) != 1 || ph[0] != sha("15550001111") {
|
||||
t.Errorf("ph = %v", e.UserData["ph"])
|
||||
}
|
||||
if xid, _ := e.UserData["external_id"].([]string); len(xid) != 1 || xid[0] != sha("u-42") {
|
||||
t.Errorf("external_id = %v", e.UserData["external_id"])
|
||||
}
|
||||
if e.UserData["client_ip_address"] != "203.0.113.5" || e.UserData["client_user_agent"] != "UA" {
|
||||
t.Errorf("network signals: %+v", e.UserData)
|
||||
}
|
||||
if e.UserData["click_id"] != "ep1" {
|
||||
t.Errorf("click_id (epik) = %v, want ep1", e.UserData["click_id"])
|
||||
}
|
||||
// Pinterest wants value as a STRING; a purchase carries its native order id.
|
||||
if e.CustomData["value"] != "20" || e.CustomData["currency"] != "USD" {
|
||||
t.Errorf("custom_data value/currency: %+v", e.CustomData)
|
||||
}
|
||||
if e.CustomData["order_id"] != "evt-9" {
|
||||
t.Errorf("order_id = %v, want evt-9", e.CustomData["order_id"])
|
||||
}
|
||||
// A custom (unmapped) event collapses to Pinterest's "custom" enum value.
|
||||
if body.Data[1].EventName != "custom" {
|
||||
t.Errorf("custom event name = %q, want custom", body.Data[1].EventName)
|
||||
}
|
||||
// The raw email must NEVER appear in the marshalled payload.
|
||||
raw, _ := json.Marshal(body)
|
||||
if strings.Contains(strings.ToLower(string(raw)), "bob@example.com") {
|
||||
t.Fatal("raw email leaked into the Pinterest payload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinterestEcommerceContents(t *testing.T) {
|
||||
body := pinterestBuild([]Conversion{{
|
||||
Standard: EventPurchase, Name: "order_completed", Value: 59.98, Currency: "USD", EventID: "ord-9",
|
||||
User: UserData{Email: "a@b.com"},
|
||||
Items: []Item{
|
||||
{ID: "SKU1", Price: 24.99, Quantity: 2},
|
||||
{ID: "SKU2", Price: 10.0, Quantity: 1},
|
||||
},
|
||||
}})
|
||||
cd := body.Data[0].CustomData
|
||||
if cd["value"] != "59.98" || cd["currency"] != "USD" {
|
||||
t.Errorf("value/currency: %+v", cd)
|
||||
}
|
||||
if ids, _ := cd["content_ids"].([]string); len(ids) != 2 || ids[0] != "SKU1" || ids[1] != "SKU2" {
|
||||
t.Errorf("content_ids: %+v", cd["content_ids"])
|
||||
}
|
||||
if cd["num_items"] != 3 {
|
||||
t.Errorf("num_items = %v, want 3", cd["num_items"])
|
||||
}
|
||||
// item_price rides as a string per Pinterest's contents contract.
|
||||
contents, _ := cd["contents"].([]map[string]any)
|
||||
if len(contents) != 2 || contents[0]["item_price"] != "24.99" || contents[0]["quantity"] != 2.0 {
|
||||
t.Errorf("contents: %+v", cd["contents"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinterestSendEndToEnd(t *testing.T) {
|
||||
var gotAccount, gotAuth string
|
||||
var gotBody pinterestBody
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") // /ad_accounts/{id}/events
|
||||
if len(parts) >= 2 {
|
||||
gotAccount = parts[1]
|
||||
}
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(b, &gotBody)
|
||||
_, _ = w.Write([]byte(`{"num_events_received":1,"num_events_processed":1}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
old := pinterestAPI
|
||||
pinterestAPI = srv.URL
|
||||
defer func() { pinterestAPI = old }()
|
||||
|
||||
res, err := pinterest{}.Send(context.Background(), Config{"adAccountId": "549755885123"}, "pina_tok",
|
||||
[]Conversion{{Standard: EventLead, Name: "plan_clicked", User: UserData{Email: "x@y.com"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
if res.Sent != 1 {
|
||||
t.Fatalf("sent = %d", res.Sent)
|
||||
}
|
||||
if gotAccount != "549755885123" {
|
||||
t.Errorf("account = %q", gotAccount)
|
||||
}
|
||||
// The token MUST ride the Authorization header, never the URL.
|
||||
if gotAuth != "Bearer pina_tok" {
|
||||
t.Errorf("auth = %q, want Bearer pina_tok", gotAuth)
|
||||
}
|
||||
if len(gotBody.Data) != 1 || gotBody.Data[0].EventName != "lead" {
|
||||
t.Errorf("body: %+v", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinterestRequiresConfig(t *testing.T) {
|
||||
if _, err := (pinterest{}).Send(context.Background(), Config{}, "s", nil); err == nil {
|
||||
t.Fatal("missing adAccountId must error")
|
||||
}
|
||||
if _, err := (pinterest{}).Send(context.Background(), Config{"adAccountId": "1"}, "", nil); err == nil {
|
||||
t.Fatal("missing access_token must error")
|
||||
}
|
||||
}
|
||||
@@ -79,8 +79,8 @@ func tiktokBuild(cfg Config, batch []Conversion) tiktokBody {
|
||||
if cv.URL != "" {
|
||||
e.Page = map[string]any{"url": cv.URL}
|
||||
}
|
||||
if cv.Value > 0 {
|
||||
e.Properties = map[string]any{"value": cv.Value, "currency": cv.Currency}
|
||||
if props := tiktokProperties(cv); len(props) > 0 {
|
||||
e.Properties = props
|
||||
}
|
||||
data = append(data, e)
|
||||
}
|
||||
@@ -112,6 +112,47 @@ func tiktokUser(u UserData) map[string]any {
|
||||
return user
|
||||
}
|
||||
|
||||
// tiktokProperties renders a conversion's commerce fields into TikTok's Events API
|
||||
// properties: value/currency, and — for an ecommerce event — the native product signals
|
||||
// (contents[{content_id,content_name,content_category,brand,price,quantity}] +
|
||||
// content_type) TikTok's Value-Based Optimization reads. Empty when the event carries
|
||||
// neither value nor items.
|
||||
func tiktokProperties(cv Conversion) map[string]any {
|
||||
p := map[string]any{}
|
||||
if cv.Value > 0 {
|
||||
p["value"] = cv.Value
|
||||
p["currency"] = cv.Currency
|
||||
}
|
||||
if len(cv.Items) > 0 {
|
||||
contents := make([]map[string]any, 0, len(cv.Items))
|
||||
for _, it := range cv.Items {
|
||||
c := map[string]any{}
|
||||
if it.ID != "" {
|
||||
c["content_id"] = it.ID
|
||||
}
|
||||
if it.Name != "" {
|
||||
c["content_name"] = it.Name
|
||||
}
|
||||
if it.Category != "" {
|
||||
c["content_category"] = it.Category
|
||||
}
|
||||
if it.Brand != "" {
|
||||
c["brand"] = it.Brand
|
||||
}
|
||||
if it.Price > 0 {
|
||||
c["price"] = it.Price
|
||||
}
|
||||
if it.Quantity > 0 {
|
||||
c["quantity"] = it.Quantity
|
||||
}
|
||||
contents = append(contents, c)
|
||||
}
|
||||
p["contents"] = contents
|
||||
p["content_type"] = "product"
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (d tiktok) Send(ctx context.Context, cfg Config, secret string, batch []Conversion) (Result, error) {
|
||||
if cfg.get("pixelCode") == "" {
|
||||
return Result{}, fmt.Errorf("tiktok: pixelCode is required")
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package destinations
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestTikTokEcommerce proves the ecommerce translation: our line items → TikTok's native
|
||||
// contents[] + content_type, alongside value/currency, for Value-Based Optimization.
|
||||
func TestTikTokEcommerce(t *testing.T) {
|
||||
body := tiktokBuild(Config{"pixelCode": "PC1"}, []Conversion{{
|
||||
Standard: EventPurchase, Name: "order_completed", Value: 59.98, Currency: "USD", EventID: "ord-9",
|
||||
User: UserData{Email: "a@b.com"},
|
||||
Items: []Item{
|
||||
{ID: "SKU1", Name: "Widget", Category: "tools", Brand: "Acme", Price: 24.99, Quantity: 2},
|
||||
{ID: "SKU2", Price: 10.0, Quantity: 1},
|
||||
},
|
||||
}})
|
||||
p := body.Data[0].Properties
|
||||
if p["value"] != 59.98 || p["currency"] != "USD" || p["content_type"] != "product" {
|
||||
t.Errorf("value/currency/content_type: %+v", p)
|
||||
}
|
||||
contents, ok := p["contents"].([]map[string]any)
|
||||
if !ok || len(contents) != 2 {
|
||||
t.Fatalf("contents = %+v", p["contents"])
|
||||
}
|
||||
if contents[0]["content_id"] != "SKU1" || contents[0]["content_name"] != "Widget" ||
|
||||
contents[0]["content_category"] != "tools" || contents[0]["brand"] != "Acme" ||
|
||||
contents[0]["price"] != 24.99 || contents[0]["quantity"] != 2.0 {
|
||||
t.Errorf("content[0]: %+v", contents[0])
|
||||
}
|
||||
// A non-commerce event carries no properties block (nothing to over-send).
|
||||
pv := tiktokBuild(Config{"pixelCode": "PC1"}, []Conversion{{Standard: EventPageView, Name: "$pageview", User: UserData{ExternalID: "v1"}}})
|
||||
if pv.Data[0].Properties != nil {
|
||||
t.Errorf("pageview must carry no properties, got %+v", pv.Data[0].Properties)
|
||||
}
|
||||
}
|
||||
@@ -45,9 +45,13 @@ var standardOf = map[string]StandardEvent{
|
||||
// (pre-warehouse-scrub) properties carry the match keys the User set is lifted from.
|
||||
func Translate(ev analytics.SinkEvent) Conversion {
|
||||
return Conversion{
|
||||
Standard: standardOf[ev.Name], // "" ⇒ custom, forwarded as ev.Name
|
||||
Name: ev.Name,
|
||||
EventID: ev.MessageID,
|
||||
Standard: standardOf[ev.Name], // "" ⇒ custom, forwarded as ev.Name
|
||||
Name: ev.Name,
|
||||
// The dedup id: the browser tag's own event_id when it set one (track.js stamps
|
||||
// it into properties + fires it on the pixel), else the messageId. This is what
|
||||
// makes a browser pixel event and this server CAPI event DEDUPLICATE — without
|
||||
// it the two carry different ids and a conversion is double-counted.
|
||||
EventID: firstNonEmpty(strProp(ev.Properties, "event_id"), ev.MessageID),
|
||||
Time: ev.Time,
|
||||
Value: conversionValue(ev),
|
||||
Currency: conversionCurrency(ev),
|
||||
@@ -153,7 +157,7 @@ func liftUser(ev analytics.SinkEvent) UserData {
|
||||
FBP: strProp(p, "fbp", "_fbp"),
|
||||
}
|
||||
clicks := map[string]string{}
|
||||
for _, k := range []string{"fbclid", "gclid", "ttclid", "twclid", "rdt_cid", "li_fat_id", "msclkid"} {
|
||||
for _, k := range []string{"fbclid", "gclid", "ttclid", "twclid", "rdt_cid", "li_fat_id", "msclkid", "epik"} {
|
||||
if v := strProp(p, k); v != "" {
|
||||
clicks[k] = v
|
||||
}
|
||||
|
||||
+150
-17
@@ -2,19 +2,45 @@ package destinations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// x.go scaffolds X (Twitter) conversions against the shared interface. Config: the
|
||||
// web-event tag pixelId. The X Ads conversions endpoint authenticates with OAuth
|
||||
// 1.0a request signing (a consumer key/secret + access token/secret), NOT a bearer
|
||||
// token — a signer that is deliberately NOT wired here. The translator + payload
|
||||
// builder are complete, so enabling X is adding the OAuth1 signer to Send, never a
|
||||
// new interface. Until then Send returns an honest, credential-free error and the
|
||||
// fan-out skips it.
|
||||
// x.go forwards conversions to X (Twitter) via the Ads API web-events measurement
|
||||
// endpoint. Config: the web-event tag pixelId. The X Ads API authenticates with OAuth
|
||||
// 1.0a USER-context request signing — a consumer key/secret PLUS an access token/secret
|
||||
// — not a single bearer token. resolveSecret hands an adapter exactly one primary
|
||||
// secret, so X's four OAuth1 parts ride as ONE composite KMS secret: a JSON object
|
||||
// {consumer_key, consumer_secret, access_token, access_token_secret}. The signer below
|
||||
// is the only thing X needs beyond the shared interface; the translator + payload
|
||||
// builder are unchanged. PII match keys are SHA-256 hashed by xBuild; the shared
|
||||
// event_id dedups against the browser tag.
|
||||
|
||||
const xID = "x"
|
||||
|
||||
// xAdsAPI is the X Ads API base (version-pinned). A package var so a test points it at
|
||||
// a mock server; never mutated in production.
|
||||
var xAdsAPI = "https://ads-api.x.com/12"
|
||||
|
||||
// xNonce / xTimestamp are the OAuth1 nonce and timestamp sources — package vars so a
|
||||
// test pins them for a deterministic signature. Production reads crypto/rand + wall
|
||||
// clock. xTimestamp is injected (not time.Now directly) so a signed request is
|
||||
// reproducible under test.
|
||||
var (
|
||||
xNonce = func() string { b := make([]byte, 16); _, _ = rand.Read(b); return hex.EncodeToString(b) }
|
||||
xTimestamp = func() int64 { return time.Now().Unix() }
|
||||
)
|
||||
|
||||
type xDest struct{}
|
||||
|
||||
func init() { register(xDest{}) }
|
||||
@@ -28,10 +54,30 @@ func (xDest) Spec() Spec {
|
||||
Fields: []DestinationField{
|
||||
{Key: "pixelId", Label: "Pixel / Event Tag ID", Required: true, Example: "o1abc"},
|
||||
},
|
||||
Secrets: []string{"access_token"},
|
||||
// One composite secret: a JSON object carrying the four OAuth1 parts, because
|
||||
// the fan-out resolves and passes a single primary secret per destination.
|
||||
Secrets: []string{"oauth1"},
|
||||
}
|
||||
}
|
||||
|
||||
// xCreds are the OAuth 1.0a user-context credentials, parsed from the single composite
|
||||
// KMS secret the connect flow seals for X.
|
||||
type xCreds struct {
|
||||
ConsumerKey string `json:"consumer_key"`
|
||||
ConsumerSecret string `json:"consumer_secret"`
|
||||
AccessToken string `json:"access_token"`
|
||||
AccessSecret string `json:"access_token_secret"`
|
||||
}
|
||||
|
||||
func (c xCreds) complete() bool {
|
||||
return c.ConsumerKey != "" && c.ConsumerSecret != "" && c.AccessToken != "" && c.AccessSecret != ""
|
||||
}
|
||||
|
||||
// xConvBody wraps the conversions in the Ads-API measurement request body.
|
||||
type xConvBody struct {
|
||||
Conversions []xConversion `json:"conversions"`
|
||||
}
|
||||
|
||||
type xConversion struct {
|
||||
ConversionTime string `json:"conversion_time"`
|
||||
EventID string `json:"event_id,omitempty"`
|
||||
@@ -46,9 +92,22 @@ type xIDent struct {
|
||||
TwClickID string `json:"twclid,omitempty"`
|
||||
}
|
||||
|
||||
// xBuild renders the batch into X's conversions payload. Pure — tests assert the
|
||||
// hashed identifiers + event-name mapping — so the scaffold carries a real, reviewed
|
||||
// payload the day the OAuth1 signer lands.
|
||||
// xNumItems is the total quantity across an event's line items (each item's quantity,
|
||||
// or 1 when unspecified) — X's number_items. 0 for a non-commerce event.
|
||||
func xNumItems(items []Item) int {
|
||||
n := 0
|
||||
for _, it := range items {
|
||||
if it.Quantity > 0 {
|
||||
n += int(it.Quantity)
|
||||
} else {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// xBuild renders the batch into X's conversions payload. Pure — tests assert the hashed
|
||||
// identifiers + value formatting without a network call.
|
||||
func xBuild(batch []Conversion) []xConversion {
|
||||
out := make([]xConversion, 0, len(batch))
|
||||
for _, cv := range batch {
|
||||
@@ -59,7 +118,7 @@ func xBuild(batch []Conversion) []xConversion {
|
||||
if tw := cv.User.click("twclid"); tw != "" {
|
||||
ids = append(ids, xIDent{TwClickID: tw})
|
||||
}
|
||||
c := xConversion{ConversionTime: redditTime(cv.Time), EventID: cv.EventID, Identifiers: ids}
|
||||
c := xConversion{ConversionTime: redditTime(cv.Time), EventID: cv.EventID, Identifiers: ids, NumberItems: xNumItems(cv.Items)}
|
||||
if cv.Value > 0 {
|
||||
c.Value = fmt.Sprintf("%.2f", cv.Value)
|
||||
c.PriceCurrency = cv.Currency
|
||||
@@ -69,11 +128,85 @@ func xBuild(batch []Conversion) []xConversion {
|
||||
return out
|
||||
}
|
||||
|
||||
func (d xDest) Send(_ context.Context, cfg Config, _ string, _ []Conversion) (Result, error) {
|
||||
if cfg.get("pixelId") == "" {
|
||||
// oauthEnc percent-encodes per RFC 3986 (OAuth 1.0a §3.6): the unreserved set stays
|
||||
// literal, everything else is %-encoded upper-hex. net/url does NOT do this exactly
|
||||
// (it leaves some sub-delims), so OAuth1 needs its own encoder.
|
||||
func oauthEnc(s string) string {
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
|
||||
c == '-' || c == '.' || c == '_' || c == '~' {
|
||||
b.WriteByte(c)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "%%%02X", c)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// xAuthHeader builds the OAuth 1.0a Authorization header for a request. A JSON body is
|
||||
// NOT part of the signature base string (only form-encoded params and query params
|
||||
// are), so the base string is the method, the URL, and the sorted oauth_* params.
|
||||
func xAuthHeader(method, endpoint string, c xCreds) string {
|
||||
params := map[string]string{
|
||||
"oauth_consumer_key": c.ConsumerKey,
|
||||
"oauth_nonce": xNonce(),
|
||||
"oauth_signature_method": "HMAC-SHA1",
|
||||
"oauth_timestamp": strconv.FormatInt(xTimestamp(), 10),
|
||||
"oauth_token": c.AccessToken,
|
||||
"oauth_version": "1.0",
|
||||
}
|
||||
|
||||
// Signature base string: METHOD & enc(url) & enc(sorted "k=v" params).
|
||||
keys := make([]string, 0, len(params))
|
||||
for k := range params {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
pairs := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
pairs = append(pairs, oauthEnc(k)+"="+oauthEnc(params[k]))
|
||||
}
|
||||
base := strings.ToUpper(method) + "&" + oauthEnc(endpoint) + "&" + oauthEnc(strings.Join(pairs, "&"))
|
||||
signingKey := oauthEnc(c.ConsumerSecret) + "&" + oauthEnc(c.AccessSecret)
|
||||
mac := hmac.New(sha1.New, []byte(signingKey))
|
||||
mac.Write([]byte(base))
|
||||
params["oauth_signature"] = base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
|
||||
// Header: OAuth k="v", … over the same params incl. the signature.
|
||||
hkeys := make([]string, 0, len(params))
|
||||
for k := range params {
|
||||
hkeys = append(hkeys, k)
|
||||
}
|
||||
sort.Strings(hkeys)
|
||||
parts := make([]string, 0, len(hkeys))
|
||||
for _, k := range hkeys {
|
||||
parts = append(parts, oauthEnc(k)+`="`+oauthEnc(params[k])+`"`)
|
||||
}
|
||||
return "OAuth " + strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func (d xDest) Send(ctx context.Context, cfg Config, secret string, batch []Conversion) (Result, error) {
|
||||
pixel := cfg.get("pixelId")
|
||||
if pixel == "" {
|
||||
return Result{}, fmt.Errorf("x: pixelId is required")
|
||||
}
|
||||
// The X Ads conversions API requires OAuth 1.0a request signing, which is not
|
||||
// wired. Honest, credential-free: the fan-out logs and skips.
|
||||
return Result{}, fmt.Errorf("x: the X Ads conversions API requires OAuth 1.0a app credentials that are not yet enabled")
|
||||
var c xCreds
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(secret)), &c); err != nil {
|
||||
return Result{}, fmt.Errorf("x: credentials must be a JSON object {consumer_key, consumer_secret, access_token, access_token_secret}")
|
||||
}
|
||||
if !c.complete() {
|
||||
return Result{}, fmt.Errorf("x: OAuth1 credentials are incomplete")
|
||||
}
|
||||
if len(batch) == 0 {
|
||||
return Result{}, nil
|
||||
}
|
||||
endpoint := xAdsAPI + "/measurement/conversions/" + pixel
|
||||
body := xConvBody{Conversions: xBuild(batch)}
|
||||
headers := map[string]string{"Authorization": xAuthHeader(http.MethodPost, endpoint, c)}
|
||||
if err := postJSON(ctx, xID, endpoint, headers, body, nil); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return Result{Sent: len(batch)}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package destinations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// xStubOAuth pins the nonce + timestamp for a deterministic signature, restoring both
|
||||
// on the returned cleanup.
|
||||
func xStubOAuth(nonce string, ts int64) func() {
|
||||
on, ot := xNonce, xTimestamp
|
||||
xNonce = func() string { return nonce }
|
||||
xTimestamp = func() int64 { return ts }
|
||||
return func() { xNonce = on; xTimestamp = ot }
|
||||
}
|
||||
|
||||
// xSigFromHeader extracts and percent-decodes the oauth_signature from an OAuth header.
|
||||
func xSigFromHeader(hdr string) string {
|
||||
const mark = `oauth_signature="`
|
||||
i := strings.Index(hdr, mark)
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
rest := hdr[i+len(mark):]
|
||||
j := strings.Index(rest, `"`)
|
||||
if j < 0 {
|
||||
return ""
|
||||
}
|
||||
dec, err := url.PathUnescape(rest[:j])
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return dec
|
||||
}
|
||||
|
||||
// TestXOAuthSignature cross-checks the OAuth 1.0a signer: it independently reconstructs
|
||||
// the signature base string + signing key and recomputes the HMAC-SHA1, so a bug in the
|
||||
// base-string assembly (param ordering, separators, a double-encode) fails here rather
|
||||
// than silently producing a 401 in production.
|
||||
func TestXOAuthSignature(t *testing.T) {
|
||||
defer xStubOAuth("nonceABC", 1700000000)()
|
||||
endpoint := "https://ads-api.x.com/12/measurement/conversions/o1abc"
|
||||
c := xCreds{ConsumerKey: "CK", ConsumerSecret: "CS", AccessToken: "AT", AccessSecret: "ATS"}
|
||||
hdr := xAuthHeader(http.MethodPost, endpoint, c)
|
||||
|
||||
// The six oauth_* params in sorted order, "k=v" joined by & — every value here is
|
||||
// already in the RFC-3986 unreserved set, so it encodes to itself.
|
||||
pstr := strings.Join([]string{
|
||||
"oauth_consumer_key=CK",
|
||||
"oauth_nonce=nonceABC",
|
||||
"oauth_signature_method=HMAC-SHA1",
|
||||
"oauth_timestamp=1700000000",
|
||||
"oauth_token=AT",
|
||||
"oauth_version=1.0",
|
||||
}, "&")
|
||||
base := "POST&" + oauthEnc(endpoint) + "&" + oauthEnc(pstr)
|
||||
mac := hmac.New(sha1.New, []byte("CS&ATS"))
|
||||
mac.Write([]byte(base))
|
||||
want := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
|
||||
if got := xSigFromHeader(hdr); got != want {
|
||||
t.Fatalf("oauth_signature = %q, want %q\nheader: %s", got, want, hdr)
|
||||
}
|
||||
for _, must := range []string{
|
||||
"OAuth ", `oauth_consumer_key="CK"`, `oauth_nonce="nonceABC"`,
|
||||
`oauth_signature_method="HMAC-SHA1"`, `oauth_token="AT"`, `oauth_version="1.0"`,
|
||||
} {
|
||||
if !strings.Contains(hdr, must) {
|
||||
t.Errorf("header missing %q: %s", must, hdr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestXSendEndToEnd(t *testing.T) {
|
||||
defer xStubOAuth("fixednonce123", 1700000000)()
|
||||
var gotAuth, gotPath string
|
||||
var gotBody xConvBody
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
gotPath = r.URL.Path
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(b, &gotBody)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
old := xAdsAPI
|
||||
xAdsAPI = srv.URL
|
||||
defer func() { xAdsAPI = old }()
|
||||
|
||||
creds := `{"consumer_key":"CK","consumer_secret":"CS","access_token":"AT","access_token_secret":"ATS"}`
|
||||
res, err := xDest{}.Send(context.Background(), Config{"pixelId": "o1abc"}, creds,
|
||||
[]Conversion{{Standard: EventPurchase, Name: "order_completed", Value: 9, Currency: "USD", EventID: "e1",
|
||||
User: UserData{Email: "a@b.com", Clicks: map[string]string{"twclid": "tw1"}}}})
|
||||
if err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
if res.Sent != 1 {
|
||||
t.Fatalf("sent = %d", res.Sent)
|
||||
}
|
||||
if gotPath != "/measurement/conversions/o1abc" {
|
||||
t.Errorf("path = %q, want /measurement/conversions/o1abc", gotPath)
|
||||
}
|
||||
// The request is OAuth1-signed with our pinned nonce/timestamp and a signature.
|
||||
if !strings.HasPrefix(gotAuth, "OAuth ") ||
|
||||
!strings.Contains(gotAuth, `oauth_nonce="fixednonce123"`) ||
|
||||
!strings.Contains(gotAuth, `oauth_timestamp="1700000000"`) ||
|
||||
!strings.Contains(gotAuth, "oauth_signature=") {
|
||||
t.Errorf("oauth header malformed: %s", gotAuth)
|
||||
}
|
||||
if len(gotBody.Conversions) != 1 || gotBody.Conversions[0].EventID != "e1" || gotBody.Conversions[0].Value != "9.00" {
|
||||
t.Errorf("body: %+v", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXNumItems(t *testing.T) {
|
||||
convs := xBuild([]Conversion{{Standard: EventPurchase, Value: 30, Currency: "USD",
|
||||
User: UserData{Email: "a@b.com"},
|
||||
Items: []Item{{ID: "s1", Quantity: 2}, {ID: "s2", Quantity: 1}}}})
|
||||
if convs[0].NumberItems != 3 {
|
||||
t.Errorf("number_items = %d, want 3 (2+1)", convs[0].NumberItems)
|
||||
}
|
||||
// No items ⇒ number_items omitted (0).
|
||||
none := xBuild([]Conversion{{Standard: EventLead, User: UserData{Email: "a@b.com"}}})
|
||||
if none[0].NumberItems != 0 {
|
||||
t.Errorf("no items ⇒ number_items 0, got %d", none[0].NumberItems)
|
||||
}
|
||||
}
|
||||
+98
-17
@@ -73,6 +73,18 @@ import (
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// zipdoc lifts the doc comment off each typed op and its In/Out fields into
|
||||
// zipdoc_gen.go, which is the ONLY way that prose reaches the published document
|
||||
// and the MCP tool list — Go drops comments at compile time. Run by
|
||||
// `make -C apps/exec describe` and by the Dockerfile before every build.
|
||||
//
|
||||
// Without this directive the package builds, tests pass, and the ONE typed op here
|
||||
// publishes a summary with no description: openapi.Complete accepts either, so the
|
||||
// gap is invisible to every gate and visible in every SDK. POST /v1/exec shipped
|
||||
// exactly that way.
|
||||
//
|
||||
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
|
||||
|
||||
// Path is where the code interpreter answers. It lives HERE, in the app that serves
|
||||
// it, because three consumers once disagreed about it in production and nothing
|
||||
// could see the disagreement.
|
||||
@@ -119,14 +131,30 @@ var langs = map[string]struct{ file, run string }{
|
||||
// sends: lang/code/args from the model's tool call, files/session_id/user_id from
|
||||
// the host's injection, runtime_session_hint from the stateful-session path.
|
||||
type CodeRun struct {
|
||||
// Lang selects the toolchain, and with it the filename the code is written to
|
||||
// and the line that runs it: py, js, ts, bash, r, php, go, rs, c, cpp, java, d,
|
||||
// f90. Anything else is refused rather than guessed at — a run in the wrong
|
||||
// language fails somewhere deep in a compiler, which reads as an outage.
|
||||
Lang string `json:"lang" validate:"required"`
|
||||
// Code is the WHOLE program, not a fragment: it is written to a single file and
|
||||
// that file is what runs, so a compiled language needs its entry point and an
|
||||
// interpreted one runs top to bottom.
|
||||
Code string `json:"code" validate:"required"`
|
||||
// Args become the PROGRAM's argv, never the compiler's. For the compiled
|
||||
// languages the toolchain builds first and these are passed to the binary it
|
||||
// produced.
|
||||
Args []string `json:"args,omitempty"`
|
||||
// Files are inputs the host already put in some session. Each names the session
|
||||
// its bytes live in, which is usually — and ideally — the session this run wants.
|
||||
Files []CodeFile `json:"files,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
Files []CodeFile `json:"files,omitempty"`
|
||||
// SessionID continues an EXISTING sandbox, which is what makes runs stateful:
|
||||
// the same filesystem, so one run's output file is the next run's input. Empty
|
||||
// leases a fresh sandbox and the id it got comes back on the result.
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
// UserID attributes the run inside the caller's org. It is a label, never a
|
||||
// tenant: the org is resolved from the validated principal and a value here
|
||||
// cannot widen what the run may reach.
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
// RuntimeSessionHint is the stateful-session hint. It is carried so a client
|
||||
// that sends it is not silently misread, and it selects nothing here: every
|
||||
// session in this implementation is already a warm sandbox, so there is no
|
||||
@@ -146,10 +174,18 @@ type CodeRun struct {
|
||||
// skipped by the "not available" note as well — so the CSV was invisible and nothing
|
||||
// said so. Answering with `storage_session_id` keeps the reply on the agents shape.
|
||||
type CodeFile struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
// ID is the file's path RELATIVE to its session's artifact directory, which is
|
||||
// also how it is fetched: GET /v1/download/{session}/{id}.
|
||||
ID string `json:"id"`
|
||||
// Name is the display name. On an ANSWER it carries the `{session}/{id}`
|
||||
// identifier whole, because the client matches on that prefix.
|
||||
Name string `json:"name"`
|
||||
// StorageSessionID names the session holding the bytes, and is the spelling the
|
||||
// answer always uses.
|
||||
StorageSessionID string `json:"storage_session_id,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
// SessionID is the other accepted spelling of the same fact on the way IN. Both
|
||||
// are read; whichever is set wins.
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
}
|
||||
|
||||
// Session is the session a file's bytes live in, whichever name the caller used.
|
||||
@@ -160,10 +196,19 @@ func (f CodeFile) Session() string { return firstNonEmpty(f.StorageSessionID, f.
|
||||
// "the code threw" and "the interpreter is down" are different facts and the caller
|
||||
// renders them differently.
|
||||
type CodeResult struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr"`
|
||||
Files []CodeFile `json:"files,omitempty"`
|
||||
// SessionID is the sandbox this run used — the one that was passed in, or the
|
||||
// fresh one that was leased. Pass it to the next run to keep the filesystem.
|
||||
SessionID string `json:"session_id"`
|
||||
// Stdout is what the program wrote to standard output.
|
||||
Stdout string `json:"stdout"`
|
||||
// Stderr is what the program wrote to standard error, INCLUDING a compiler's
|
||||
// diagnostics and the trace of a program that exited non-zero. Its presence is
|
||||
// not a failed call.
|
||||
Stderr string `json:"stderr"`
|
||||
// Files are what this run CREATED OR CHANGED, decided by mtime against a marker
|
||||
// taken before the program started — so it is the run's output, not a listing of
|
||||
// the directory. Fetch each from GET /v1/download/{session}/{id}.
|
||||
Files []CodeFile `json:"files,omitempty"`
|
||||
}
|
||||
|
||||
// listing is one row of GET /v1/files/{sid}. The client matches on `name` having
|
||||
@@ -189,13 +234,35 @@ type uploadedFile struct {
|
||||
|
||||
// ---- the composition -------------------------------------------------------
|
||||
|
||||
// run is the typed op: resolve the tenant, then the interpreter.
|
||||
// run executes a program in a throwaway sandbox and answers with what it printed
|
||||
// and what it left behind.
|
||||
//
|
||||
// The resolution is what makes THIS door safe. A typed op is also an MCP tool and
|
||||
// an op-plane op; MCP's tools/call invokes it directly, with no route and therefore
|
||||
// no middleware, so nothing there could have checked a credential. tenantOf refuses
|
||||
// a context that carries neither a validated principal nor exec's own admission
|
||||
// marker, so those doors fail closed without a second gate to keep in step.
|
||||
// `lang` names one of the thirteen the sandbox image carries — py, js, ts, bash, r,
|
||||
// php, go, rs, c, cpp, java, d, f90 — and `code` is the whole program, not a
|
||||
// fragment: a compiled language is compiled and then run, an interpreted one is
|
||||
// interpreted, and `args` becomes the program's own argv either way. Nothing is
|
||||
// installed for you; the image is the environment.
|
||||
//
|
||||
// A PROGRAM THAT FAILS IS A SUCCESSFUL CALL. A non-zero exit answers 200 with the
|
||||
// diagnostics on `stderr`, because "the code threw" and "the interpreter is down"
|
||||
// are different facts a caller renders differently. Only the second is an error
|
||||
// status.
|
||||
//
|
||||
// Runs are stateful through `session_id`. Omit it and the run gets a fresh sandbox
|
||||
// whose id comes back on the answer; pass that id again and the next run sees the
|
||||
// same filesystem, so a program can write a file one call and read it the next.
|
||||
// `files` names bytes already uploaded to a session (POST /v1/upload), copied in
|
||||
// before the program starts. `files` on the ANSWER is what the program created or
|
||||
// changed, by comparison against a marker taken at start — so it is the run's real
|
||||
// output, not a listing of the directory — and each is fetched from
|
||||
// GET /v1/download/{session}/{name}.
|
||||
//
|
||||
// The tenant is the caller's, never the body's, at every door. A typed op is also
|
||||
// an MCP tool and an op-plane op; MCP's tools/call invokes it directly, with no
|
||||
// route and therefore no middleware, so nothing there could have checked a
|
||||
// credential. tenantOf refuses a context carrying neither a validated principal nor
|
||||
// exec's own admission marker, so those doors fail closed without a second gate to
|
||||
// keep in step.
|
||||
func run(ctx context.Context, in *CodeRun) (*CodeResult, error) {
|
||||
org, err := tenantOf(ctx)
|
||||
if err != nil {
|
||||
@@ -662,7 +729,21 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
|
||||
return c.Continue()
|
||||
}))
|
||||
|
||||
zip.Post[CodeRun, CodeResult](app, Path, run,
|
||||
// Registered on the *zip.App rather than on the cloud.Router, and that is what
|
||||
// makes the prose reach the document. zipdoc resolves a typed op's path
|
||||
// STATICALLY, and a `cloud.Router` parameter is an interface it cannot follow to
|
||||
// a prefix — so it refuses to lift, the op publishes a summary and no
|
||||
// description, and openapi.Complete accepts that because either one satisfies
|
||||
// it. The subsystem scope adds no prefix here (every path below is absolute), so
|
||||
// this is the same registration, spelled where the generator can read it.
|
||||
//
|
||||
// It is only the ROUTES that move: the credential middleware above stays on the
|
||||
// scoped router, where the prefix guard applies to it.
|
||||
reg := cloud.ZipApp(app)
|
||||
if reg == nil {
|
||||
return fmt.Errorf("exec.Mount: router carries no typed-op registry")
|
||||
}
|
||||
zip.Post[CodeRun, CodeResult](reg, Path, run,
|
||||
zip.WithSummary("Run a code snippet in a sandboxed interpreter"))
|
||||
app.Post(Path+"/programmatic", programmatic)
|
||||
app.Post("/v1/upload", upload)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user