GitHub Actions in this repo builds the ai image (the harness plus the
data-mcp, analysis-mcp, and presentation-mcp FastMCP apps it mounts
in-process) and swaps it
onto an existing Cloud Run service. It never creates or reconfigures a
service. Dev deploys automatically on every push to dev that touches the
image's inputs. Production deploys on every push to main, and on manual
dispatch. Both run the same steps from build-deploy.yml. The sibling
infra repo owns the Cloud Run service definition (service account,
scaling, secrets, env vars) that these workflows target.
The workspace services (harness, data-mcp, analysis-mcp, presentation-mcp) deploy as ONE Cloud Run service. See docs/architecture.md for the full rationale.
Until infra defines the Cloud Run service and someone sets the repo
variables below, the deploy workflows will fail at the "resolve image ref"
or auth step. ci.yml needs none of this and works today.
${REGION}-docker.pkg.dev/${PROJECT_ID}/${AR_REPO}/dfo-ai:<tag>
<tag> is the git SHA the image was built from. Dev builds one image per
commit to dev that touched packages/, services/, deploy/Dockerfile.ai, or
either deploy workflow. Production builds one per push to main, into
prod's own registry.
build-deploy.yml reads these from a GitHub Environment matching the
deploy target (dev, later staging, production). Configure the same variable
and secret names in each environment with per-environment values. The
workflow YAML doesn't change between environments. Only the environment it
targets does.
| Name | Kind | Example value | Who provides it |
|---|---|---|---|
WIF_PROVIDER |
secret | projects/123456789/locations/global/workloadIdentityPools/github/providers/github-actions |
the infra repository's handoff (WIF pool/provider) |
WIF_SA |
secret | dfo-ai-deployer@dev-fin-observatory.iam.gserviceaccount.com |
the infra repository's handoff (deployer service account) |
REGION |
var | northamerica-northeast1 (dev), europe-west1 (production) |
the infra repository's handoff |
PROJECT_ID |
var | dev-fin-observatory (dev); the prod project ID in production |
the infra repository's handoff |
AR_REPO |
var | datacommons |
the infra repository's handoff (Artifact Registry repo) |
SERVICE_AI |
var | dfo-dev-ai |
the infra repository's handoff, once the Cloud Run service exists |
JOB_LANGFUSE_EXPORT |
var | dfo-prod-langfuse-export (production only) |
the infra repository's handoff, once the job exists |
The Cloud Run service itself also needs STUDIO_API_TOKEN mounted as a
secret (infra, in the service definition next to GOOGLE_API_KEY). It
is the shared secret the frontend's proxy presents on studio requests. A
service without it accepts any non-empty bearer token and logs
STUDIO AUTH IS OPEN at startup.
The service takes LIVE_REFRESH_PRINCIPAL as a second secret (infra
generates it in the apis stack and mounts one value on apis, this service,
and the website). It is the bearer of POST /api/studio/refresh, which the
website calls to redraw a live share's derived card, and the principal this
service presents to apis' frame refresh and render routes. A service
without it refuses every refresh with 401 and logs LIVE REFRESH IS CLOSED at
startup. Startup fails when it equals STUDIO_API_TOKEN. The route also reads
DFO_APIS_URL and DATASETS_FIRST_PARTY_PRINCIPAL, which the service already
holds.
A refresh answers within 53 seconds, the deadline LIVE_REFRESH_DEADLINE_S
(default 45) plus an 8 second hard stop. The website gives the call 60 seconds, so startup
fails for a deadline above 52 or below 30, and the Cloud Run request timeout,
set in infra, must be above the deadline plus 8. The other
refresh settings (LIVE_REFRESH_REQUESTS_PER_S, LIVE_REFRESH_BURST,
LIVE_REFRESH_MAX_IN_FLIGHT, LIVE_REFRESH_ROWS_IN_FLIGHT_MAX) have defaults
sized for one instance and are described in services/harness/README.md.
MCP_API_TOKEN needs no secret on this one-Cloud-Run-service deployment.
Left unset, the harness mints a token at startup and hands it to its own
data-mcp / analysis-mcp / presentation-mcp MCP clients (common.auth),
so the three mounts require a credential nothing outside the process ever
holds. Set it as a secret only once one of those services is split out to
its own Cloud Run service (see "Splitting a service out later" below).
DATA_MCP_URL / ANALYSIS_MCP_URL / PRESENTATION_MCP_URL then point off
loopback, and startup refuses that combination without a shared
MCP_API_TOKEN the split-out service also has.
The workspace's dfo-spec-kit git dependencies come from the public
DevelopmentFinanceObservatory/spec-kit repo, so ci.yml, build-deploy.yml,
and deploy/Dockerfile.ai need no token to install them.
The service name value above is a placeholder for illustration. infra names the actual service, and whatever they pick goes in this var verbatim.
infra sets every variable and secret in the Environment: SERVICE_AI,
JOB_LANGFUSE_EXPORT (production), WIF_PROVIDER, WIF_SA, REGION, PROJECT_ID, and AR_REPO. Run
just ENV=dfo-dev handoff ai from the infra repo for the dev
Environment, and just ENV=dfo-prod handoff ai for production. The
script also sets the deployment branch policy. The values stay reproducible
and the Environment has one writer.
Before the first real deploy, infra needs to set up, per environment:
- A Workload Identity Federation pool and provider trusting this repo's GitHub Actions OIDC issuer (no JSON service account keys).
- A deployer service account, impersonable through that provider, with:
roles/run.developer(update Cloud Run services)roles/artifactregistry.writer(push images)roles/iam.serviceAccountUser(act as the Cloud Run runtime SA, which is a dedicated identity on theaiservice)- in production,
roles/run.developeron the Langfuse export job, to point it at each new image, androles/iam.serviceAccountUseron the job's service account
- ONE Cloud Run service for the
aiimage (the harness process that mounts data-mcp, analysis-mcp, and presentation-mcp in-process, per docs/architecture.md), including its runtime service account, scaling config, env vars, and secrets. This repo's workflows only ever update the--imageon the service that already exists. - Model credentials on that service. google-adk reads Gemini/Vertex
configuration straight from the process environment (
GOOGLE_API_KEY, orGOOGLE_GENAI_USE_VERTEXAI+GOOGLE_CLOUD_PROJECT+GOOGLE_CLOUD_LOCATION), not from this repo's settings classes, so they must be wired into the service definition as env vars/secrets. DFO_APIS_URLon that service, pointing at apis, the FastAPI app that serves both the Search and Datasets surfaces. The data-mcp component mounted in the harness reads it to reach that API and falls back to ahttp://localhost:8083default without it, so the deployed service can't search until it's set. The value depends on the apis dev deployment existing. The Datasets surface additionally needs the apis service'sDFO_DATASETS_SURFACEflag set, since that gates whether it mounts at all.DFO_SITE_ORIGINon that service, set to the website's public origin (scheme and host, no path). The artifact download links data-mcp hands to models sit on it, since the website proxies/api/datasets/*to dfo-apis with its own ID token and dfo-apis refuses callers without one. The harness fails at startup, naming the variable, when it is unset on Cloud Run.PRESENTATION_PUBLIC_URLon that service, set to the service's own publichttpsURL plus/mcp/presentation. Every presentation view's payload names its theme stylesheet under it, and the renderer's CSP admits its origin. It has no default. The harness fails at startup, naming the variable, when it is unset or not a valid absolutehttp(s)URL with no query, fragment, or credentials, so a revision without it never serves views a browser would render unstyled.- The Artifact Registry repo named by
AR_REPOin each environment's project (datacommonsin dev). - In production, the Cloud Run job named by
JOB_LANGFUSE_EXPORTon theaiimage, triggered hourly with an 1800-second task timeout and no task retries, running as its own service account withroles/bigquery.jobUseron the project androles/bigquery.dataEditoron the export dataset, and with the settings listed under "Langfuse export".
- A
devGitHub Environment withdevas its only deployment branch, and aproductionGitHub Environment withmainas its only deployment branch andreleaseas required reviewers. - The vars/secrets table above, populated per environment.
- Staging is unbuilt. infra has no staging Terraform tree or GCP
project, and standing one up means a new
infra/<env>/directory with its own backend and state prefix. Once it exists, astagingGitHub Environment and astagingentry indeploy-dev.yml'sdeployjobenvironmentlist are the only changes needed on this side.
The one-Cloud-Run-service model is a starting point, not a permanent constraint. Each workspace package (harness, data_mcp, analysis_mcp, presentation_mcp) stays a separate uv package with its own pyproject.toml, so pulling one back out into its own Cloud Run service is additive, not a rewrite:
- Add a
deploy/Dockerfile.<service>for it, followingdeploy/Dockerfile.ai's two-stage uv pattern: a builder stage that copies in the workspace manifest and the memberpyproject.tomlfiles the split-out service actually needs, runsuv sync --frozen --no-dev --no-install-workspace --package <service>for the dependency-only layer, then copies insrc/and runsuv sync --frozen --no-dev --no-editable --package <service>for the full build; and a slim runtime stage that copies only/app/.venvfrom the builder. - Add a build and deploy step for it in
build-deploy.ymlpointing at its ownvars.SERVICE_<NAME>. - Point the harness's
DATA_MCP_URL/ANALYSIS_MCP_URL/PRESENTATION_MCP_URLenv var at the new service'shttpsURL instead of the in-container loopback path (a Cloud Run service URL already is one). That env override is the seamharness.settings.Settingswas built with, so no code change is needed on the harness side. - Provision
MCP_API_TOKENas a shared secret on both the harness and the split-out service. The harness's own minted-token default only works while every mount shares its process. Once one is reachable over a real URL,harness.settings.Settingsrefuses to start without an explicitMCP_API_TOKEN(the split-out service needs the identical value), and refuses a non-httpsURL even with one set -- that shared bearer goes out on every request, so cleartext HTTP would leak it.
The triggers for actually doing this: external MCP traffic (through the website proxy) interfering with chat-session latency on the shared instance, or the search/analysis and harness workloads needing to scale independently of each other.
python -m harness.recipe_backfill stores in apis the recipe of every derived frame in the materialisation bucket. A derived frame published without its recipe can only be replayed from the traces in that bucket, so run this job to completion before the bucket's 30-day age rule is restored.
The module ships in the image (services/harness/src/harness/recipe_backfill.py) and starts no service, no model, and no MCP server. It reads three settings from the environment or .env, under the names the services use, and exits 2 naming any that is unset:
| Setting | Use |
|---|---|
DFO_APIS_URL |
apis' origin |
DATASETS_FIRST_PARTY_PRINCIPAL |
the publish principal, sent on the recipe routes |
FRAME_STORE_BUCKET |
the materialisation bucket |
The identity it runs as needs to list and read objects in the bucket. --write also creates mints/v4/ objects, so it needs object create.
From a workstation, set DFO_APIS_URL=http://localhost:8083 and run just apis-proxy beside it, since the job sends a Cloud Run ID token only on Cloud Run, in a service (K_SERVICE set) or a job (CLOUD_RUN_JOB set).
uv run --package harness python -m harness.recipe_backfill # dry run, the default
uv run --package harness python -m harness.recipe_backfill --write
uv run --package harness python -m harness.recipe_backfill --write --since 2026-09-14 --until 2026-09-21
uv run --package harness python -m harness.recipe_backfill --limit 50
A dry run builds every recipe and compares it with what apis holds. It sends no PUT and writes nothing to the bucket. --write puts each recipe to PUT /api/datasets/frames/{spec_id}/{content_version}/recipe and writes each recovered mint record to the bucket. --since and --until bound the frames by the time their object was written, and --limit takes the oldest N in range, so the job can run in parts. A run can be repeated: apis keeps a pair's first recipe, and a second --write run reports already_stored for each.
The job walks the frames oldest first and works on four at a time. For each derived frame it downloads the frame's object once (at most 64 MiB), checks its digest, and reads the trace from the Parquet footer. It decodes no rows, writes no frame object, and deletes nothing.
The report lists every derived frame with one reason:
| Reason | Meaning | Detail |
|---|---|---|
stored |
this run stored the recipe | |
already_stored |
apis holds the recipe, with the same two hashes when it could be rebuilt | not rebuilt: <reason> ... when it could not |
would_store |
dry run only: the recipe was built and apis holds none | |
not_published |
apis has no version of the frame (404 snapshot_not_found), so no card draws on it |
|
missing_ancestor |
a frame it was computed from is gone from the bucket | the missing pair |
root_unrecoverable |
a root dataset frame has no mint record and none could be rebuilt | the root pair and the reason, such as no_version_matches |
trace_unusable |
a stored trace on the way yields no op-spec | the pair and why |
anchor_unresolved |
a rolling frame's latest period cannot be stated from what the bucket holds | the pair and the reason, such as no_anchor_record |
conflict |
apis refused the recipe (409 publish_conflict) |
the constraint_id |
apis_failure |
an apis service call failed after the client's retries | status= and code= |
unexpected_error |
reading the frame raised; the traceback is logged | the exception type |
Exit status is 0 when every frame ended on a settled reason, and 1 when any ended apis_failure or unexpected_error, which a rerun may clear.
The report closes with the numbers to read before the age rule returns:
Derived frames with a recipe stored in apis: 3 (stored 3, already stored 0)
Derived frames without a stored recipe: 3
missing_ancestor 1
root_unrecoverable 1
conflict 1
(2 of these had no recipe to send, so apis was not asked whether they are published)
Not published in apis, so no card draws on them: 1
Oldest frame object: 23.0 days (written 2026-09-14T00:01:00Z)
The first number is the frames whose cards can be refreshed after the bucket's objects are deleted. Every frame under the second is listed above with its reason, and a card drawn from one of them cannot be refreshed. A conflict whose detail is recipe_requires_matching_normal_form_hash or recipe_requires_matching_replay_key means apis holds another recipe for the pair. The last line is the age of the oldest object the walk saw: a restored 30-day rule deletes every object older than 30 days on its next pass.
python -m harness.langfuse_export loads the Langfuse observations of one environment into the BigQuery table observations, day-partitioned on start_time. It runs as the Cloud Run job named by JOB_LANGFUSE_EXPORT on this image, and build-deploy.yml points that job at each new image in an Environment that sets the var. It reads these settings from the environment or .env and exits 2 naming any that is unset:
| Setting | Use |
|---|---|
LANGFUSE_PUBLIC_KEY |
the Langfuse project's public key |
LANGFUSE_SECRET_KEY |
its secret key |
LANGFUSE_BASE_URL |
the Langfuse origin, https://cloud.langfuse.com |
LANGFUSE_EXPORT_ENVIRONMENT |
the Langfuse environment to export, dfo-prod-ai |
EXPORT_BQ_PROJECT |
the BigQuery project holding the dataset |
EXPORT_BQ_DATASET |
the dataset, langfuse_export |
Each run replaces every day from the latest loaded day minus one through today (UTC), and every day from 2026-10-05 on whose Langfuse observation count differs from its partition's row count, one load job per day writing that day's partition with WRITE_TRUNCATE. The counts come from GET /api/public/v2/metrics queries grouped by day, one for each 900 days since 2026-10-05, each asking for Langfuse's maximum of 1,000 rows; a query that answers 1,000 rows may be cut and fails the run. The day before the latest loaded day is read every run because Langfuse updates an observation's end time, usage and cost in place, which changes no count. A day's rows are keyed by observation id, so an observation two pages both hold loads once, as the copy with an end time, or the later-seen copy when neither or both have one. A replace never shrinks a partition: a day that reads fewer rows than its partition holds, none included, keeps the partition and prints both counts. A day that reads fewer rows than its partition holds or than Langfuse counts fails the run: the other days still load, then the run exits 1 naming every such day with its counts, and the day stays in later runs until the counts match. A day whose partition holds rows Langfuse no longer counts, such as an observation deleted in the Langfuse UI, fails every run until its rows are deleted from the table, after which the next run reloads it: bq query --use_legacy_sql=false 'DELETE FROM `PROJECT.DATASET.observations` WHERE DATE(start_time) = "YYYY-MM-DD"'. When the count query fails, the run still loads the days from the latest loaded day minus one through today under the never-shrink rule, then exits 1 with the query's error, which also names any day that read fewer rows than its partition holds. When every day of the run reads no rows, the run exits 1 naming the days and LANGFUSE_EXPORT_ENVIRONMENT, whether or not the count query failed, since a wrong environment or keys for another project read as empty and prod has had turns every day since 2026-10-05. Each day prints a line as it is replaced or kept, and a failure prints the day it stopped on. An empty table is backfilled from 2026-10-05. The job creates the table when it is absent, and a load adds any column the table lacks, which rows loaded earlier hold as NULL until their day is replaced. Its identity needs roles/bigquery.jobUser on the project and roles/bigquery.dataEditor on the dataset.
The rows hold ids, trace and session ids, names, type, level, environment, model, start and end time, latency, time to first token, tokens and cost. input_tokens is Langfuse's inputUsage, which includes cached input, and cached_input_tokens is usageDetails.input_cached_tokens, NULL on an observation without that key. The job never requests the io or metadata field groups and leaves out statusMessage and userId. The dfo organisation's Langfuse Cloud Pro plan shows 3 years of data, which reaches back past 2026-10-05, and sets the request limits the job keeps under. Langfuse requests go out at most one every 0.1 seconds, 600 a minute, and observation pages count against the General API bucket of 1,000 requests a minute. Metrics queries draw from the separate Metrics API bucket of 500 queries an hour and make at most two attempts: each hourly run spends one query and at most two through 2029-03-22, and two queries and at most four through 2031-09-08, and each --check-day spends two to four. A request error ends the run, with these rules: a 429 waits when its Retry-After, in seconds or as an HTTP date, is at most 60 seconds, or backs off for 15, 30, then 60 seconds, one step for each 429 in a row, when the header is missing or unusable, and ends the run when it asks for longer, as an exhausted Metrics bucket can, or when the attempts run out; a 5xx or a transport error is retried with backoff; any other 4xx ends the run. A refused run ends well inside the job's 1800-second timeout. A failed request's error carries the first 500 characters of Langfuse's answer.
--check-day DAY reads one day and compares its row count, summed cost and summed cached input tokens with GET /api/public/v2/metrics for the same day and environment. It needs only the Langfuse settings, writes nothing, and exits 1 on a difference. When Langfuse refuses the usageByType measure that sums cached input tokens, the check fails naming that measure.
uv run --package harness python -m harness.langfuse_export --check-day 2026-10-20
--from DAY adds every day from DAY through today to the run's days, under the rules above, so a column added to the table reaches history. Days run oldest first. Nothing before 2026-10-05, the first day exported, is read. Run it after a release adds a column. On the production job:
gcloud run jobs execute "$JOB_LANGFUSE_EXPORT" --region="$REGION" --project="$PROJECT_ID" \
--args=-m,harness.langfuse_export,--from,YYYY-MM-DD --wait
A --from run that exits non-zero has replaced the days it reached and left the others as they were. The hourly runs do not repair the others, since their counts already match, so rerun it from the first day its execution's log reports neither replaced nor partition kept for. Each day prints its line as it finishes, so the log shows how far the run got. Rerunning from the original DAY would replace the same leading days again and, for a run that outlived the job's 1800-second task timeout, stop at about the same day. Every replace is whole-day and never shrinks, so a rerun is safe. For a long backfill, give that execution a longer deadline with --task-timeout:
gcloud run jobs execute "$JOB_LANGFUSE_EXPORT" --region="$REGION" --project="$PROJECT_ID" \
--task-timeout=7200s --args=-m,harness.langfuse_export,--from,FIRST-DAY-NOT-REACHED --wait
Start it a few minutes after the hour, because the hourly trigger's run shares Langfuse's request budget.