mirror of
https://github.com/Heretek-AI/The-Agency.git
synced 2026-11-07 10:06:01 -10:00
feat(stage-1d): gateway, dispatcher service, director persona, render updates
This commit is contained in:
1 parent
f71f89cc5a
commit
f01279777f
16 files changed
+580
-35
No files matched your search
@@ -3,6 +3,8 @@ docs/prompts/v1/
|
||||
docs/audit/
|
||||
docs/evidence/
|
||||
docker-compose.yml
|
||||
docker-compose.ports.yml
|
||||
gateway/Caddyfile
|
||||
experts/
|
||||
workspace/
|
||||
state/
|
||||
|
||||
@@ -75,7 +75,7 @@ engine-live:
|
||||
bash scripts/accept/1c/engine-live.inc both
|
||||
|
||||
e2e:
|
||||
@echo "not built until stage 4c: e2e" >&2; exit 1
|
||||
bash scripts/e2e.sh
|
||||
|
||||
gc:
|
||||
@echo "not built until stage 3b: gc" >&2; exit 1
|
||||
bash scripts/gc.sh
|
||||
@@ -1,7 +1,8 @@
|
||||
# The Agency dispatcher image (Stage 1c). FROM the pinned base image, plus
|
||||
# Bun sources for driveway engine runs. Runs as `agent`, attaches to the
|
||||
# `fleet` network. Mounts `/work`, `/artifacts`, the registry, and `/data`
|
||||
# at run time. It has no server entrypoint yet: Stage 1d adds one.
|
||||
# The Agency dispatcher image (Stage 1d). FROM the pinned base image, plus
|
||||
# Bun sources for the service. Runs as `agent`, attaches to the `fleet`
|
||||
# network. Mounts `/work`, `/artifacts`, and `/data` (state, registry,
|
||||
# audit) at run time. The server is dispatcher/src/main.ts: REST, SSE,
|
||||
# and the fleet MCP endpoint on port 8080.
|
||||
ARG OPENCODE_VERSION=2.0.26
|
||||
ARG AGENT_UID=1000
|
||||
ARG AGENT_GID=1000
|
||||
@@ -28,4 +29,4 @@ USER agent
|
||||
WORKDIR /app
|
||||
# The base image's OpenCode entrypoint does not apply here.
|
||||
ENTRYPOINT ["tini", "--"]
|
||||
CMD ["bun", "--version"]
|
||||
CMD ["bun", "dispatcher/src/main.ts"]
|
||||
@@ -0,0 +1,30 @@
|
||||
# Fallback port publishing for expert debug access (Stage 1d, Step 4).
|
||||
# Not applied by default: `make up` publishes only the gateway.
|
||||
# Apply explicitly when direct OpenCode UI access is needed:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.ports.yml up -d
|
||||
# Every mapping stays on the loopback interface.
|
||||
services:
|
||||
director:
|
||||
ports: ["127.0.0.1:4111:4096"]
|
||||
scout:
|
||||
ports: ["127.0.0.1:4112:4096"]
|
||||
architect:
|
||||
ports: ["127.0.0.1:4113:4096"]
|
||||
product:
|
||||
ports: ["127.0.0.1:4114:4096"]
|
||||
frontend:
|
||||
ports: ["127.0.0.1:4115:4096"]
|
||||
backend:
|
||||
ports: ["127.0.0.1:4116:4096"]
|
||||
dba:
|
||||
ports: ["127.0.0.1:4117:4096"]
|
||||
platform:
|
||||
ports: ["127.0.0.1:4118:4096"]
|
||||
security:
|
||||
ports: ["127.0.0.1:4119:4096"]
|
||||
qa:
|
||||
ports: ["127.0.0.1:4120:4096"]
|
||||
reviewer:
|
||||
ports: ["127.0.0.1:4121:4096"]
|
||||
docs:
|
||||
ports: ["127.0.0.1:4122:4096"]
|
||||
@@ -745,6 +745,117 @@ services:
|
||||
volumes:
|
||||
- ./egress/allowlist.txt:/etc/squid/allowlist.txt:ro,z
|
||||
- ./egress/squid.conf:/etc/squid/squid.conf:ro,z
|
||||
dispatcher:
|
||||
container_name: agency-dispatcher
|
||||
build:
|
||||
context: .
|
||||
dockerfile: dispatcher/Dockerfile
|
||||
args:
|
||||
OPENCODE_VERSION: ${OPENCODE_VERSION}
|
||||
AGENT_UID: ${AGENT_UID}
|
||||
AGENT_GID: ${AGENT_GID}
|
||||
image: agency-dispatcher:1.0.0
|
||||
restart: unless-stopped
|
||||
working_dir: /app
|
||||
networks:
|
||||
- fleet
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
pids_limit: 512
|
||||
cpus: 2
|
||||
mem_limit: 4g
|
||||
depends_on:
|
||||
director:
|
||||
condition: service_healthy
|
||||
scout:
|
||||
condition: service_healthy
|
||||
architect:
|
||||
condition: service_healthy
|
||||
product:
|
||||
condition: service_healthy
|
||||
frontend:
|
||||
condition: service_healthy
|
||||
backend:
|
||||
condition: service_healthy
|
||||
dba:
|
||||
condition: service_healthy
|
||||
platform:
|
||||
condition: service_healthy
|
||||
security:
|
||||
condition: service_healthy
|
||||
qa:
|
||||
condition: service_healthy
|
||||
reviewer:
|
||||
condition: service_healthy
|
||||
docs:
|
||||
condition: service_healthy
|
||||
verifier:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- curl -fsS --noproxy '*' http://127.0.0.1:8080/healthz >/dev/null
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
environment:
|
||||
AGENCY_MODEL: ${AGENCY_MODEL}
|
||||
FLEET_MCP_TOKEN: ${FLEET_MCP_TOKEN}
|
||||
VERIFIER_TOKEN: ${VERIFIER_TOKEN}
|
||||
DISPATCHER_STATE_DIR: /data
|
||||
DISPATCHER_REGISTRY_DIR: /data/registry
|
||||
DISPATCHER_WORKSPACE: /work
|
||||
DISPATCHER_ARTIFACTS: /artifacts
|
||||
DISPATCHER_ALLOWLIST: /data/registry/allowlist.yaml
|
||||
PW_DIRECTOR: ${PW_DIRECTOR}
|
||||
PW_SCOUT: ${PW_SCOUT}
|
||||
PW_ARCHITECT: ${PW_ARCHITECT}
|
||||
PW_PRODUCT: ${PW_PRODUCT}
|
||||
PW_FRONTEND: ${PW_FRONTEND}
|
||||
PW_BACKEND: ${PW_BACKEND}
|
||||
PW_DBA: ${PW_DBA}
|
||||
PW_PLATFORM: ${PW_PLATFORM}
|
||||
PW_SECURITY: ${PW_SECURITY}
|
||||
PW_QA: ${PW_QA}
|
||||
PW_REVIEWER: ${PW_REVIEWER}
|
||||
PW_DOCS: ${PW_DOCS}
|
||||
volumes:
|
||||
- ${AGENCY_WORKSPACE:-./workspace}:/work:rw,z
|
||||
- artifacts:/artifacts
|
||||
- ./state:/data:rw,z
|
||||
- dispatcher-cache:/home/agent/.cache
|
||||
gateway:
|
||||
container_name: agency-gateway
|
||||
image: caddy@sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- fleet
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
pids_limit: 256
|
||||
cpus: 1
|
||||
mem_limit: 512m
|
||||
depends_on:
|
||||
dispatcher:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- curl -fsS --noproxy '*' http://127.0.0.1:8080/healthz >/dev/null
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
ports:
|
||||
- 127.0.0.1:${AGENCY_HTTP_PORT}:8080
|
||||
volumes:
|
||||
- ./gateway/Caddyfile:/etc/caddy/Caddyfile:ro,z
|
||||
- gateway-data:/data
|
||||
networks:
|
||||
fleet:
|
||||
internal: true
|
||||
@@ -789,3 +900,5 @@ volumes:
|
||||
docs-state: {}
|
||||
docs-cache: {}
|
||||
verifier-cache: {}
|
||||
dispatcher-cache: {}
|
||||
gateway-data: {}
|
||||
@@ -3,28 +3,91 @@ description: Mission orchestrator. Picks a playbook, writes the plan and task br
|
||||
mode: primary
|
||||
---
|
||||
|
||||
You are the Director, the mission orchestrator of the expert fleet. You own
|
||||
decomposition, briefs, sequencing, rework decisions, and the final mission
|
||||
report. You never edit files and you cannot skip a playbook gate; landing is
|
||||
done by the Dispatcher, not by you.
|
||||
# Director
|
||||
|
||||
What you hand off, and to whom: everything except planning and judgement.
|
||||
Reconnaissance goes to scout, design to architect, requirements to product,
|
||||
implementation to frontend or backend, data work to dba, delivery
|
||||
definitions to platform, test authoring to qa, review to reviewer, and
|
||||
documentation to docs. Name the expert and state the request; never do
|
||||
their job yourself.
|
||||
## Identity
|
||||
|
||||
How you work: read the mission objective and the repository's control
|
||||
files first, pick the playbook that fits, then slice the work by owner and
|
||||
write path so each task has one expert and a small set of files. Write each
|
||||
brief with an objective, context, constraints, and numbered acceptance
|
||||
criteria. When results return, check every criterion against the evidence
|
||||
quoted in the report before you accept; if evidence is missing, send the
|
||||
task back with one precise question rather than guessing.
|
||||
You lead missions. You do not write code: the workspace is mounted
|
||||
read-only for you, and your shell is limited to `git log`, `git status`,
|
||||
`git diff`, and `git show`. Your tools are reading the repository and
|
||||
the `fleet_*` tools on the Dispatcher's MCP server. Landing, merging,
|
||||
verification, and scheduling belong to the Dispatcher; you cannot skip a
|
||||
playbook gate, and you never push, merge, or switch branches. Your
|
||||
reports go under `/artifacts/<taskId>/`. What you hand off, and to whom:
|
||||
everything except planning and judgement. Reconnaissance goes to scout,
|
||||
design to architect, requirements to product, implementation to frontend
|
||||
or backend, data work to dba, delivery definitions to platform, test
|
||||
authoring to qa, review to reviewer, and documentation to docs. Name the
|
||||
expert and state the request; never do their job yourself.
|
||||
|
||||
You are read-only: the workspace is mounted read-only for you, your shell
|
||||
is limited to `git log`, `git status`, `git diff`, and `git show`, and your
|
||||
reports go under `/artifacts/<taskId>/`. You end your final message with
|
||||
the `RESULT` block from your fleet protocol, with the plan or the accept
|
||||
and rework decisions recorded in it.
|
||||
## Start
|
||||
|
||||
Call `fleet_roster` to see who is available, healthy, and loaded, then
|
||||
`fleet_mission_create` with the mission objective, which returns the
|
||||
mission id and the repository's ownership map summary. Read enough of
|
||||
the repository to plan (recent history, the areas the objective
|
||||
touches). If the objective is ambiguous, ask the person one concise
|
||||
question and wait; otherwise proceed without further questions.
|
||||
|
||||
## Planning
|
||||
|
||||
Each task has one owner, paths inside that owner's grant, and checkable
|
||||
acceptance criteria. Slice by owner and write path so no two tasks claim
|
||||
the same files; order by dependency (interfaces before implementations,
|
||||
builders before the test author). Tell the person the plan in a few
|
||||
lines — what each expert will do and what it waits on — then call
|
||||
`fleet_plan_submit`. When the mission parks as `waiting-human`, stop and
|
||||
wait for the human's approval; never resubmit a smaller plan to dodge
|
||||
the threshold.
|
||||
|
||||
## Briefs
|
||||
|
||||
A brief stands alone; the expert sees nothing of this conversation. Each
|
||||
brief carries an objective (what changes and why), the context it needs
|
||||
(file pointers, the decision being implemented, prior results it builds
|
||||
on), constraints (granted paths, what is out of scope, artifacts under
|
||||
`/artifacts/<taskId>/`), and acceptance criteria as a numbered list,
|
||||
each checkable by running something (`bun test tests/x.test.ts`, not
|
||||
"tests pass"). If AGENTS.md exists at the worktree root, the brief tells
|
||||
the expert to read it first.
|
||||
|
||||
## Waiting
|
||||
|
||||
After submitting, stop. You will be woken by a `[fleet]` message when a
|
||||
task reaches a terminal state or the mission needs a decision. On
|
||||
waking, call `fleet_mission_get` and act on what changed: read results,
|
||||
decide, or replan. If you wake without a session to return to — after a
|
||||
compaction or restart — recover with `fleet_mission_get` first, then
|
||||
continue. When there is nothing to decide yet, call `fleet_wait` and
|
||||
stop again; do not poll, do not chat, do not invent progress.
|
||||
|
||||
## Deciding
|
||||
|
||||
Read `fleet_task_result` first and `fleet_task_diff` only when the
|
||||
report does not settle the question. Match every acceptance criterion
|
||||
against quoted evidence (commands, exit codes, observed output) before
|
||||
you accept. Do not accept a task with verification failures, blocked
|
||||
status, or scope failures. Decide with `fleet_decide`: `accept` for
|
||||
verified work inside scope, `rework` with one precise question rather
|
||||
than a guess, `abandon` when the slice was wrong. Rework at most twice,
|
||||
then report to the person instead of trying a third time.
|
||||
|
||||
## Handoffs
|
||||
|
||||
Turn each handoff line in a result (`{"expert": ..., "request": ...}`)
|
||||
into a new task in the plan: same brief discipline, the named expert as
|
||||
owner, paths inside that expert's grant, and a dependency on the task
|
||||
that asked for it. Never do the handoff work yourself and never hand
|
||||
work to an expert outside its grant.
|
||||
|
||||
## Report
|
||||
|
||||
When tasks have landed, call `fleet_mission_close` with the summary and
|
||||
report to the person what was done, where it is (the integration branch
|
||||
and merge SHAs), what was verified (the checks that ran green on the
|
||||
integration branch), and what remains open (assumptions, risks,
|
||||
follow-ups). The Dispatcher refuses the close unless every code change
|
||||
carries a landed SHA and the last integration verify is green; write
|
||||
only from landed evidence. End your final message with the `RESULT`
|
||||
block from your fleet protocol, with the plan and the accept and rework
|
||||
decisions recorded in it.
|
||||
@@ -24,3 +24,10 @@ knowledge produces hallway results.
|
||||
|
||||
The expert can start without asking anything; each criterion names its
|
||||
check (`bun test tests/x.test.ts`, not "tests pass").
|
||||
|
||||
## Fleet tools
|
||||
|
||||
A brief is the `objective`, `context`, `constraints`, `acceptance`, and
|
||||
`paths` of one `fleet_plan_submit` entry. Read the repository first
|
||||
(`fleet_roster` for who does what, `git log` and `git diff` for the
|
||||
ground truth); the expert sees only the brief, never this conversation.
|
||||
@@ -22,3 +22,11 @@ end.
|
||||
|
||||
A human reading only the report knows what shipped, what did not, and
|
||||
what to watch.
|
||||
|
||||
## Fleet tools
|
||||
|
||||
Recover with `fleet_mission_get` (objective, plan, every task status and
|
||||
reason, decisions) after any compaction or restart. Close with
|
||||
`fleet_mission_close`, which the Dispatcher refuses unless every code
|
||||
change carries a landed SHA and the last integration verify is green;
|
||||
write the summary only from landed evidence.
|
||||
@@ -23,3 +23,11 @@ objective changes shape.
|
||||
|
||||
One playbook named, its gates listed in the plan, and no gate skipped or
|
||||
weakened to fit the schedule.
|
||||
|
||||
## Fleet tools
|
||||
|
||||
Start with `fleet_roster` (who is available and healthy) and
|
||||
`fleet_mission_create` (which returns the repository's ownership summary).
|
||||
The plan you submit must keep every task's paths inside its owner's
|
||||
grant; `fleet_plan_submit` refuses anything outside, and that refusal is
|
||||
a planning error, not a negotiation.
|
||||
@@ -24,3 +24,11 @@ evidence you have not checked.
|
||||
|
||||
Each decision cites the evidence, the attempt number, and what changes
|
||||
next; nothing is accepted on trust.
|
||||
|
||||
## Fleet tools
|
||||
|
||||
Read `fleet_task_result` first and `fleet_task_diff` only when the report
|
||||
does not settle the question. Decide with `fleet_decide`: `accept` only
|
||||
for verified work inside scope, `rework` with one precise question (at
|
||||
most twice), `abandon` when the slice was wrong. Turn each handoff line
|
||||
into a new task rather than doing it yourself.
|
||||
@@ -25,3 +25,11 @@ single-file fixes that one expert can own outright.
|
||||
|
||||
Every task names its expert, its files, and what it waits on; no two tasks
|
||||
claim the same files; the plan fits the playbook's task ceiling.
|
||||
|
||||
## Fleet tools
|
||||
|
||||
Submit the sliced plan with `fleet_plan_submit`: each entry carries `key`,
|
||||
`expert`, `title`, `objective`, `context`, `constraints`, `acceptance[]`,
|
||||
`paths[]`, and `deps[]`. When the mission parks as `waiting-human`, tell
|
||||
the person the plan in a few lines and wait; never resubmit to dodge
|
||||
approval.
|
||||
@@ -0,0 +1,83 @@
|
||||
# GENERATED, do not edit. Rendered by scripts/render.ts from fleet.yaml.
|
||||
{
|
||||
admin off
|
||||
http_port 8080
|
||||
}
|
||||
|
||||
http://agency.localhost {
|
||||
reverse_proxy dispatcher:8080 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
http://director.agency.localhost {
|
||||
reverse_proxy director:4096 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
http://scout.agency.localhost {
|
||||
reverse_proxy scout:4096 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
http://architect.agency.localhost {
|
||||
reverse_proxy architect:4096 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
http://product.agency.localhost {
|
||||
reverse_proxy product:4096 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
http://frontend.agency.localhost {
|
||||
reverse_proxy frontend:4096 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
http://backend.agency.localhost {
|
||||
reverse_proxy backend:4096 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
http://dba.agency.localhost {
|
||||
reverse_proxy dba:4096 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
http://platform.agency.localhost {
|
||||
reverse_proxy platform:4096 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
http://security.agency.localhost {
|
||||
reverse_proxy security:4096 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
http://qa.agency.localhost {
|
||||
reverse_proxy qa:4096 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
http://reviewer.agency.localhost {
|
||||
reverse_proxy reviewer:4096 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
http://docs.agency.localhost {
|
||||
reverse_proxy docs:4096 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
+59
-2
@@ -1,14 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# init-env for The Agency (Stage 1b, Step 7).
|
||||
# init-env for The Agency (Stage 1b, Step 7; allowlist in Stage 1d).
|
||||
# From the salvaged studio init: creates .env (host uid/gid, gateway port,
|
||||
# a random value for every PW_<NAME>, PROXY_PW_<NAME>, and token) and a
|
||||
# placeholder .env.provider, both mode 0600, never overwriting an existing
|
||||
# file. Never prints a secret: values are generated silently.
|
||||
# Stage 1d addition: seeds state/registry/allowlist.yaml (admin, approver,
|
||||
# viewer with argon2-hashed passwords) and the operator copy
|
||||
# secrets/allowlist.env with the plaintext passwords, both mode 0600 and
|
||||
# never overwritten.
|
||||
set -u
|
||||
|
||||
ROOT="$(git rev-parse --show-toplevel)"
|
||||
ENVFILE="$ROOT/.env"
|
||||
PROVIDERFILE="$ROOT/.env.provider"
|
||||
ALLOWLIST="$ROOT/state/registry/allowlist.yaml"
|
||||
OPERATOR="$ROOT/secrets/allowlist.env"
|
||||
|
||||
if [ -f "$ENVFILE" ]; then
|
||||
echo "init-env: $ENVFILE exists; refusing to overwrite (delete it to re-init)" >&2
|
||||
@@ -18,11 +24,23 @@ if [ -f "$PROVIDERFILE" ]; then
|
||||
echo "init-env: $PROVIDERFILE exists; refusing to overwrite (delete it to re-init)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -f "$ALLOWLIST" ]; then
|
||||
echo "init-env: $ALLOWLIST exists; refusing to overwrite (delete it to re-init)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -f "$OPERATOR" ]; then
|
||||
echo "init-env: $OPERATOR exists; refusing to overwrite (delete it to re-init)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
command -v python3 >/dev/null 2>&1 || {
|
||||
echo "init-env: missing required command python3" >&2
|
||||
exit 1
|
||||
}
|
||||
command -v bun >/dev/null 2>&1 || {
|
||||
echo "init-env: missing required command bun (needed to hash allowlist passwords)" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
gen_secret() {
|
||||
python3 -c "import secrets; print(secrets.token_urlsafe(24))" || {
|
||||
@@ -67,4 +85,43 @@ chmod 600 "$ENVFILE"
|
||||
} >"$PROVIDERFILE"
|
||||
chmod 600 "$PROVIDERFILE"
|
||||
|
||||
echo "init-env: wrote $ENVFILE and $PROVIDERFILE (0600). Set AGENCY_MODEL in .env and OPENCODE_API_KEY in .env.provider."
|
||||
ADMIN_PW="$(gen_secret)" || exit 1
|
||||
APPROVER_PW="$(gen_secret)" || exit 1
|
||||
VIEWER_PW="$(gen_secret)" || exit 1
|
||||
ADMIN_HASH="$(bun -e 'console.log(await Bun.password.hash(process.argv[2], { algorithm: "argon2id" }))' "$ADMIN_PW")" || {
|
||||
echo "init-env: hashing the admin password failed" >&2
|
||||
exit 1
|
||||
}
|
||||
APPROVER_HASH="$(bun -e 'console.log(await Bun.password.hash(process.argv[2], { algorithm: "argon2id" }))' "$APPROVER_PW")" || {
|
||||
echo "init-env: hashing the approver password failed" >&2
|
||||
exit 1
|
||||
}
|
||||
VIEWER_HASH="$(bun -e 'console.log(await Bun.password.hash(process.argv[2], { algorithm: "argon2id" }))' "$VIEWER_PW")" || {
|
||||
echo "init-env: hashing the viewer password failed" >&2
|
||||
exit 1
|
||||
}
|
||||
mkdir -p "$ROOT/state/registry" "$ROOT/secrets"
|
||||
{
|
||||
echo "# Generated by scripts/init-env.sh. Do not commit."
|
||||
echo "version: 1"
|
||||
echo "users:"
|
||||
echo " - username: admin"
|
||||
echo " role: admin"
|
||||
echo " passwordHash: '$ADMIN_HASH'"
|
||||
echo " - username: approver"
|
||||
echo " role: approver"
|
||||
echo " passwordHash: '$APPROVER_HASH'"
|
||||
echo " - username: viewer"
|
||||
echo " role: viewer"
|
||||
echo " passwordHash: '$VIEWER_HASH'"
|
||||
} >"$ALLOWLIST"
|
||||
chmod 600 "$ALLOWLIST"
|
||||
{
|
||||
echo "# Generated by scripts/init-env.sh. Operator copy of dashboard passwords; never commit."
|
||||
echo "AGENCY_ADMIN_PASSWORD=$ADMIN_PW"
|
||||
echo "AGENCY_APPROVER_PASSWORD=$APPROVER_PW"
|
||||
echo "AGENCY_VIEWER_PASSWORD=$VIEWER_PW"
|
||||
} >"$OPERATOR"
|
||||
chmod 600 "$OPERATOR"
|
||||
|
||||
echo "init-env: wrote $ENVFILE, $PROVIDERFILE, $ALLOWLIST, and $OPERATOR (0600). Set AGENCY_MODEL in .env and OPENCODE_API_KEY in .env.provider."
|
||||
+29
-1
@@ -84,6 +84,7 @@ async function fixtureRoot(
|
||||
allowlistBase: options.allowlistBase ?? "# base\nprobe.example.com\n",
|
||||
squidTemplate: "port @@PORT@@ list @@ALLOWLIST_PATH@@\n",
|
||||
expertsDir,
|
||||
caddyDigest: "sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
};
|
||||
return { root, inputs };
|
||||
}
|
||||
@@ -105,7 +106,34 @@ describe("render", () => {
|
||||
const doc = parseYaml(compose.content.replace(/^#.*\n/, "")) as {
|
||||
services: Record<string, unknown>;
|
||||
};
|
||||
expect(Object.keys(doc.services).sort()).toEqual(["egress", "reader", "verifier", "writer"]);
|
||||
expect(Object.keys(doc.services).sort()).toEqual([
|
||||
"dispatcher",
|
||||
"egress",
|
||||
"gateway",
|
||||
"reader",
|
||||
"verifier",
|
||||
"writer",
|
||||
]);
|
||||
});
|
||||
|
||||
test("the gateway routes the dispatcher and every expert without buffering", async () => {
|
||||
const { root, inputs } = await fixtureRoot();
|
||||
const files = await renderAll(root, inputs);
|
||||
const caddy = files.find((f) => f.path.endsWith("gateway/Caddyfile"))!;
|
||||
expect(caddy.content).toContain("http://agency.localhost");
|
||||
expect(caddy.content).toContain("reverse_proxy dispatcher:8080");
|
||||
expect(caddy.content).toContain("http://writer.agency.localhost");
|
||||
expect(caddy.content).toContain("reverse_proxy writer:4096");
|
||||
expect(caddy.content).toContain("flush_interval -1");
|
||||
expect(caddy.content).not.toContain("sleeper.agency.localhost");
|
||||
const compose = files.find((f) => f.path.endsWith("docker-compose.yml"))!;
|
||||
const doc = parseYaml(compose.content.replace(/^#.*\n/, "")) as {
|
||||
services: Record<string, { image?: string; ports?: string[] }>;
|
||||
};
|
||||
expect(doc.services["gateway"]?.image).toContain("caddy@sha256:");
|
||||
expect(doc.services["gateway"]?.ports).toEqual(["127.0.0.1:${AGENCY_HTTP_PORT}:8080"]);
|
||||
expect(doc.services["dispatcher"]?.ports).toBeUndefined();
|
||||
expect(doc.services["writer"]?.ports).toBeUndefined();
|
||||
});
|
||||
|
||||
test("refuses an expert naming an MCP key missing from the registry", async () => {
|
||||
|
||||
+128
-3
@@ -144,6 +144,7 @@ export interface RenderInputs {
|
||||
allowlistBase: string;
|
||||
squidTemplate: string;
|
||||
expertsDir: string;
|
||||
caddyDigest: string;
|
||||
}
|
||||
|
||||
export async function loadInputs(root: string): Promise<RenderInputs> {
|
||||
@@ -156,7 +157,20 @@ export async function loadInputs(root: string): Promise<RenderInputs> {
|
||||
const protocol = await Bun.file(`${root}/shared/FLEET_PROTOCOL.md`).text();
|
||||
const allowlistBase = await Bun.file(`${root}/shared/templates/allowlist-base.txt`).text();
|
||||
const squidTemplate = await Bun.file(`${root}/shared/templates/squid.conf.tmpl`).text();
|
||||
return { fleet, registry, protocol, allowlistBase, squidTemplate, expertsDir: `${root}/experts` };
|
||||
const versions = await Bun.file(`${root}/versions.env`).text();
|
||||
const digest = /^CADDY_DIGEST=(.+)$/m.exec(versions)?.[1]?.trim();
|
||||
if (digest === undefined || !/^sha256:[0-9a-f]{64}$/.test(digest)) {
|
||||
throw new RenderError("refusing to render: versions.env holds no CADDY_DIGEST pin");
|
||||
}
|
||||
return {
|
||||
fleet,
|
||||
registry,
|
||||
protocol,
|
||||
allowlistBase,
|
||||
squidTemplate,
|
||||
expertsDir: `${root}/experts`,
|
||||
caddyDigest: digest,
|
||||
};
|
||||
}
|
||||
|
||||
function parseAllowlistBase(text: string): string[] {
|
||||
@@ -286,7 +300,11 @@ export async function renderAll(root: string, inputs: RenderInputs): Promise<Gen
|
||||
});
|
||||
}
|
||||
|
||||
files.push({ path: `${root}/docker-compose.yml`, content: renderCompose(fleet) });
|
||||
files.push({ path: `${root}/docker-compose.yml`, content: renderCompose(fleet, inputs) });
|
||||
files.push({
|
||||
path: `${root}/gateway/Caddyfile`,
|
||||
content: renderCaddyfile(fleet),
|
||||
});
|
||||
files.push({
|
||||
path: `${root}/egress/allowlist.txt`,
|
||||
content:
|
||||
@@ -302,7 +320,8 @@ export async function renderAll(root: string, inputs: RenderInputs): Promise<Gen
|
||||
return files;
|
||||
}
|
||||
|
||||
function renderCompose(fleet: FleetConfig): string {
|
||||
function renderCompose(fleet: FleetConfig, inputs: RenderInputs): string {
|
||||
const { caddyDigest } = inputs;
|
||||
const enabled = enabledExperts(fleet);
|
||||
const upper = (name: string): string => name.toUpperCase().replace(/-/g, "_");
|
||||
const services: Record<string, unknown> = {};
|
||||
@@ -450,6 +469,79 @@ function renderCompose(fleet: FleetConfig): string {
|
||||
],
|
||||
};
|
||||
|
||||
const dispatcherEnv: Record<string, string> = {
|
||||
AGENCY_MODEL: "${AGENCY_MODEL}",
|
||||
FLEET_MCP_TOKEN: "${FLEET_MCP_TOKEN}",
|
||||
VERIFIER_TOKEN: "${VERIFIER_TOKEN}",
|
||||
DISPATCHER_STATE_DIR: "/data",
|
||||
DISPATCHER_REGISTRY_DIR: "/data/registry",
|
||||
DISPATCHER_WORKSPACE: "/work",
|
||||
DISPATCHER_ARTIFACTS: "/artifacts",
|
||||
DISPATCHER_ALLOWLIST: "/data/registry/allowlist.yaml",
|
||||
};
|
||||
for (const name of enabled) dispatcherEnv[`PW_${upper(name)}`] = `\${PW_${upper(name)}}`;
|
||||
const healthyDeps: Record<string, { condition: string }> = {};
|
||||
for (const name of [...enabled, "verifier"]) healthyDeps[name] = { condition: "service_healthy" };
|
||||
services["dispatcher"] = {
|
||||
container_name: "agency-dispatcher",
|
||||
build: {
|
||||
context: ".",
|
||||
dockerfile: "dispatcher/Dockerfile",
|
||||
args: {
|
||||
OPENCODE_VERSION: "${OPENCODE_VERSION}",
|
||||
AGENT_UID: "${AGENT_UID}",
|
||||
AGENT_GID: "${AGENT_GID}",
|
||||
},
|
||||
},
|
||||
image: "agency-dispatcher:1.0.0",
|
||||
restart: "unless-stopped",
|
||||
working_dir: "/app",
|
||||
networks: ["fleet"],
|
||||
cap_drop: ["ALL"],
|
||||
security_opt: ["no-new-privileges:true"],
|
||||
pids_limit: 512,
|
||||
cpus: 2,
|
||||
mem_limit: "4g",
|
||||
depends_on: healthyDeps,
|
||||
healthcheck: {
|
||||
test: ["CMD-SHELL", "curl -fsS --noproxy '*' http://127.0.0.1:8080/healthz >/dev/null"],
|
||||
interval: "15s",
|
||||
timeout: "10s",
|
||||
retries: 5,
|
||||
start_period: "30s",
|
||||
},
|
||||
environment: dispatcherEnv,
|
||||
volumes: [
|
||||
"${AGENCY_WORKSPACE:-./workspace}:/work:rw,z",
|
||||
"artifacts:/artifacts",
|
||||
"./state:/data:rw,z",
|
||||
"dispatcher-cache:/home/agent/.cache",
|
||||
],
|
||||
};
|
||||
volumes["dispatcher-cache"] = {};
|
||||
services["gateway"] = {
|
||||
container_name: "agency-gateway",
|
||||
image: `caddy@${caddyDigest}`,
|
||||
restart: "unless-stopped",
|
||||
networks: ["fleet"],
|
||||
cap_drop: ["ALL"],
|
||||
security_opt: ["no-new-privileges:true"],
|
||||
pids_limit: 256,
|
||||
cpus: 1,
|
||||
mem_limit: "512m",
|
||||
depends_on: { dispatcher: { condition: "service_healthy" } },
|
||||
healthcheck: {
|
||||
test: ["CMD-SHELL", "curl -fsS --noproxy '*' http://127.0.0.1:8080/healthz >/dev/null"],
|
||||
interval: "15s",
|
||||
timeout: "5s",
|
||||
retries: 10,
|
||||
start_period: "20s",
|
||||
},
|
||||
ports: ["127.0.0.1:${AGENCY_HTTP_PORT}:8080"],
|
||||
volumes: ["./gateway/Caddyfile:/etc/caddy/Caddyfile:ro,z", "gateway-data:/data"],
|
||||
};
|
||||
volumes["gateway-data"] = {};
|
||||
|
||||
const doc = {
|
||||
services,
|
||||
networks: { fleet: { internal: true }, outside: { driver: "bridge" } },
|
||||
@@ -461,6 +553,39 @@ function renderCompose(fleet: FleetConfig): string {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway routing (Stage 1d, Step 4): agency.localhost reaches the
|
||||
* Dispatcher; each <name>.agency.localhost reaches that expert's
|
||||
* OpenCode server as the debug fallback. Buffering is off
|
||||
* (flush_interval -1) so SSE streams pass through unbuffered.
|
||||
*/
|
||||
function renderCaddyfile(fleet: FleetConfig): string {
|
||||
const lines = [
|
||||
"# GENERATED, do not edit. Rendered by scripts/render.ts from fleet.yaml.",
|
||||
"{",
|
||||
"\tadmin off",
|
||||
"\thttp_port 8080",
|
||||
"}",
|
||||
"",
|
||||
"http://agency.localhost {",
|
||||
"\treverse_proxy dispatcher:8080 {",
|
||||
"\t\tflush_interval -1",
|
||||
"\t}",
|
||||
"}",
|
||||
];
|
||||
for (const name of enabledExperts(fleet)) {
|
||||
lines.push(
|
||||
"",
|
||||
`http://${name}.agency.localhost {`,
|
||||
`\treverse_proxy ${name}:4096 {`,
|
||||
"\t\tflush_interval -1",
|
||||
"\t}",
|
||||
"}",
|
||||
);
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = Bun.argv.slice(2);
|
||||
const check = args.includes("--check");
|
||||
|
||||
@@ -108,3 +108,7 @@ APT_LIBXML2=2.9.14+dfsg-1.3ubuntu3.10
|
||||
MCP_PYPI_VERSION=1.30.0
|
||||
# Stage 1c: BUN_LINUX_X64_SHA256 above is the bun-linux-x64.zip release
|
||||
# asset; the verifier and dispatcher images check it at build time.
|
||||
# Stage 1d gateway pin (verified with `docker pull caddy:2.10-alpine` on
|
||||
# 2026-10-14; the digest below is its RepoDigest).
|
||||
CADDY_VERSION=2.10-alpine
|
||||
CADDY_DIGEST=sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d
|
||||
Reference in new issue
Block a user