feat(5): overleveringspakka er KUN kjørbar Python — container-innpakningen fjernet
Operatørdirektiv 14.08 etter ekstern test: mottakeren skal få kjørbar Python, ikke en Docker-innpakking. `Dockerfile` og `azure.yaml` er SLETTET fra treet. Sømmen er valgt av den eksisterende invarianten, ikke av smak: pakka ER `git archive HEAD`, så å ekskludere filene fra arkivet ville krevd en kurerings-mekanisme — den andre kopien av «hva mottakeren får», fri til å drifte fra HEAD (kø-(p)). Fjerning holder arkivet ukurert og gjør fraværet til en egenskap ved HEAD, som er det eneste en gate kan måle. De to gatene som pinnet flaten er håndtert bevisst: - 4e-rå-tekst-gaten (`--platform linux/amd64` + ÉN kopi av startkommandoen) er SLETTET, med et notat der den sto. En gate som pinner en fjernet flate kan bare bli grønn. - handover-gatens `_REQUIRED_MEMBERS` er ikke bare fratatt de to navnene, men erstattet av en POSITIV fraværs-assert pluss en dokument-gate. Å kun slutte å KREVE dem ville gitt en gate som ikke kan skille «fjernet» fra «shippes fortsatt». Startkommandoen har nå ÉN kopi igjen: DEPLOY.md-ens `python main.py`, som navngir inngangen subprosess-testen faktisk kjører.
This commit is contained in:
parent
642ce8ae9a
commit
605c02aa62
10 changed files with 215 additions and 205 deletions
54
CLAUDE.md
54
CLAUDE.md
|
|
@ -191,15 +191,12 @@ Python ≥3.10. MAF (`agent-framework-core` 1.9.0). Pakkehåndtering: `uv`. To b
|
||||||
subklasser den) → 400, alt annet → 500 `{error_type, error}` (speiler `RunFailure`), og en
|
subklasser den) → 400, alt annet → 500 `{error_type, error}` (speiler `RunFailure`), og en
|
||||||
`Rejection` er en VELLYKKET kjøring → 200 — det negative utfallet tilhører payloaden, aldri
|
`Rejection` er en VELLYKKET kjøring → 200 — det negative utfallet tilhører payloaden, aldri
|
||||||
transporten. `outbox.outcome_payload` er den ENE kopien av validated/rejected-forgreningen
|
transporten. `outbox.outcome_payload` er den ENE kopien av validated/rejected-forgreningen
|
||||||
(delt av fil-skriveren og HTTP-responsen — to kopier drifter, kø-(p)-regelen). `azure.yaml`
|
(delt av fil-skriveren og HTTP-responsen — to kopier drifter, kø-(p)-regelen). **Container-innpakningen
|
||||||
validert GRØNN mot begge autoritative skjemaer; ingen `env:` (redeklarer aldri
|
(`Dockerfile`/`azure.yaml`) ER FJERNET 14.08** — se python-only-invarianten under; resten av
|
||||||
`FOUNDRY_PROJECT_ENDPOINT`), ingen `startupCommand` (imagets `CMD` er den ene kopien av
|
denne raden står, for `main.py` startes nå direkte (`python main.py`). Load-bearing MÅLT (`tests/test_hosting_loadbearing.py`), seks mutasjoner alle
|
||||||
startkommandoen). `git archive <tree> | docker build --platform linux/amd64 -` grønn på
|
|
||||||
indeks-treet. Load-bearing MÅLT (`tests/test_hosting_loadbearing.py`), seks mutasjoner alle
|
|
||||||
røde mot hele suiten på riktig test: detach felt-mappingen · dropp ukjente felt stille · flipp
|
røde mot hele suiten på riktig test: detach felt-mappingen · dropp ukjente felt stille · flipp
|
||||||
400/500 · detach azure-defaulten · detach SIGTERM-handleren · detach main.py-shimen (de to
|
400/500 · detach azure-defaulten · detach SIGTERM-handleren · detach main.py-shimen (de to
|
||||||
siste fanges KUN av subprosess-testen — P4-presedensen). Deploy er IKKE utført (azd-steget er
|
siste fanges KUN av subprosess-testen — P4-presedensen). Chunked request-bodies støttes ikke, og under CPU-bundne strekk (CBC-solven)
|
||||||
operatørens); chunked request-bodies støttes ikke, og under CPU-bundne strekk (CBC-solven)
|
|
||||||
står readiness — uttalt, ikke skjult.
|
står readiness — uttalt, ikke skjult.
|
||||||
- **Whitelisten må komponere med den EKTE `run_project`, og artefaktene gates som RÅ TEKST
|
- **Whitelisten må komponere med den EKTE `run_project`, og artefaktene gates som RÅ TEKST
|
||||||
(Fase 4e):** alle 4d-testene ga `invoke` en stand-in som sluker `**kwargs`, så whitelisten kunne
|
(Fase 4e):** alle 4d-testene ga `invoke` en stand-in som sluker `**kwargs`, så whitelisten kunne
|
||||||
|
|
@ -213,16 +210,12 @@ Python ≥3.10. MAF (`agent-framework-core` 1.9.0). Pakkehåndtering: `uv`. To b
|
||||||
et Foundry-deployment-navn i modell-mappet FØR noen klient bygges (`run.py` stempler provenance
|
et Foundry-deployment-navn i modell-mappet FØR noen klient bygges (`run.py` stempler provenance
|
||||||
med det), så den kan ikke fullføre offline — **containeren trenger altså `PORTFOLIO_MODEL_MAP`
|
med det), så den kan ikke fullføre offline — **containeren trenger altså `PORTFOLIO_MODEL_MAP`
|
||||||
eller et utfylt `data/model_map.json`, ikke bare et endepunkt** (målt her, ikke antatt).
|
eller et utfylt `data/model_map.json`, ikke bare et endepunkt** (målt her, ikke antatt).
|
||||||
`Dockerfile`/`azure.yaml` KJØRES av ingen test (`docker build`/`azd deploy` er operatør-gatet), så
|
**Artefakt-halvdelen av denne raden er PENSJONERT 14.08** sammen med `Dockerfile`/`azure.yaml`
|
||||||
rå-tekst er eneste tilgjengelige gate: `--platform linux/amd64` (målt påkrevd, spike §1.4 — uten
|
(rå-tekst-gaten pinnet `--platform linux/amd64` + ÉN kopi av startkommandoen; to av radens fem
|
||||||
det arver imaget byggerens arkitektur og bygger grønt lokalt mens det ikke kan starte i skyen) +
|
mutasjoner traff nettopp den). Whitelist-halvdelen står URØRT. Load-bearing MÅLT
|
||||||
ÉN kopi av startkommandoen (imagets `CMD` navngir `main.py`, `azure.yaml` har ingen
|
(`tests/test_hosting_loadbearing.py`), de tre gjenværende mutasjonene alle røde på riktig test og
|
||||||
`startupCommand`). **Nøkkel-sjekkene er LINJEFORANKRET, ikke delstreng:** `azure.yaml`s egen
|
på INGEN annen: send `project_id` to ganger · whitelist et felt `run_project` ikke tar · fjern
|
||||||
kommentar NAVNGIR `startupCommand` og `env` for å begrunne fraværet, så en substring-gate ville
|
`bundle_dir` fra whitelisten.
|
||||||
vært rød på prosaen den beskytter. Load-bearing MÅLT (`tests/test_hosting_loadbearing.py`), fem
|
|
||||||
mutasjoner alle røde på riktig test og på INGEN annen (836 øvrige grønne hver gang): send
|
|
||||||
`project_id` to ganger · whitelist et felt `run_project` ikke tar · fjern `bundle_dir` fra
|
|
||||||
whitelisten · fjern `--platform linux/amd64` · gi `azure.yaml` en `startupCommand`-nøkkel.
|
|
||||||
- **Påstander flaten gjør om SEG SELV gates som rå tekst, linjeforankret (Fase 3, A5):** to påstander
|
- **Påstander flaten gjør om SEG SELV gates som rå tekst, linjeforankret (Fase 3, A5):** to påstander
|
||||||
bodde i prosa der ingen test kunne se dem, og begge drev. (1) `env.template` sa at credential
|
bodde i prosa der ingen test kunne se dem, og begge drev. (1) `env.template` sa at credential
|
||||||
resolves via `DefaultAzureCredential` — den har ALDRI gjort det; gaten leser de klassene
|
resolves via `DefaultAzureCredential` — den har ALDRI gjort det; gaten leser de klassene
|
||||||
|
|
@ -597,9 +590,8 @@ Python ≥3.10. MAF (`agent-framework-core` 1.9.0). Pakkehåndtering: `uv`. To b
|
||||||
`scripts/make-handover-package.sh` bygger én zip en ekstern organisasjon deployer uten å klone
|
`scripts/make-handover-package.sh` bygger én zip en ekstern organisasjon deployer uten å klone
|
||||||
repoet. **Tracked files only er hele eksponerings-kontrollen** — `STATE.md`, `*.local.md` og
|
repoet. **Tracked files only er hele eksponerings-kontrollen** — `STATE.md`, `*.local.md` og
|
||||||
`.env` er gitignorert, så de KAN ikke komme inn; et filter vedlikeholdt i skriptet ville vært den
|
`.env` er gitignorert, så de KAN ikke komme inn; et filter vedlikeholdt i skriptet ville vært den
|
||||||
andre kopien av den regelen, og den andre kopien er den som drifter (kø-(p)). Det er dessuten
|
andre kopien av den regelen, og den andre kopien er den som drifter (kø-(p)). Mottakeren får altså
|
||||||
SAMME tre som den målte docker-build-konteksten (Dockerfile-headeren), så mottakeren deployer det
|
HEAD selv. Versjonen LESES fra `pyproject.toml` — et hardkodet tall her ville råtnet ved neste bump
|
||||||
vi målte. Versjonen LESES fra `pyproject.toml` — et hardkodet tall her ville råtnet ved neste bump
|
|
||||||
nøyaktig som README-ens wheel-filnavn gjorde (Fase 3). `DEPLOY.md` ligger i treet og blir dermed
|
nøyaktig som README-ens wheel-filnavn gjorde (Fase 3). `DEPLOY.md` ligger i treet og blir dermed
|
||||||
med i arkivet av seg selv; den bærer mottakerens tre første spørsmål — hvem gjør hva
|
med i arkivet av seg selv; den bærer mottakerens tre første spørsmål — hvem gjør hva
|
||||||
(plattform-operatør / bestiller / fagperson), prosessen ende-til-ende, og **hvorfor det ikke
|
(plattform-operatør / bestiller / fagperson), prosessen ende-til-ende, og **hvorfor det ikke
|
||||||
|
|
@ -607,11 +599,31 @@ Python ≥3.10. MAF (`agent-framework-core` 1.9.0). Pakkehåndtering: `uv`. To b
|
||||||
fordi validator, baseline-forankring, checker-gate og ledger ligger UTENFOR grafen — et chat-lag
|
fordi validator, baseline-forankring, checker-gate og ledger ligger UTENFOR grafen — et chat-lag
|
||||||
ville rutet forespørsler rundt nøyaktig det som gjør svaret etterprøvbart). Den navngir også det
|
ville rutet forespørsler rundt nøyaktig det som gjør svaret etterprøvbart). Den navngir også det
|
||||||
4e målte deploy-kravet som ingen rad hadde skrevet ned: pakket `model_map.json` bærer
|
4e målte deploy-kravet som ingen rad hadde skrevet ned: pakket `model_map.json` bærer
|
||||||
`REPLACE-WITH-*`, så uten `PORTFOLIO_MODEL_MAP` starter containeren, svarer på `/readiness` og
|
`REPLACE-WITH-*`, så uten `PORTFOLIO_MODEL_MAP` starter tjenesten, svarer på `/readiness` og
|
||||||
feiler HVER invocation. Gaten er `tests/test_handover_package_loadbearing.py`, og
|
feiler HVER invocation. Gaten er `tests/test_handover_package_loadbearing.py`, og
|
||||||
DEPLOY.md-asserten er LINJEFORANKRET: `PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT` INNEHOLDER
|
DEPLOY.md-asserten er LINJEFORANKRET: `PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT` INNEHOLDER
|
||||||
`FOUNDRY_PROJECT_ENDPOINT`, så en delstreng-assert på det injiserte navnet ville vært oppfylt av
|
`FOUNDRY_PROJECT_ENDPOINT`, så en delstreng-assert på det injiserte navnet ville vært oppfylt av
|
||||||
vårt eget (repoets 08-09-klasse, femte gang).
|
vårt eget (repoets 08-09-klasse, femte gang).
|
||||||
|
- **Overleveringen er KUN kjørbar Python, og fraværet er FJERNING — ikke filtrering (14.08,
|
||||||
|
operatørdirektiv etter ekstern test):** `Dockerfile` og `azure.yaml` er slettet fra TREET.
|
||||||
|
**Sømmen er valgt av den eksisterende invarianten, ikke av smak:** pakka ER `git archive HEAD`, så
|
||||||
|
å ekskludere filene fra arkivet ville krevd en kurerings-mekanisme (skript-filter eller
|
||||||
|
`export-ignore`) — den andre kopien av «hva mottakeren får», fri til å drifte fra HEAD, altså
|
||||||
|
nøyaktig kø-(p)-regelen raden over finnes for. Å beholde dem som «opt-in» ville ikke oppfylt
|
||||||
|
direktivet i det hele tatt. Fjerning holder arkivet ukurert OG gjør fraværet til en egenskap ved
|
||||||
|
HEAD, som er det eneste en gate kan måle. **De to gatene som pinnet flaten er håndtert BEVISST,
|
||||||
|
aldri stille svekket:** 4e-rå-tekst-gaten (`--platform linux/amd64` + ÉN kopi av startkommandoen)
|
||||||
|
er SLETTET med et notat der den sto — en gate som pinner en fjernet flate kan bare bli grønn — og
|
||||||
|
handover-gatens `_REQUIRED_MEMBERS` er ikke bare fratatt de to navnene, men erstattet av en
|
||||||
|
POSITIV fraværs-assert; å kun slutte å KREVE dem ville gitt en gate som ikke kan skille «fjernet»
|
||||||
|
fra «shippes fortsatt». Matchingen skjer på arkiv-MEDLEMSNAVN, ikke på prosa (dokumentene må
|
||||||
|
kunne forklare at ingen image shippes — repoets 08-09-klasse, sjette gang), og dokument-gaten
|
||||||
|
forbyr kommando-FRAGMENTER (`docker build`, `azd deploy`), ikke ordet. **Startkommandoen har nå
|
||||||
|
ÉN kopi igjen — DEPLOY.md-ens `python main.py`** — og den navngir inngangen subprosess-testen
|
||||||
|
faktisk kjører. **Ærlighets-grense, uttalt:** azd/hosted-agent-stien finnes ikke lenger i pakka;
|
||||||
|
hvordan prosessen driftes er mottakerens valg. `git archive HEAD` leser HEAD, ikke arbeidstreet,
|
||||||
|
så gaten er ekte men forsinket med én commit (funn 35). Load-bearing MÅLT
|
||||||
|
(`tests/test_handover_package_loadbearing.py`).
|
||||||
- **STATE.md er local-only** (gitignored). Voyage session-state er efemert; STATE.md er kanonisk kontinuitet.
|
- **STATE.md er local-only** (gitignored). Voyage session-state er efemert; STATE.md er kanonisk kontinuitet.
|
||||||
- Prosess: Voyage-plugin (`/trekbrief → /trekplan → /trekexecute → /trekreview`) per større fase.
|
- Prosess: Voyage-plugin (`/trekbrief → /trekplan → /trekexecute → /trekreview`) per større fase.
|
||||||
|
|
||||||
|
|
|
||||||
40
DEPLOY.md
40
DEPLOY.md
|
|
@ -41,7 +41,7 @@ The package has three distinct audiences. Only the first one is technical.
|
||||||
|
|
||||||
| Role | What they do | Where it is documented |
|
| Role | What they do | Where it is documented |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Platform operator** | Deploys the container into Microsoft Foundry, sets two environment variables, verifies with the offline preflight. One-time work. | This document, sections 3–5. |
|
| **Platform operator** | Installs the package, sets two environment variables, starts the service, verifies with the offline preflight. One-time work. | This document, sections 3–6. |
|
||||||
| **Ordering domain expert** (*bestiller*) | Writes the mandate file: the objective, which approaches are worth trying, and why. The *why* is the part the system cannot read out of cost figures. | `docs/bestille-en-kjoring.md` |
|
| **Ordering domain expert** (*bestiller*) | Writes the mandate file: the objective, which approaches are worth trying, and why. The *why* is the part the system cannot read out of cost figures. | `docs/bestille-en-kjoring.md` |
|
||||||
| **Reviewing domain expert** (*fagperson*) | Receives a validated proposal and answers `approved` or `rejected` with a written rationale. That verdict is what the system learns from. | `docs/ekspert-svar.md` |
|
| **Reviewing domain expert** (*fagperson*) | Receives a validated proposal and answers `approved` or `rejected` with a written rationale. That verdict is what the system learns from. | `docs/ekspert-svar.md` |
|
||||||
|
|
||||||
|
|
@ -59,7 +59,7 @@ back with its reason. That property is the point of the whole design; do not bui
|
||||||
your own.
|
your own.
|
||||||
2. **Order a run** with a mandate file (optional but recommended). See
|
2. **Order a run** with a mandate file (optional but recommended). See
|
||||||
`docs/bestille-en-kjoring.md`.
|
`docs/bestille-en-kjoring.md`.
|
||||||
3. **Run it** — from the CLI, or by `POST /invocations` against the deployed container (section 5).
|
3. **Run it** — from the CLI, or by `POST /invocations` against the running service (section 6).
|
||||||
The run navigates the knowledge base, debates candidate measures, validates the numbers against
|
The run navigates the knowledge base, debates candidate measures, validates the numbers against
|
||||||
the project's actual cost baseline, and returns either a **validated proposal** or a
|
the project's actual cost baseline, and returns either a **validated proposal** or a
|
||||||
**rejection with a reason**. A rejection is a successful run: the negative outcome belongs to the
|
**rejection with a reason**. A rejection is a successful run: the negative outcome belongs to the
|
||||||
|
|
@ -99,8 +99,8 @@ PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT # yours; wins if set
|
||||||
FOUNDRY_PROJECT_ENDPOINT # injected by the Foundry hosting platform
|
FOUNDRY_PROJECT_ENDPOINT # injected by the Foundry hosting platform
|
||||||
```
|
```
|
||||||
|
|
||||||
Inside a hosted Foundry container the platform injects the second one and you need to set nothing.
|
Where something injects the second one for you — the Foundry hosting platform does — you need to
|
||||||
Outside it — a laptop, a pipeline, a container you run yourself — set the first. The value looks
|
set nothing. Everywhere else (a laptop, a pipeline, your own runtime) set the first. The value looks
|
||||||
like `https://<resource>.services.ai.azure.com/api/projects/<project>`.
|
like `https://<resource>.services.ai.azure.com/api/projects/<project>`.
|
||||||
|
|
||||||
**5.2 The model map — this is the one that catches people.** The packaged
|
**5.2 The model map — this is the one that catches people.** The packaged
|
||||||
|
|
@ -125,7 +125,7 @@ own file:
|
||||||
export PORTFOLIO_MODEL_MAP=/path/to/model_map.json
|
export PORTFOLIO_MODEL_MAP=/path/to/model_map.json
|
||||||
```
|
```
|
||||||
|
|
||||||
If you skip this, the container starts and answers `/readiness` — and fails every invocation. That
|
If you skip this, the service starts and answers `/readiness` — and fails every invocation. That
|
||||||
is the single most likely first-deployment failure, which is why it has its own section.
|
is the single most likely first-deployment failure, which is why it has its own section.
|
||||||
|
|
||||||
**5.3 Verify before you pay for a model call:**
|
**5.3 Verify before you pay for a model call:**
|
||||||
|
|
@ -140,24 +140,32 @@ no placeholders left. It is **necessary but not sufficient** — RBAC (403), tok
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Deploying
|
## 6. Running it
|
||||||
|
|
||||||
The image must be built for x86_64 regardless of your workstation's architecture:
|
**This package is Python and nothing else.** There is no image to build and no platform-specific
|
||||||
|
deployment manifest — that was removed deliberately after an external trial, because it forced a
|
||||||
|
container toolchain on receivers who only needed to run the code. How the process is supervised,
|
||||||
|
packaged or placed in your environment is your decision, and this package does not pre-empt it.
|
||||||
|
|
||||||
|
Install the locked environment and start the service:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git archive HEAD | docker build --platform linux/amd64 -t portfolio-optimiser -
|
uv sync --frozen --no-dev
|
||||||
|
uv run python main.py
|
||||||
```
|
```
|
||||||
|
|
||||||
If you unzipped this package rather than cloning it, build from the directory instead:
|
`uv sync --frozen` installs the exact resolution every measurement in this repository ran against —
|
||||||
|
never a fresh resolve, which picks other versions. Two dependencies are git-tag-pinned direct
|
||||||
|
references, so **`git` must be on PATH** for the install to work.
|
||||||
|
|
||||||
|
`main.py` serves port 8088 (override with `PORT`), answers `GET /readiness`, accepts
|
||||||
|
`POST /invocations`, and exits cleanly on SIGTERM. For a single run without the service, the CLI is
|
||||||
|
the same code path:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build --platform linux/amd64 -t portfolio-optimiser .
|
uv run portfolio-optimiser --help
|
||||||
```
|
```
|
||||||
|
|
||||||
`azure.yaml` describes the hosted-agent deployment (`azd`). The container serves port 8088 (override
|
|
||||||
with `PORT`), answers `GET /readiness`, accepts `POST /invocations`, and shuts down cleanly on
|
|
||||||
SIGTERM.
|
|
||||||
|
|
||||||
**The invocations payload** is whitelisted against the run signature — unknown fields are refused by
|
**The invocations payload** is whitelisted against the run signature — unknown fields are refused by
|
||||||
name with HTTP 400, never silently ignored:
|
name with HTTP 400, never silently ignored:
|
||||||
|
|
||||||
|
|
@ -167,7 +175,7 @@ name with HTTP 400, never silently ignored:
|
||||||
| `docs_dir` | yes | Source documents. |
|
| `docs_dir` | yes | Source documents. |
|
||||||
| `verdict_input` | yes | The expert verdict for this run. |
|
| `verdict_input` | yes | The expert verdict for this run. |
|
||||||
| `bundle_dir` | no | Knowledge base to navigate. |
|
| `bundle_dir` | no | Knowledge base to navigate. |
|
||||||
| `profile` | no | Defaults to `azure` in the hosted container. |
|
| `profile` | no | Defaults to `azure` on this HTTP surface. |
|
||||||
| `max_rounds` | no | Debate round cap. |
|
| `max_rounds` | no | Debate round cap. |
|
||||||
| `max_tokens` | no | **Per-invocation token ceiling.** Set it. The built-in default is 100 000 tokens per run, which is a ceiling nobody chose for your workload. |
|
| `max_tokens` | no | **Per-invocation token ceiling.** Set it. The built-in default is 100 000 tokens per run, which is a ceiling nobody chose for your workload. |
|
||||||
| `top_k` | no | Retrieval breadth. |
|
| `top_k` | no | Retrieval breadth. |
|
||||||
|
|
@ -180,7 +188,7 @@ HTTP 400 means the request was malformed; 500 means the run itself failed, with
|
||||||
## 7. Honest limits
|
## 7. Honest limits
|
||||||
|
|
||||||
- **This framework has never been run against a live model in our environment.** Everything above is
|
- **This framework has never been run against a live model in our environment.** Everything above is
|
||||||
measured against tests, an offline simulation and container builds. The first live run in *your*
|
measured against tests and an offline simulation. The first live run in *your*
|
||||||
tenant is the first live run, full stop.
|
tenant is the first live run, full stop.
|
||||||
- The system is a **technical framework**. Data protection assessments, lawful basis and risk
|
- The system is a **technical framework**. Data protection assessments, lawful basis and risk
|
||||||
ownership belong to whoever deploys it — see `README.md` and `SECURITY.md`.
|
ownership belong to whoever deploys it — see `README.md` and `SECURITY.md`.
|
||||||
|
|
|
||||||
35
Dockerfile
35
Dockerfile
|
|
@ -1,35 +0,0 @@
|
||||||
# Hosted-agent container (Fase 4d). Build context is `git archive HEAD` — what a stranger
|
|
||||||
# actually downloads, never the working tree — and the hosting platform requires x86_64
|
|
||||||
# (spike §1.4), so the measured build command is:
|
|
||||||
#
|
|
||||||
# git archive HEAD | docker build --platform linux/amd64 -t portfolio-optimiser -
|
|
||||||
#
|
|
||||||
# Base: the version pinned by .python-version (3.12), mid-band of the resolution matrix
|
|
||||||
# (3.10–3.13 have numpy wheels, 3.14 does not — spike §4), and the same base Microsoft's
|
|
||||||
# own MAF sample uses. Runs as root like that sample: no non-root requirement is
|
|
||||||
# documented (spike, Ikke verifisert pkt. 7).
|
|
||||||
FROM python:3.12-slim-bookworm
|
|
||||||
|
|
||||||
# git: the two security components are git-tag-pinned direct references, and
|
|
||||||
# [tool.uv.sources] never reaches wheel metadata (spike §3/§7.2) — uv needs the git CLI
|
|
||||||
# to fetch them, and -slim ships without it.
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends git \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Pinned to the uv the tree was measured with (13.08) — an unpinned installer would let
|
|
||||||
# the container's resolver drift from the working tree's.
|
|
||||||
RUN pip install --no-cache-dir uv==0.9.8
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# The LOCKED, dev-free environment — the same resolution the working tree was measured
|
|
||||||
# with (agent-framework-core 1.9.0). Never a fresh resolve from pyproject.toml: that
|
|
||||||
# picks other versions (1.13.0) than every measurement in this repo ran against (§3).
|
|
||||||
RUN uv sync --frozen --no-dev
|
|
||||||
|
|
||||||
# The hosted-agent runtime contract: port 8088, GET /readiness, POST /invocations,
|
|
||||||
# SIGTERM shutdown (spike §1.1) — served by main.py (portfolio_optimiser.hosting).
|
|
||||||
EXPOSE 8088
|
|
||||||
CMD ["/app/.venv/bin/python", "main.py"]
|
|
||||||
39
README.md
39
README.md
|
|
@ -459,13 +459,9 @@ only an expert has:
|
||||||
- **Vendor-neutral knowledge** — the same bundles drive two different agent stacks; switching
|
- **Vendor-neutral knowledge** — the same bundles drive two different agent stacks; switching
|
||||||
frameworks does not orphan the organization's curated knowledge.
|
frameworks does not orphan the organization's curated knowledge.
|
||||||
|
|
||||||
## Hosted on Foundry Agent Service
|
## The task API — runnable Python, no wrapper
|
||||||
|
|
||||||
The repo ships the three files a hosted-agent deployment needs. All three are measured —
|
`main.py` is the ONE process entry. It serves the hosted-agent runtime contract (port 8088 /
|
||||||
schema-validated and container-built — but an actual `azd` deploy has **not** been run; that
|
|
||||||
step is the operator's.
|
|
||||||
|
|
||||||
- `main.py` — the ONE process entry. It serves the hosted-agent runtime contract (port 8088 /
|
|
||||||
`PORT`, `GET /readiness`, `POST /invocations`, clean SIGTERM exit) from
|
`PORT`, `GET /readiness`, `POST /invocations`, clean SIGTERM exit) from
|
||||||
`portfolio_optimiser.hosting`, wrapping `run_project` — never `Workflow.as_agent()`, which
|
`portfolio_optimiser.hosting`, wrapping `run_project` — never `Workflow.as_agent()`, which
|
||||||
would bypass the deterministic validator and the rest of the gate chain. No protocol library
|
would bypass the deterministic validator and the rest of the gate chain. No protocol library
|
||||||
|
|
@ -473,21 +469,28 @@ step is the operator's.
|
||||||
`agent-framework-core>=1.13.0` while this tree locks 1.9.0, so the contract is a small
|
`agent-framework-core>=1.13.0` while this tree locks 1.9.0, so the contract is a small
|
||||||
stdlib-asyncio server instead (single event loop, no threads — the same concurrency model as
|
stdlib-asyncio server instead (single event loop, no threads — the same concurrency model as
|
||||||
the portfolio waves).
|
the portfolio waves).
|
||||||
- `Dockerfile` — `python:3.12-slim-bookworm` + git (the two security components are
|
|
||||||
git-tag-pinned direct references; wheel metadata alone cannot install them) +
|
```bash
|
||||||
`uv sync --frozen --no-dev` (the exact locked resolution every measurement ran against).
|
uv sync --frozen --no-dev # the exact locked resolution every measurement ran against
|
||||||
Build it the way a stranger would:
|
uv run python main.py # serves the task API
|
||||||
`git archive HEAD | docker build --platform linux/amd64 -t portfolio-optimiser -`
|
```
|
||||||
- `azure.yaml` — closed against the two authoritative JSON schemas (see
|
|
||||||
`docs/2026-08-13-fase4-azure-yaml-valg.md`): `language: docker`, invocations protocol pinned
|
`git` must be on PATH for the install: two dependencies are git-tag-pinned direct references,
|
||||||
to `2.0.0`, no `env:` block (the platform injects `FOUNDRY_PROJECT_ENDPOINT`; model choice is
|
and wheel metadata alone cannot fetch them.
|
||||||
config, not environment), no `startupCommand` (the image `CMD` is the one copy).
|
|
||||||
|
A `Dockerfile` and an `azure.yaml` shipped here until **14 August 2026** and were removed on an
|
||||||
|
operator directive after an external trial: what is delivered is runnable Python, and how the
|
||||||
|
process is containerised, supervised or deployed belongs to whoever runs it. Git history keeps
|
||||||
|
both files. The raw-text gate that pinned them (`--platform linux/amd64`, one copy of the start
|
||||||
|
command) was **deleted with them** rather than weakened into a check that could only pass — the
|
||||||
|
start command now has exactly one copy, in [`DEPLOY.md`](DEPLOY.md), and
|
||||||
|
`tests/test_handover_package_loadbearing.py` is what keeps it there.
|
||||||
|
|
||||||
An invocation is a JSON object whitelisted onto `run_project`'s signature — `project_id`,
|
An invocation is a JSON object whitelisted onto `run_project`'s signature — `project_id`,
|
||||||
`docs_dir` and `verdict_input` required; `bundle_dir`, `profile`, `max_rounds`, `max_tokens`
|
`docs_dir` and `verdict_input` required; `bundle_dir`, `profile`, `max_rounds`, `max_tokens`
|
||||||
and `top_k` optional. Unknown fields are refused by name (400), never silently dropped.
|
and `top_k` optional. Unknown fields are refused by name (400), never silently dropped.
|
||||||
`profile` defaults to `azure` on this surface: the AZURE profile reads its endpoint and
|
`profile` defaults to `azure` on this surface: the AZURE profile reads its endpoint and
|
||||||
credential from the environment at call time, so the same image runs hosted (managed identity)
|
credential from the environment at call time, so the same process runs hosted (managed identity)
|
||||||
and locally (`az login`) without rewiring.
|
and locally (`az login`) without rewiring.
|
||||||
|
|
||||||
### Handing it to someone else
|
### Handing it to someone else
|
||||||
|
|
@ -499,8 +502,8 @@ cloning this repository or having an account here:
|
||||||
scripts/make-handover-package.sh # → dist/portfolio-optimiser-foundry-<version>.zip
|
scripts/make-handover-package.sh # → dist/portfolio-optimiser-foundry-<version>.zip
|
||||||
```
|
```
|
||||||
|
|
||||||
The archive is `git archive HEAD` — tracked files only, which is the same tree the measured docker
|
The archive is `git archive HEAD` — tracked files only, which is why local-only files cannot enter
|
||||||
build context uses, and the reason local-only files cannot enter it. [`DEPLOY.md`](DEPLOY.md) rides
|
it and why nothing curates what a receiver sees. [`DEPLOY.md`](DEPLOY.md) rides
|
||||||
along inside it and answers the receiver's first questions: what the three roles do, what the
|
along inside it and answers the receiver's first questions: what the three roles do, what the
|
||||||
process is end to end, why there is no chat interface, and the two environment variables that decide
|
process is end to end, why there is no chat interface, and the two environment variables that decide
|
||||||
whether the first deployment works. Gated by `tests/test_handover_package_loadbearing.py`.
|
whether the first deployment works. Gated by `tests/test_handover_package_loadbearing.py`.
|
||||||
|
|
|
||||||
28
azure.yaml
28
azure.yaml
|
|
@ -1,28 +0,0 @@
|
||||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json
|
|
||||||
#
|
|
||||||
# Fase 4d. The four open choices were closed against the two authoritative JSON schemas
|
|
||||||
# (docs/2026-08-13-fase4-azure-yaml-valg.md), not against Learn prose:
|
|
||||||
#
|
|
||||||
# * `language: docker`, NO `codeConfiguration` — codeConfiguration is ZIP-source mode,
|
|
||||||
# and the git-pinned dependencies need our own Dockerfile (git in the base image).
|
|
||||||
# * `protocols` pinned to invocations 2.0.0, stated outright: the header contract in
|
|
||||||
# spike §1.5 (x-agent-user-id / x-agent-foundry-call-id, gateway allowlist) is a
|
|
||||||
# 2.0.0 property, and 1.0.0 is deprecated with a blocking deadline.
|
|
||||||
# * `project` is REQUIRED for host azure.ai.agent (the $ref'd azure.ai.agent.json);
|
|
||||||
# `config:` is deprecated there and not used.
|
|
||||||
# * NO `env:` block, deliberately: FOUNDRY_PROJECT_ENDPOINT must never be redeclared
|
|
||||||
# (it would risk shadowing the platform's injected value — spike pkt. 2), and model
|
|
||||||
# selection is config (the packaged data/model_map.json, override via
|
|
||||||
# PORTFOLIO_MODEL_MAP), not environment variables.
|
|
||||||
# * NO `startupCommand`, deliberately: the image's CMD (Dockerfile) is the ONE copy of
|
|
||||||
# the start command — a second copy here could drift from it.
|
|
||||||
name: portfolio-optimiser
|
|
||||||
services:
|
|
||||||
portfolio-optimiser:
|
|
||||||
host: azure.ai.agent
|
|
||||||
kind: hosted
|
|
||||||
project: .
|
|
||||||
language: docker
|
|
||||||
protocols:
|
|
||||||
- protocol: invocations
|
|
||||||
version: "2.0.0"
|
|
||||||
9
main.py
9
main.py
|
|
@ -1,9 +1,10 @@
|
||||||
"""Hosted-agent process entry (Fase 4d): the ONE file the container starts.
|
"""Hosted-agent process entry (Fase 4d): the ONE file that starts the service.
|
||||||
|
|
||||||
Thin by design — the runtime contract lives in ``portfolio_optimiser.hosting`` (packaged
|
Thin by design — the runtime contract lives in ``portfolio_optimiser.hosting`` (packaged
|
||||||
and tested); this file is the scaffold-conventional ``main.py`` that the Dockerfile ``CMD``
|
and tested); this file is the scaffold-conventional ``main.py`` that ``python main.py``
|
||||||
and ``azure.yaml`` point at. It is part of the git archive (the build context), not of the
|
starts, which is the single start command DEPLOY.md prints (14.08: the delivery is runnable
|
||||||
wheel: a wheel consumer starts the same server with
|
Python, and no image ``CMD`` exists to be a second copy of it). It is part of the git archive,
|
||||||
|
not of the wheel: a wheel consumer starts the same server with
|
||||||
``python -c "from portfolio_optimiser.hosting import main; main()"``.
|
``python -c "from portfolio_optimiser.hosting import main; main()"``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,11 @@
|
||||||
# cannot enter the archive. A filter maintained here would be a second copy of that rule, and the
|
# cannot enter the archive. A filter maintained here would be a second copy of that rule, and the
|
||||||
# second copy is the one that drifts (kø-(p)).
|
# second copy is the one that drifts (kø-(p)).
|
||||||
#
|
#
|
||||||
# It is also the SAME tree the measured docker build context uses (see the Dockerfile header:
|
# What the receiver gets is therefore HEAD itself -- never a hand-curated selection. That also
|
||||||
# `git archive HEAD | docker build --platform linux/amd64 -`), so what the receiver deploys is what
|
# decided how the 14.08 directive ("runnable Python, no container wrapper") was carried out: the
|
||||||
# we measured -- never a hand-curated selection.
|
# Dockerfile and azure.yaml were removed from the TREE, not filtered out here. A filter would have
|
||||||
|
# been a curation step deciding what a receiver sees, i.e. exactly the second copy this comment
|
||||||
|
# exists to forbid. Absence is asserted in tests/test_handover_package_loadbearing.py.
|
||||||
#
|
#
|
||||||
# Usage: scripts/make-handover-package.sh [dest-dir] (default: dist/)
|
# Usage: scripts/make-handover-package.sh [dest-dir] (default: dist/)
|
||||||
# Gated by tests/test_handover_package_loadbearing.py.
|
# Gated by tests/test_handover_package_loadbearing.py.
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,24 @@
|
||||||
Microsoft Foundry with minimal friction.
|
Microsoft Foundry with minimal friction.
|
||||||
|
|
||||||
**Why an archive and not "clone the repo".** The receiving party is not a contributor: they get a
|
**Why an archive and not "clone the repo".** The receiving party is not a contributor: they get a
|
||||||
tree, set two environment variables and deploy it. The measured docker build context has always been
|
tree, set two environment variables and run it. The package is ``git archive HEAD`` itself — never a
|
||||||
``git archive HEAD`` (Dockerfile header), so the package is that SAME tree — never a hand-curated
|
hand-curated copy, which would be the second copy that drifts (the kø-(p) rule applied to a
|
||||||
copy, which would be the second copy that drifts (the kø-(p) rule applied to a deliverable).
|
deliverable).
|
||||||
|
|
||||||
Three seams are gated here, and each one is a way the handover fails in the receiver's hands rather
|
**Runnable Python, no container wrapper (operator directive 14.08, from an external trial).** What a
|
||||||
|
receiver gets is a Python tree they install and start themselves — ``uv sync --frozen`` +
|
||||||
|
``python main.py`` — and the ``Dockerfile``/``azure.yaml`` pair that used to ride along was removed
|
||||||
|
from the tree rather than filtered out of the archive. Filtering would have meant a curation
|
||||||
|
mechanism deciding what a receiver sees, i.e. a SECOND copy of "what is delivered" free to drift
|
||||||
|
from HEAD (the kø-(p) rule, which is the very reason this package is ``git archive HEAD``). Removing
|
||||||
|
the files keeps the archive uncurated and makes the absence a property of HEAD — which is what the
|
||||||
|
gate below can actually measure.
|
||||||
|
|
||||||
|
Four seams are gated here, and each one is a way the handover fails in the receiver's hands rather
|
||||||
than in ours:
|
than in ours:
|
||||||
|
|
||||||
1. **Completeness** — a tree missing ``uv.lock`` resolves different versions than every measurement
|
1. **Completeness** — a tree missing ``uv.lock`` resolves different versions than every measurement
|
||||||
in this repo ran against; missing ``shared/`` gives a container with no example knowledge base.
|
in this repo ran against; missing ``shared/`` gives a receiver with no example knowledge base.
|
||||||
2. **Exposure** — ``STATE.md``, ``*.local.md`` and ``.env`` must never leave this machine. The
|
2. **Exposure** — ``STATE.md``, ``*.local.md`` and ``.env`` must never leave this machine. The
|
||||||
archive is built from tracked files only, so this is a property of the BUILDER; the control below
|
archive is built from tracked files only, so this is a property of the BUILDER; the control below
|
||||||
proves the check looks for names that could actually appear.
|
proves the check looks for names that could actually appear.
|
||||||
|
|
@ -19,6 +28,10 @@ than in ours:
|
||||||
only an endpoint gets a container that answers ``/readiness`` and fails every ``/invocations``.
|
only an endpoint gets a container that answers ``/readiness`` and fails every ``/invocations``.
|
||||||
4e measured that requirement and called it "et deploy-krav ingen rad hadde skrevet ned"; DEPLOY.md
|
4e measured that requirement and called it "et deploy-krav ingen rad hadde skrevet ned"; DEPLOY.md
|
||||||
is that row, and this test is what keeps it written.
|
is that row, and this test is what keeps it written.
|
||||||
|
4. **Python-only delivery** — the archive carries no container/azd wrapper, and DEPLOY.md starts the
|
||||||
|
service the way the tree actually supports: as a Python process. A gate that merely stopped
|
||||||
|
REQUIRING ``Dockerfile`` could not tell "removed" from "still shipped", so the check is positive
|
||||||
|
(absence, asserted) rather than an omission.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -33,10 +46,10 @@ _REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
_SCRIPT = _REPO_ROOT / "scripts" / "make-handover-package.sh"
|
_SCRIPT = _REPO_ROOT / "scripts" / "make-handover-package.sh"
|
||||||
|
|
||||||
# Deploy-critical members. Each one is load-bearing for a receiver, not decoration:
|
# Deploy-critical members. Each one is load-bearing for a receiver, not decoration:
|
||||||
# the two manifests, the entry point, the locked resolution, the packaged data and the example base.
|
# the entry point, the locked resolution, the packaged data and the example base. `Dockerfile` and
|
||||||
|
# `azure.yaml` were REQUIRED here until 14.08; they are gone from the tree, and their absence is now
|
||||||
|
# asserted below instead of their presence.
|
||||||
_REQUIRED_MEMBERS = (
|
_REQUIRED_MEMBERS = (
|
||||||
"Dockerfile",
|
|
||||||
"azure.yaml",
|
|
||||||
"main.py",
|
"main.py",
|
||||||
"pyproject.toml",
|
"pyproject.toml",
|
||||||
"uv.lock",
|
"uv.lock",
|
||||||
|
|
@ -51,6 +64,25 @@ _REQUIRED_MEMBERS = (
|
||||||
_FORBIDDEN_SUFFIXES = (".local.md", ".env")
|
_FORBIDDEN_SUFFIXES = (".local.md", ".env")
|
||||||
_FORBIDDEN_NAMES = ("STATE.md",)
|
_FORBIDDEN_NAMES = ("STATE.md",)
|
||||||
|
|
||||||
|
# The container/azd wrapper. Matched on archive MEMBER NAMES, never on prose: the documents may
|
||||||
|
# explain that no image is shipped, and a gate that read the word out of a sentence would be red on
|
||||||
|
# exactly the prose it protects (this repo's 08-09 defect class).
|
||||||
|
_CONTAINER_WRAPPER_NAMES = ("Dockerfile", ".dockerignore", "docker-compose.yml", "azure.yaml")
|
||||||
|
|
||||||
|
# The start command a receiver is told to run. With no image CMD left, the document IS the one copy —
|
||||||
|
# and it names the entry point whose serve/SIGTERM behaviour test_hosting_loadbearing measures.
|
||||||
|
_PYTHON_START = "python main.py"
|
||||||
|
|
||||||
|
# Command invocations that would put a container step back into the documented path. Fragments, not
|
||||||
|
# the bare word: "no container image is shipped" must stay sayable.
|
||||||
|
_WRAPPER_COMMANDS = ("docker build", "docker run", "azd up", "azd deploy", "azd provision")
|
||||||
|
|
||||||
|
|
||||||
|
def _members_named(names: list[str], wanted: tuple[str, ...]) -> list[str]:
|
||||||
|
"""Archive members whose basename is one of ``wanted`` — the matcher both the assertion and its
|
||||||
|
control run through, so a matcher that silently matches nothing cannot pass unnoticed."""
|
||||||
|
return [n for n in names if Path(n).name in wanted]
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
def package(tmp_path_factory: pytest.TempPathFactory) -> zipfile.ZipFile:
|
def package(tmp_path_factory: pytest.TempPathFactory) -> zipfile.ZipFile:
|
||||||
|
|
@ -129,3 +161,63 @@ def test_deploy_doc_states_the_placeholder_requirement(package: zipfile.ZipFile)
|
||||||
assert "REPLACE-WITH-" in doc, (
|
assert "REPLACE-WITH-" in doc, (
|
||||||
"DEPLOY.md must state that the packaged deployment ids are placeholders"
|
"DEPLOY.md must state that the packaged deployment ids are placeholders"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_package_ships_runnable_python_and_no_container_wrapper(package: zipfile.ZipFile) -> None:
|
||||||
|
"""Detach point: put ``Dockerfile`` or ``azure.yaml`` back into HEAD → RED.
|
||||||
|
|
||||||
|
The 14.08 operator directive is that the deliverable is runnable Python. Removing the wrapper
|
||||||
|
from the TREE (rather than filtering it out of the archive) is what makes that measurable here:
|
||||||
|
the package is ``git archive HEAD``, so absence in the archive IS absence in what we ship.
|
||||||
|
|
||||||
|
Two controls, because a name-matcher that matches nothing would make this green forever:
|
||||||
|
the run path a receiver actually needs must be present, and the matcher must be shown to match
|
||||||
|
a member this archive really has."""
|
||||||
|
names = package.namelist()
|
||||||
|
|
||||||
|
# Control 1 — the Python run path is what replaces the image. If these are missing, "no
|
||||||
|
# container" would just mean "nothing to run".
|
||||||
|
for member in ("main.py", "pyproject.toml", "uv.lock"):
|
||||||
|
assert member in names, f"the runnable-Python path is incomplete: {member} is not packaged"
|
||||||
|
|
||||||
|
# Control 2 — the matcher matches by basename against THIS archive, proven on a member we know
|
||||||
|
# is there. Without it, a matcher comparing full paths would find nothing and pass silently.
|
||||||
|
assert _members_named(names, ("main.py",)) == ["main.py"], (
|
||||||
|
"control: the member matcher found nothing for a member the archive demonstrably has"
|
||||||
|
)
|
||||||
|
|
||||||
|
wrappers = _members_named(names, _CONTAINER_WRAPPER_NAMES)
|
||||||
|
assert not wrappers, (
|
||||||
|
f"the handover package ships a container/azd wrapper: {wrappers}. The delivery is runnable "
|
||||||
|
"Python (operator directive 14.08); a wrapper here is a second, unmeasured way to start it."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_receiver_documents_start_the_service_as_a_python_process(package: zipfile.ZipFile) -> None:
|
||||||
|
"""Detach point: tell the receiver to build an image again in either document → RED.
|
||||||
|
|
||||||
|
Both documents ride inside the archive, so both are the receiver's instructions. Each is checked
|
||||||
|
LINE-ANCHORED with its own positive control first: a negative assertion on a document the
|
||||||
|
extractor failed to read is a gate that can only be green."""
|
||||||
|
deploy = package.read("DEPLOY.md").decode("utf-8").splitlines()
|
||||||
|
readme = package.read("README.md").decode("utf-8").splitlines()
|
||||||
|
|
||||||
|
# Positive control on DEPLOY.md: with no image CMD left, this document carries the ONE copy of
|
||||||
|
# the start command, and it must name the entry point the hosting tests actually exercise.
|
||||||
|
assert [line for line in deploy if _PYTHON_START in line], (
|
||||||
|
f"DEPLOY.md no longer prints the start command ({_PYTHON_START!r}) — the receiver has "
|
||||||
|
"nothing to run, and the one copy of the start command is gone"
|
||||||
|
)
|
||||||
|
# Positive control on README.md: proves this document was read and searched at all.
|
||||||
|
assert [line for line in readme if "uv sync" in line], (
|
||||||
|
"control: README.md has no install line — the check below would be searching nothing"
|
||||||
|
)
|
||||||
|
|
||||||
|
for label, lines in (("DEPLOY.md", deploy), ("README.md", readme)):
|
||||||
|
offenders = [
|
||||||
|
line for line in lines if any(fragment in line for fragment in _WRAPPER_COMMANDS)
|
||||||
|
]
|
||||||
|
assert not offenders, (
|
||||||
|
f"{label} instructs a container/azd build step that this package no longer ships: "
|
||||||
|
f"{offenders}"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,11 @@ Load-bearing surface pinned here:
|
||||||
* The server is asyncio on the ONE loop (NG1: ``test_no_thread_or_process_path_exists_under_src``
|
* The server is asyncio on the ONE loop (NG1: ``test_no_thread_or_process_path_exists_under_src``
|
||||||
ratchets src/ thread-free) — these tests run client and server as coroutines on the SAME loop,
|
ratchets src/ thread-free) — these tests run client and server as coroutines on the SAME loop,
|
||||||
which only works because nothing in the server blocks it.
|
which only works because nothing in the server blocks it.
|
||||||
* Root ``main.py`` is the ONE process entry (Dockerfile CMD + azure.yaml point at it): the
|
* Root ``main.py`` is the ONE process entry (``python main.py``, the command DEPLOY.md prints): the
|
||||||
subprocess test is the ONLY test that catches a detached shim or a detached SIGTERM handler
|
subprocess test is the ONLY test that catches a detached shim or a detached SIGTERM handler
|
||||||
(P4-presedensen: entry-point-mutasjoner fanges aldri av in-process-tester).
|
(P4-presedensen: entry-point-mutasjoner fanges aldri av in-process-tester).
|
||||||
|
|
||||||
Fase 4e closes two gaps the above leaves open, and both are about things a stand-in cannot see:
|
Fase 4e closed two gaps the above leaves open, and both are about things a stand-in cannot see:
|
||||||
|
|
||||||
* **The whitelist composes with the REAL ``run_project``.** Every test above hands ``invoke`` a
|
* **The whitelist composes with the REAL ``run_project``.** Every test above hands ``invoke`` a
|
||||||
stand-in that swallows ``**kwargs``, so the whitelist could name a field ``run_project`` does not
|
stand-in that swallows ``**kwargs``, so the whitelist could name a field ``run_project`` does not
|
||||||
|
|
@ -33,14 +33,11 @@ Fase 4e closes two gaps the above leaves open, and both are about things a stand
|
||||||
PURPOSE (a caller must never choose the server's model client), so patching the factory the run
|
PURPOSE (a caller must never choose the server's model client), so patching the factory the run
|
||||||
falls back to is the only injection point this surface leaves — the same argument
|
falls back to is the only injection point this surface leaves — the same argument
|
||||||
``test_run_cli_loadbearing`` makes for ``main()``.
|
``test_run_cli_loadbearing`` makes for ``main()``.
|
||||||
* **The deployment artifacts are raw-text-gated.** ``Dockerfile`` and ``azure.yaml`` are the two
|
* **The deployment artifacts were raw-text-gated** — until 14.08, when the operator directive
|
||||||
files that decide whether the image the platform pulls can run at all, and NO test executes
|
after an external trial made the delivery runnable Python and the two artifacts were removed
|
||||||
them here (``docker build``/``azd deploy`` are operator-gated). A raw-text gate is therefore the
|
from the tree. The gate is deleted, not weakened; see the note where it stood, below the
|
||||||
only mechanism available: it pins ``--platform linux/amd64`` (measured required — spike §1.4;
|
end-to-end test. The start command now has exactly one copy left, in DEPLOY.md, and
|
||||||
an arm64 image built on this Intel-free-of-charge assumption would fail only in the cloud) and
|
``tests/test_handover_package_loadbearing.py`` is what keeps it there.
|
||||||
the ONE-copy rule for the start command (the image's ``CMD``; ``azure.yaml`` carries no
|
|
||||||
``startupCommand`` to drift from it). Guard-tester leser kildefiler som RÅ TEKST — reformulate
|
|
||||||
the prose around them, never the strings they pin.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -381,8 +378,8 @@ def _blocking_get(url: str) -> tuple[int, str]:
|
||||||
|
|
||||||
|
|
||||||
def test_main_entrypoint_serves_and_stops_on_sigterm() -> None:
|
def test_main_entrypoint_serves_and_stops_on_sigterm() -> None:
|
||||||
"""Root main.py is the ONE process entry (Dockerfile CMD + azure.yaml point at it): started
|
"""Root main.py is the ONE process entry (``python main.py``, the command DEPLOY.md prints):
|
||||||
as a subprocess it must serve /readiness and exit 0 on SIGTERM. This is the only test that
|
started as a subprocess it must serve /readiness and exit 0 on SIGTERM. This is the only test that
|
||||||
catches a shim that stops calling hosting.main() or a detached SIGTERM handler."""
|
catches a shim that stops calling hosting.main() or a detached SIGTERM handler."""
|
||||||
with socket.socket() as probe:
|
with socket.socket() as probe:
|
||||||
probe.bind(("127.0.0.1", 0))
|
probe.bind(("127.0.0.1", 0))
|
||||||
|
|
@ -415,7 +412,7 @@ def test_main_entrypoint_serves_and_stops_on_sigterm() -> None:
|
||||||
proc.wait()
|
proc.wait()
|
||||||
|
|
||||||
|
|
||||||
# --- Fase 4e: the whitelist against the REAL run_project, and the deployment artifacts ----------
|
# --- Fase 4e: the whitelist against the REAL run_project -----------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
|
|
@ -491,51 +488,9 @@ async def test_invocations_answers_through_the_real_run_project(
|
||||||
assert _scripted_backend, "the scripted backend was never called — no real run happened"
|
assert _scripted_backend, "the scripted backend was never called — no real run happened"
|
||||||
|
|
||||||
|
|
||||||
def test_deployment_artifacts_pin_the_measured_platform_and_one_start_command() -> None:
|
# The 4e raw-text gate on ``Dockerfile``/``azure.yaml`` lived here until 14.08. It pinned
|
||||||
"""RAW-TEXT GATE on the two files that decide whether the hosted image runs at all.
|
# ``--platform linux/amd64`` and the one-copy rule for the image's ``CMD``. Both files were removed
|
||||||
|
# from the tree that day (operator directive: the delivery is runnable Python), and a gate that pins
|
||||||
Neither is executed by any test: ``docker build`` and ``azd deploy`` are operator-gated, so a
|
# a surface we no longer ship is deleted with it rather than weakened into something that can only
|
||||||
regression in them is invisible to the whole suite until it fails in the cloud. What the gate
|
# be green. What replaced it lives in ``tests/test_handover_package_loadbearing.py``: the package
|
||||||
pins is exactly what was MEASURED, and nothing about the prose around it:
|
# must carry NO container/azd wrapper and must document the Python start command.
|
||||||
|
|
||||||
* ``--platform linux/amd64`` — the platform requires x86_64 (spike §1.4). Dropping it makes the
|
|
||||||
image inherit the builder's architecture, which on an arm64 laptop yields an image that
|
|
||||||
builds green locally and cannot start in the cloud. The flag lives in the documented build
|
|
||||||
command (a Dockerfile cannot set the build platform for its own invocation), so pinning the
|
|
||||||
documented string is the only gate available — and it is worth having precisely because
|
|
||||||
nothing else re-derives it.
|
|
||||||
* ONE copy of the start command: the image's ``CMD`` names ``main.py``, and ``azure.yaml``
|
|
||||||
declares NO ``startupCommand``. Two copies drift (kø-(p)-regelen); this is the pair that
|
|
||||||
keeps there being one.
|
|
||||||
|
|
||||||
``env:`` is checked for the same reason: ``FOUNDRY_PROJECT_ENDPOINT`` is injected by the
|
|
||||||
platform, and redeclaring it here could shadow the injected value — which is the failure mode
|
|
||||||
Fase 4b's endpoint precedence exists to avoid, undone from the config side."""
|
|
||||||
dockerfile = _REPO_ROOT / "Dockerfile"
|
|
||||||
azure_yaml = _REPO_ROOT / "azure.yaml"
|
|
||||||
assert dockerfile.is_file(), "the hosted image has no build definition"
|
|
||||||
assert azure_yaml.is_file(), "azd has no project definition to deploy"
|
|
||||||
|
|
||||||
docker_text = dockerfile.read_text(encoding="utf-8")
|
|
||||||
azure_text = azure_yaml.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
assert "--platform linux/amd64" in docker_text, (
|
|
||||||
"the Dockerfile no longer names the measured build platform; an image built without it "
|
|
||||||
"inherits the builder's architecture and cannot start on the hosting platform"
|
|
||||||
)
|
|
||||||
assert "CMD" in docker_text and "main.py" in docker_text, (
|
|
||||||
"the image's CMD is the ONE copy of the start command and must name main.py"
|
|
||||||
)
|
|
||||||
# Line-anchored: a mention inside a comment is prose, a top-level key is a declaration.
|
|
||||||
azure_keys = [line.split(":")[0].strip() for line in azure_text.splitlines()]
|
|
||||||
assert "startupCommand" not in azure_keys, (
|
|
||||||
"azure.yaml declares a startupCommand — a SECOND copy of the start command, free to drift "
|
|
||||||
"from the image's CMD"
|
|
||||||
)
|
|
||||||
assert "env" not in azure_keys, (
|
|
||||||
"azure.yaml declares an env block — FOUNDRY_PROJECT_ENDPOINT is injected by the platform "
|
|
||||||
"and must never be redeclared here"
|
|
||||||
)
|
|
||||||
assert "host: azure.ai.agent" in azure_text and "kind: hosted" in azure_text, (
|
|
||||||
"azure.yaml no longer declares the hosted-agent host this whole entrypoint targets"
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@ made in prose that no test could see, and both drift silently:
|
||||||
Both gates read the source artefacts as RAW TEXT, because that is the only thing that can see prose.
|
Both gates read the source artefacts as RAW TEXT, because that is the only thing that can see prose.
|
||||||
Both are LINE-ANCHORED rather than substring-matched: ``backends.py`` NAMES ``DefaultAzureCredential``
|
Both are LINE-ANCHORED rather than substring-matched: ``backends.py`` NAMES ``DefaultAzureCredential``
|
||||||
four times in the comments that explain why it is not used, so a whole-file substring check would be
|
four times in the comments that explain why it is not used, so a whole-file substring check would be
|
||||||
red on exactly the prose it protects (this repo's 08-09 defect class, and the reason the 4e
|
red on exactly the prose it protects (this repo's 08-09 defect class, and the reason the handover
|
||||||
``azure.yaml`` gate is line-anchored too).
|
package's python-only gate matches archive member NAMES rather than document prose).
|
||||||
|
|
||||||
Each positive assertion is paired with a CONTROL that the thing being searched for is actually
|
Each positive assertion is paired with a CONTROL that the thing being searched for is actually
|
||||||
present. An extractor that silently finds nothing makes a gate that can only ever be green, which
|
present. An extractor that silently finds nothing makes a gate that can only ever be green, which
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue