Repository navigation
Expand file tree
/
Copy pathjustfile
More file actions
1169 lines (1098 loc) · 58.5 KB
/
Copy pathjustfile
File metadata and controls
1169 lines (1098 loc) · 58.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Engram dev recipes. Install `just`: https://github.com/casey/just
#
# Single-line summary of the dev story:
# `just dev` runs `tilt up` on every host: docker-compose infra
# (postgres + OCI registry + fake-gcs + jaeger), the KEK one-shot,
# the coordinator, a host-agent (on real-virt hosts), and the web
# SPA. Visit http://localhost:10350 for the Tilt dashboard. The
# backend (Firecracker / VZ / process) is detected per host by
# deploy/dev/detect-backend.sh — no flag, no per-arch recipe (ADR
# 0024). `just bake-demo` bakes the Claude image; `just pull-kernel`
# fetches the right kernel.
set shell := ["bash", "-cu"]
set dotenv-load := true
# `just` with no args prints the recipe list.
default:
@just --list
# ------------------------------------------------------------------
# Build / quality gates
# ------------------------------------------------------------------
# Format, clippy, build, and test gates — run before pushing.
#
# Uses `cargo nextest` for ~3-5× speedup over `cargo test --workspace`.
# Inside `nix develop` it's already on $PATH; outside Nix install with
# `cargo install cargo-nextest --locked` (one-time, ~30s).
check:
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo hakari generate --diff
cargo nextest run --workspace
# Auto-format the workspace.
fmt:
cargo fmt --all
# Regenerate `workspace-hack/Cargo.toml` from the current dep
# graph. Run after adding or removing a workspace dep so
# `cargo hakari generate --diff` (in `just check` and CI) stays
# green. The workspace-hack crate unifies feature sets across
# members so switching between `cargo test -p X` and
# `cargo build -p Y` doesn't re-cook shared deps under different
# features.
#
# `workspace-hack/Cargo.toml` is GENERATED. Never hand-edit it, and
# never bump a version inside it — the only correct content is what
# this recipe emits.
hakari:
cargo hakari generate
cargo hakari manage-deps --yes
# Regenerate the TS bindings for the app contract (ADR 0051 §7), driven
# by buf.gen.yaml. Run after editing crates/engram-protocol/proto/engram/app/**.
# The output dirs (web/src/gen, orchestrator/src/gen) land with their
# consumers (ADR 0051 Tasks 4-5); until then there is nothing to generate.
gen-proto:
@if [ -d crates/engram-protocol/proto/engram/app ]; then \
buf generate; \
else \
echo "engram/app contract not present yet (ADR 0051 Tasks 4-5) — nothing to generate"; \
fi
# Run all tests via nextest (faster). Pass extra args after `--`.
test *ARGS:
cargo nextest run --workspace {{ARGS}}
# Run a single crate's tests with output. Stays on `cargo test` so
# `--nocapture` works the way you expect.
test-pkg pkg *ARGS:
cargo test -p {{pkg}} -- --nocapture {{ARGS}}
# Run a crate's Linux-gated tests (the `#[cfg(target_os = "linux")]` ones —
# e.g. engram-harness-claude's hook/socket/resume suite). On Linux this is
# just `cargo test`. On macOS those tests don't compile or run natively, so
# this builds + runs them inside a `rust` Docker container — on Apple
# Silicon that's the native arch (no emulation). The host cargo registry is
# mounted so crates aren't re-downloaded, and a container-local
# CARGO_TARGET_DIR keeps the macOS `target/` (a different target triple)
# untouched. Uses `cargo test` (not nextest) so the container needs no extra
# cargo tooling; `jq` is installed on demand — the fake-codex test scripts
# shell out to it, and `rust:bookworm` doesn't ship it. `rust:bookworm`
# tracks the latest stable, matching our pinned `channel = "stable"`.
# `protoc` is installed too — every crate in the `engram-protocol`
# dependency closure (engram-host-agent among them, which owns the
# Linux-gated dirty-file recovery tests) fails its build script without it.
# Example: `just test-linux engram-harness-claude`.
test-linux pkg='engram-harness-claude' *ARGS:
#!/usr/bin/env bash
set -euo pipefail
case "$(uname -s)" in
Linux)
cargo test -p {{pkg}} {{ARGS}} ;;
Darwin)
if ! command -v docker >/dev/null 2>&1; then
echo "test-linux on macOS needs Docker — the cfg(target_os=\"linux\") tests can't run on darwin." >&2
exit 1
fi
echo ">> macOS host: running {{pkg}} Linux tests in a rust:bookworm container (native arch)…" >&2
# Target dir is a named volume (lives in Docker's VM on a native
# fs — fast incremental rebuilds, persists across runs — unlike a
# virtiofs bind mount). Registry is bind-mounted from the host so
# crates aren't re-downloaded.
docker run --rm \
-v "$PWD":/work \
-v "$HOME/.cargo/registry":/usr/local/cargo/registry \
-v engram-linux-target:/lxtarget \
-w /work \
-e CARGO_TARGET_DIR=/lxtarget \
rust:bookworm \
bash -c "command -v jq >/dev/null && command -v protoc >/dev/null || (apt-get update -qq && apt-get install -y -qq jq protobuf-compiler); cargo test -p {{pkg}} {{ARGS}}" ;;
*)
echo "unsupported host: $(uname -s)" >&2; exit 1 ;;
esac
# ------------------------------------------------------------------
# Local stack — Postgres in docker, coordinator in cargo.
# ------------------------------------------------------------------
# Start Postgres only. Coordinator runs via `just dev`.
db-up:
docker compose -f deploy/docker-compose.dev.yml up -d postgres
# Stop the local Postgres.
db-down:
docker compose -f deploy/docker-compose.dev.yml down
# Bring up the local OCI registry on http://localhost:5001.
# Anonymous pull/push, plaintext HTTP. Engram's OCI client only
# allows HTTP for loopback hosts so this is safe-by-construction.
registry-up:
docker compose -f deploy/docker-compose.dev.yml up -d registry
registry-down:
docker compose -f deploy/docker-compose.dev.yml stop registry
# Bring up Jaeger for ADR 0019 cold-boot tracing. UI at
# http://localhost:16686; OTLP/gRPC collector on :4317. Export
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 to make the
# binaries emit spans (`just dev` sets it for you). Works the same on
# macOS and the Linux dev-vm.
trace-up:
docker compose -f deploy/docker-compose.dev.yml up -d jaeger
@echo "Jaeger UI → http://localhost:16686 (OTLP/gRPC on :4317)"
trace-down:
docker compose -f deploy/docker-compose.dev.yml stop jaeger
# Generate a 32-byte master key (KEK) for envelope-encrypted
# registry credentials and write it into the SHARED `.env` — the primary
# checkout's, discovered via `git rev-parse --git-common-dir`. All git
# worktrees of one clone share it, because the dev Postgres/GCS are
# machine-global: data sealed under one KEK must unseal from every
# worktree. In the primary checkout the shared file IS `./.env`, so
# behavior there is unchanged. Idempotent. A worktree-local `.env` keeps
# working for overrides (backend, kernel paths, ...) but may never carry
# a KEK — bootstrap strips one (adopting it into the shared file when
# the shared file lacks a KEK; discarding it loudly otherwise, since
# sealed state lives under the shared KEK).
bootstrap:
#!/usr/bin/env bash
set -euo pipefail
shared="$(cd "$(git rev-parse --git-common-dir)/.." && pwd)"
env_file="$shared/.env"
[ -f "$env_file" ] || touch "$env_file"
in_worktree=0
[ "$shared" != "$(pwd)" ] && in_worktree=1
local_kek=""
if [ "$in_worktree" = 1 ] && [ -f .env ]; then
local_kek="$(grep '^ENGRAM_KEK_MASTER_KEY=' .env | head -1 || true)"
fi
if grep -q '^ENGRAM_KEK_MASTER_KEY=' "$env_file"; then
echo "ENGRAM_KEK_MASTER_KEY already present in $env_file — leaving as-is"
elif [ -n "$local_kek" ]; then
# First shared-aware run of a clone whose only KEK lives in a
# worktree: adopt it rather than minting a competing one.
printf '%s\n' "$local_kek" >> "$env_file"
echo "adopted the worktree's ENGRAM_KEK_MASTER_KEY into $env_file"
else
if command -v openssl >/dev/null 2>&1; then
KEY=$(openssl rand -base64 32)
else
KEY=$(head -c 32 /dev/urandom | base64)
fi
printf 'ENGRAM_KEK_MASTER_KEY=%s\n' "$KEY" >> "$env_file"
echo "wrote ENGRAM_KEK_MASTER_KEY to $env_file (32 random bytes, base64)"
fi
if [ -n "$local_kek" ]; then
shared_kek="$(grep '^ENGRAM_KEK_MASTER_KEY=' "$env_file" | head -1)"
if [ "$local_kek" != "$shared_kek" ]; then
echo "WARNING: worktree .env carried a DIFFERENT KEK than $env_file — discarding it (sealed dev data lives under the shared KEK)" >&2
fi
grep -v '^ENGRAM_KEK_MASTER_KEY=' .env > .env.tmp && mv .env.tmp .env
echo "stripped ENGRAM_KEK_MASTER_KEY from the worktree .env (the shared file owns it)"
fi
# Point this worktree's `var/shared` (content-addressed bundle store +
# stamp) and `var/bundles` (unpacked Process-backend trees) at the
# primary checkout's, so every worktree reuses the same baked bundles
# and build cache instead of re-baking from scratch. A no-op in the
# primary checkout. Existing real dirs are adopted: content-addressed
# files merge in (identical names are identical bytes), then the dir is
# replaced by a symlink. Safe concurrently — only one tilt stack can run
# at a time (machine-global compose ports).
dev-link-shared:
#!/usr/bin/env bash
set -euo pipefail
shared="$(cd "$(git rev-parse --git-common-dir)/.." && pwd)"
if [ "$shared" = "$(pwd)" ]; then
exit 0
fi
mkdir -p var
for d in shared bundles; do
tgt="$shared/var/$d"
loc="var/$d"
mkdir -p "$tgt"
if [ -L "$loc" ]; then
if [ "$(readlink "$loc")" != "$tgt" ]; then
rm "$loc"
ln -s "$tgt" "$loc"
fi
continue
fi
if [ -d "$loc" ]; then
# Adopt: merge content-addressed artifacts + cache fingerprints,
# keep the shared copy on any name collision, drop the rest
# (everything under var/ is regenerable).
shopt -s nullglob dotglob
for f in "$loc"/*; do
base="$(basename "$f")"
if [ "$base" = ".fingerprints" ] && [ -d "$f" ]; then
mkdir -p "$tgt/.fingerprints"
for fp in "$f"/*; do
mv -n "$fp" "$tgt/.fingerprints/" 2>/dev/null || true
done
elif [ -f "$f" ]; then
mv -n "$f" "$tgt/" 2>/dev/null || true
fi
done
shopt -u nullglob dotglob
rm -rf "$loc"
echo "adopted $loc into $tgt"
fi
ln -s "$tgt" "$loc"
echo "linked $loc -> $tgt"
done
# psql into the dev Postgres.
psql:
docker compose -f deploy/docker-compose.dev.yml exec postgres \
psql -U engram -d engram
# Apply migrations (sqlx-cli not required — coordinator runs them on
# startup, but this is useful for ad-hoc psql work).
migrate:
docker compose -f deploy/docker-compose.dev.yml exec -T postgres \
psql -U engram -d engram < deploy/migrations/0001_initial.sql
# Create + migrate the orchestrator DB (ADR 0051). The Tiltfile's
# `orchestrator-migrate` resource does this on `tilt up`; this recipe is
# the same steps for ad-hoc use (e.g. after a `just db-reset`).
migrate-orchestrator:
cd orchestrator && bun install --silent && \
(PGPASSWORD=engram createdb -h localhost -p 5435 -U engram engram_orchestrator 2>/dev/null || true) && \
ORCHESTRATOR_DATABASE_URL=postgres://engram:engram@localhost:5435/engram_orchestrator \
bun x drizzle-kit migrate
# Provision an orchestrator login (ADR 0051 better-auth). Prompts for
# email / admin? / password, then creates the user through the running
# orchestrator's sign-up endpoint (so the password is hashed exactly like
# a real sign-up) and, if you asked for admin, promotes the role via SQL.
# Assumes the stack is already up (`just dev`) — it talks to the live
# orchestrator on :8787 and the postgres container. Idempotent: an
# already-existing email is tolerated and (if admin) still gets promoted.
orchestrator-user:
#!/usr/bin/env bash
set -euo pipefail
ORIGIN="http://localhost:5173"
PORT="${ORCHESTRATOR_PORT:-8787}"
URL="http://127.0.0.1:${PORT}/api/auth/sign-up/email"
read -rp "Email: " EMAIL
[[ -n "$EMAIL" ]] || { echo "email is required" >&2; exit 1; }
read -rp "Admin? [y/N]: " ADMIN_ANS
read -rsp "Password (min 8 chars): " PASSWORD; echo
[[ ${#PASSWORD} -ge 8 ]] || { echo "password must be at least 8 characters" >&2; exit 1; }
NAME="${EMAIL%@*}"
echo "→ creating ${EMAIL} via ${URL}"
# Password goes through jq's env (not argv) and reaches curl over stdin,
# so it never lands in a process arg list.
BODY="$(PW="$PASSWORD" jq -n --arg e "$EMAIL" --arg n "$NAME" \
'{email:$e, password:env.PW, name:$n}')"
CODE="$(printf '%s' "$BODY" | curl -sS -o /tmp/orchestrator-user.out -w '%{http_code}' \
-X POST "$URL" -H 'content-type: application/json' -H "origin: ${ORIGIN}" --data @-)"
if [[ "$CODE" == "200" ]]; then
echo "✓ user created"
elif grep -qi 'already' /tmp/orchestrator-user.out; then
echo "• user already exists — continuing"
else
echo "✗ sign-up failed (HTTP ${CODE}):" >&2
cat /tmp/orchestrator-user.out >&2; echo >&2
echo " (is the orchestrator running? \`just dev\`)" >&2
exit 1
fi
rm -f /tmp/orchestrator-user.out
if [[ "$ADMIN_ANS" =~ ^[Yy] ]]; then
EMAIL_SQL="${EMAIL//\'/\'\'}"
echo "→ promoting ${EMAIL} to admin"
docker compose -f deploy/docker-compose.dev.yml exec -T postgres \
psql -U engram -d engram_orchestrator \
-c "UPDATE \"user\" SET role='admin' WHERE email='${EMAIL_SQL}'"
echo "✓ role set to admin — sign out and back in to pick it up"
fi
docker compose -f deploy/docker-compose.dev.yml exec -T postgres \
psql -U engram -d engram_orchestrator \
-c "SELECT email, coalesce(role,'user') AS role FROM \"user\" WHERE email='${EMAIL//\'/\'\'}'"
# Drop the dev DB volume (destructive). Use when migrations diverge.
db-reset:
docker compose -f deploy/docker-compose.dev.yml down -v
just db-up
# ------------------------------------------------------------------
# `just dev` — the whole stack, one command, every host (ADR 0024).
#
# Runs `tilt up`: postgres + OCI registry + fake-gcs + jaeger
# (docker-compose), the KEK one-shot, the coordinator, a host-agent
# (when the host has real virt), and the web SPA. All processes stream
# into Tilt's UI at http://localhost:10350.
#
# No backend flag, no per-arch recipe. `deploy/dev/detect-backend.sh`
# picks the backend from the host — /dev/kvm → Firecracker (split),
# macOS+arm64 → VZ (split), else → ProcessBackend (in-process mode=all).
# The binaries receive a concrete ENGRAM_SANDBOX_BACKEND; the switch
# never reaches this file.
#
# Prereqs (one-time): `just pull-kernel` (fetches the right kernel for
# this host; a no-op on the process backend). Tilt comes from the nix
# devShell (`nix develop`) or `brew install tilt-dev/tap/tilt`.
# ------------------------------------------------------------------
dev:
tilt up
# Bring everything down: stop the Tilt-managed processes + the
# docker-compose services, leave volumes intact.
dev-down:
tilt down
# ADR 0082: like `just dev`, but the host-agent (+ its Firecracker stack)
# runs INSIDE a dedicated Colima VM instead of as a Mac-local process —
# the only way to exercise the FC-only surfaces (NBD, UFFD, netns egress,
# squashfs patch-drives) on Apple Silicon. Coordinator/orchestrator/web/
# compose stay on the Mac exactly as in plain `just dev`; only the
# Tiltfile's host-agent resource moves. The env var is the real switch —
# the Tiltfile reads ENGRAM_FC_COLIMA_PROFILE directly — this recipe is
# sugar so you don't have to remember its name. First-time setup:
# `just fc-colima-provision [profile]`.
dev-fc profile='fc-dev' mac_docker_context='colima':
#!/usr/bin/env bash
set -euo pipefail
# ADR 0082: only the host-agent + FC stack run in the Colima VM (as a raw
# `colima ssh` process, NOT a container); the docker-compose deps stay on the
# Mac. `colima start` persistently repoints the docker CLI at the VM daemon
# (writes currentContext=colima-<profile> to ~/.docker/config.json), so a
# plain `tilt up` makes Tilt's docker_compose() deploy the deps INTO the VM —
# where the VM's localhost→Mac DNAT (engram-dev-fwd) routes registry/GCS
# traffic away from them and the Mac-side stack can't reach them. Pin
# DOCKER_HOST to the Mac docker for the tilt process ONLY (no global-context
# mutation — other shells keep whatever colima set). Mac context defaults to
# `colima` (the default-profile daemon, per ADR 0082 "default Colima docker
# daemon untouched"); for Docker Desktop: `just dev-fc {{profile}} desktop-linux`.
mac_host="$(docker context inspect '{{mac_docker_context}}' 2>/dev/null \
| python3 -c 'import sys,json; print(json.load(sys.stdin)[0]["Endpoints"]["docker"]["Host"])' 2>/dev/null || true)"
if [ -z "$mac_host" ]; then
echo "dev-fc: docker context '{{mac_docker_context}}' not found or has no docker endpoint. Available:" >&2
docker context ls >&2
echo "Pass one explicitly, e.g.: just dev-fc {{profile}} desktop-linux" >&2
exit 1
fi
echo "dev-fc: compose deps -> Mac docker '{{mac_docker_context}}' ($mac_host); host-agent -> colima VM '{{profile}}'"
ENGRAM_FC_COLIMA_PROFILE={{profile}} DOCKER_HOST="$mac_host" tilt up
# ADR 0082: create/update the named Colima VM (aarch64 Ubuntu, nested
# virt, /dev/kvm, Firecracker + the aarch64 guest kernel, NBD/UFFD host
# prep, the localhost→Mac DNAT unit engram-dev-fwd) — everything `dev-fc` needs
# before its first run. Idempotent; safe to re-run after a Colima
# upgrade or a provisioning-script change.
fc-colima-provision profile='fc-dev':
bash deploy/dev/fc-colima-provision.sh {{profile}}
# Reclaim all dev sandbox disk — the dev analog of the production lifecycle
# GC. Two stages:
#
# 1. Reap every session the coordinator tracks via the production
# DeleteSession path (terminate -> destroy bound sandbox). There is no
# fleet-wide reap RPC — the prod idle detector evicts one session at a
# time (ADR 0034) — so this loops `session delete` over `session list`.
# Idempotent: an already-terminal session deletes as a no-op.
# 2. Sweep orphaned local sandbox rootfs files. The host-agent teardown
# reconciler (ADR 0050 E) only reaps sandboxes it still TRACKS; rootfs
# files leaked by a killed / branch-switched host-agent are invisible to
# it and pile up (hundreds of MiB each). This removes any
# `<sandbox>.rootfs.ext4` (+ its vsock sockets) NOT owned by a session
# that is still live — the live set is re-read AFTER stage 1, so a
# session created concurrently is never swept.
# 3. Prune stale RO skill bundles in var/shared: any `<sha>.{erofs,squashfs}`
# NOT referenced by the live `current.json` stamp. Old generations pile up
# on every re-bundle, and a VZ→FC backend switch strands the ENTIRE .erofs
# set (VZ stages erofs; FC stages squashfs and can't mount erofs) — that
# alone was ~9 GiB. When ENGRAM_FC_COLIMA_PROFILE is set (ADR 0082) the
# same prune runs inside the VM's /opt/engram-dev/shared, where the synced
# bundles actually consume the small VM disk.
# 4. (ADR 0082, fc-colima only) Sweep orphaned base-snapshot dirs in the VM:
# /opt/engram-dev/var/sandboxes/snapshots/<id> with no live coordinator DB
# row. The snapshot GC works off DB rows, so a dir left by a hard DB delete
# or a FAILED capture is never reclaimed — and each holds a GiB-sized
# memory dump that fills the small VM disk. Live ids come from Postgres.
#
# Checkpoints (snapshots) are otherwise reclaimed by the coordinator's
# snapshot/chunk GC once their owning sessions are gone; the warm-pool base
# snapshot an enabled image clones from is preserved (re-captured on re-enable).
#
# Use it to reclaim disk or get a clean slate before a re-bake. Drives the
# LOCAL orchestrator (:8787) via the `engrams` CLI (bun, cli/) with
# ENGRAMS_API_KEY auth; the stack must be up.
reap-sessions profile='' mac_docker_context='colima':
#!/usr/bin/env bash
set -euo pipefail
# The `engrams` CLI drives the orchestrator (the coordinator is internal).
# The URL is HARDCODED to the local dev stack — the CLI's default host is
# prod, and a stray ENGRAMS_URL (or a stored prod login in hosts.json) must
# never point a reaper at it. Auth is ENGRAMS_API_KEY only: from the env,
# else var/dev-api-key (`just dev` seeds it via Tilt's dev-api-key
# resource); the env var also stops the CLI falling back to hosts.json.
export ENGRAMS_URL="http://localhost:8787"
export ENGRAMS_API_KEY="${ENGRAMS_API_KEY:-$(cat var/dev-api-key 2>/dev/null || true)}"
[ -n "$ENGRAMS_API_KEY" ] || { echo "no ENGRAMS_API_KEY and no var/dev-api-key — is the stack up (just dev)?" >&2; exit 1; }
# fc-colima profile for the VM-side stages (3 bundles, 4 snapshots). The
# `{{profile}}` param wins; else fall back to ENGRAM_FC_COLIMA_PROFILE. Unlike
# `just dev-fc`, a bare `just reap-sessions` has NO env var set (dev-fc sets it
# inline for tilt only), so the VM stages used to silently skip — pass the
# profile explicitly: `just reap-sessions fc-dev`.
fc_profile="{{profile}}"; [ -z "$fc_profile" ] && fc_profile="${ENGRAM_FC_COLIMA_PROFILE:-}"
sandboxes_dir="${ENGRAM_HOST_SANDBOX_DIR:-var/host-sandboxes}"
# Disk usage of the sandbox dir in KiB (0 if it doesn't exist yet). `du -k`
# is portable across macOS/Linux and reports actual allocated blocks, so
# the before/after diff is the real disk reclaimed (sparse-aware). Never
# fails the recipe under `set -e` — a missing dir just reports 0.
dir_kb() {
local kb=0
[ -d "$1" ] && kb="$(du -sk "$1" 2>/dev/null | awk '{print $1}')" || true
echo "${kb:-0}"
}
# KiB -> human-readable, matching `du -h` style.
human_kb() {
awk -v kb="${1:-0}" 'BEGIN{
if (kb >= 1048576) printf "%.2f GB", kb/1048576;
else if (kb >= 1024) printf "%.1f MB", kb/1024;
else printf "%d KB", kb;
}'
}
echo "==> starting reaper"
(cd cli && bun install --silent)
cli() { bun cli/src/main.ts "$@"; }
before_kb="$(dir_kb "$sandboxes_dir")"; before_kb="${before_kb:-0}"
# 1. Reap every live session via the production DeleteSession path.
echo "==> loading sessions from the orchestrator"
ids="$(cli --json session list \
| python3 -c 'import sys,json;print("\n".join(s["id"] for s in json.load(sys.stdin)["sessions"]))')"
total=0; for id in $ids; do total=$((total+1)); done
echo "found $total live session(s)"
count=0
for id in $ids; do
printf '==> reaping %s ... ' "$id"
if cli session delete "$id" >/dev/null 2>&1; then echo deleted; count=$((count+1)); else echo "(skipped)"; fi
done
echo "reaped $count live session(s)."
# 2. Sweep orphaned rootfs files no still-live session owns.
echo "==> sweeping orphaned sandbox rootfs files"
live=" $(cli --json session list \
| python3 -c 'import sys,json;print(" ".join(s["sandbox_id"] for s in json.load(sys.stdin)["sessions"] if s.get("sandbox_id")))') "
shopt -s nullglob
swept=0
for f in "$sandboxes_dir"/*.rootfs.ext4; do
sb="$(basename "$f")"; sb="${sb%.rootfs.ext4}"
case "$live" in *" $sb "*) continue ;; esac
rm -f "$f" "$sandboxes_dir/$sb".vsock_*
swept=$((swept+1))
done
echo "swept $swept orphaned sandbox rootfs file(s)."
# 3. Prune stale RO skill bundles (var/shared): <sha>.{erofs,squashfs} not
# referenced by current.json. Keeps the download cache (.cache) and the
# hidden .*.build.* / .*.stage temp entries (dotfiles don't match the
# non-dot globs below). No current.json -> nothing is "live", so skip
# rather than nuke the lot.
prune_bundles() { # prune_bundles <dir> (runs on the Mac; var/shared)
local d="$1" keep sha f pruned=0
[ -f "$d/current.json" ] || { echo " ($d: no current.json — skip)"; return 0; }
keep="$(grep -oE '[0-9a-f]{64}' "$d/current.json" | sort -u)"
shopt -s nullglob
for f in "$d"/*.erofs "$d"/*.squashfs; do
sha="$(basename "$f")"; sha="${sha%.*}"
printf '%s\n' "$keep" | grep -qx "$sha" && continue
rm -f "$f"; pruned=$((pruned+1))
done
echo " pruned $pruned stale bundle(s) from $d"
}
echo "==> pruning stale skill bundles (var/shared)"
before_shared_kb="$(dir_kb var/shared)"
prune_bundles var/shared
after_shared_kb="$(dir_kb var/shared)"
shared_freed=$(( before_shared_kb > after_shared_kb ? before_shared_kb - after_shared_kb : 0 ))
[ "$shared_freed" -gt 0 ] && echo " reclaimed $(human_kb "$shared_freed") from var/shared."
# ADR 0082: the synced bundles live on the small fc-colima VM disk — prune
# the VM's /opt/engram-dev/shared the same way. The prune runs from a
# helper piped to `sudo bash -s` over stdin (NOT a heredoc: an unindented
# heredoc terminator would break `just`'s recipe indentation, and colima
# ssh doesn't run remote args through a shell anyway).
if [ -n "$fc_profile" ] && command -v colima >/dev/null 2>&1; then
echo "==> pruning stale bundles inside the fc-colima VM ($fc_profile)"
colima ssh --profile "$fc_profile" -- sudo bash -s < deploy/dev/reap-vm-bundles.sh
# 4. Sweep orphaned base-snapshot dirs in the VM: the coordinator's
# snapshot GC works off DB rows, so a snapshot dir left by a hard DB
# delete or a failed capture is never reclaimed — and each carries a
# GiB-sized memory dump that fills the small VM disk. Gather the live
# snapshot-id set from Postgres (via the always-up dev container) and
# pass it (space-joined) to the VM sweeper; anything else is orphaned.
echo "==> sweeping orphaned base-snapshot dirs in the VM"
# The compose deps (Postgres) live on the MAC docker daemon, but
# `colima start --profile <p>` persistently repoints the docker CLI at
# the VM daemon (currentContext=colima-<p>). So a bare `docker compose
# exec postgres` here hits the VM — which has no Postgres — the query
# fails, and (guarded in reap-vm-snapshots.sh) the sweep is refused
# rather than nuking every base snapshot. Pin the Mac docker context
# like `dev-fc` does. Default `colima`; for Docker Desktop:
# `just reap-sessions {{profile}} desktop-linux`.
mac_host="$(docker context inspect '{{mac_docker_context}}' 2>/dev/null \
| python3 -c 'import sys,json; print(json.load(sys.stdin)[0]["Endpoints"]["docker"]["Host"])' 2>/dev/null || true)"
if [ -z "$mac_host" ]; then
echo " docker context '{{mac_docker_context}}' not found or has no endpoint. Available:" >&2
docker context ls >&2
echo " Pass the Mac context, e.g.: just reap-sessions $fc_profile desktop-linux" >&2
exit 1
fi
# No `2>/dev/null` on the query: a failure must be VISIBLE (paired with
# the empty-set guard in the sweeper) — a silently-swallowed error used
# to abort with a cryptic exit code, or worse, feed an empty live set.
live_snaps="$(DOCKER_HOST="$mac_host" docker compose -f deploy/docker-compose.dev.yml exec -T postgres \
psql -U engram -d engram -tAc \
"select id::text from snapshots union select base_snapshot_id::text from enabled_images where base_snapshot_id is not null" \
| tr '\n' ' ' | tr -s ' ')"
colima ssh --profile "$fc_profile" -- sudo bash -s -- "$live_snaps" < deploy/dev/reap-vm-snapshots.sh
elif command -v colima >/dev/null 2>&1; then
echo "==> SKIPPING fc-colima VM stages (bundles + orphaned snapshots) — no profile."
echo " The VM holds the GiB-sized base-snapshot dumps; pass the profile to sweep them:"
echo " just reap-sessions fc-dev"
fi
after_kb="$(dir_kb "$sandboxes_dir")"; after_kb="${after_kb:-0}"
reclaimed_kb=$(( before_kb > after_kb ? before_kb - after_kb : 0 ))
if [ "$reclaimed_kb" -gt 0 ]; then
echo "reclaimed $(human_kb "$reclaimed_kb") of dev sandbox disk."
else
echo "no dev sandbox disk reclaimed (already clean)."
fi
echo "reap-sessions: done. Checkpoints GC in the background once their sessions are gone."
# Clear stale bundle generations / snapshot blobs / chunks from BLOB STORAGE
# via the coordinator's own GC RPCs (ADR 0035 §5 / ADR 0028 addendum / ADR
# 0016 Phase C). The pin set is Postgres truth — anything referenced by a
# live snapshot or the mount/harness catalogs is never touched. Two steps:
# 1. Deregister DEAD host rows. A dead leftover host-agent (e.g. the
# fc-colima VM's after `just dev-fc`, killed or VM stopped) otherwise
# keeps feeding the fleet bundle catalog — VZ sessions then resolve the
# FC host's SQUASHFS generations, which the VZ guest kernel can't mount
# ("cannot find valid erofs superblock" → the agentd-bundle panic) —
# and keeps winning session placement. DeleteHost refuses (FAILED_
# PRECONDITION) while sessions are still bound, so this can't drop a
# row out from under live work; `ready`/`draining` hosts are never
# touched (the dead-host detector owns that transition).
# 2. Run the three sweeps with a zero grace window so unpinned blobs
# delete in the same pass — the dev "clear it now" posture (prod
# trusts the background loop's 24h grace).
# Blob generations are pinned by snapshots: run `just reap-sessions` FIRST if
# old sessions still reference the generations you want gone. Local staged-
# file pruning (var/shared on the Mac, /opt/engram-dev/shared in the fc VM)
# also lives in reap-sessions. The stack must be up — the `engrams` CLI
# drives the orchestrator (:8787), which forwards FleetService (admin-gated).
#
# Deregister dead host rows + GC stale bundle/snapshot/chunk blobs (grace 0).
reap-bundles:
#!/usr/bin/env bash
set -euo pipefail
# Hardcoded local orchestrator + ENGRAMS_API_KEY auth — same rationale as
# reap-sessions: never let env/hosts.json aim a GC pass at prod.
export ENGRAMS_URL="http://localhost:8787"
export ENGRAMS_API_KEY="${ENGRAMS_API_KEY:-$(cat var/dev-api-key 2>/dev/null || true)}"
[ -n "$ENGRAMS_API_KEY" ] || { echo "no ENGRAMS_API_KEY and no var/dev-api-key — is the stack up (just dev)?" >&2; exit 1; }
(cd cli && bun install --silent)
cli() { bun cli/src/main.ts "$@"; }
echo "==> deregistering dead host rows"
dead="$(cli --json host list \
| python3 -c 'import sys,json;print("\n".join(h["id"] for h in json.load(sys.stdin)["hosts"] if h["status"]=="dead"))')"
if [ -z "$dead" ]; then
echo " (no dead hosts)"
else
for id in $dead; do
printf ' deleting dead host %s ... ' "$id"
if cli host delete "$id" >/dev/null 2>&1; then
echo deleted
else
echo "SKIPPED (sessions still bound? reap those first: just reap-sessions)"
fi
done
fi
echo "==> GC sweeps (apply, grace 0)"
cli admin gc --apply --grace-secs 0
echo "reap-bundles: done."
# Params forward to reap-sessions (fc-colima profile + Mac docker context for
# the VM-side stages): `just reap-all fc-dev`. Order is load-bearing: deleting
# sessions first drops their snapshots' pins, so the bundle/blob GC pass that
# follows can actually delete the generations they were holding.
#
# Full dev-stack reclaim: reap-sessions, then reap-bundles.
reap-all profile='' mac_docker_context='colima': (reap-sessions profile mac_docker_context) reap-bundles
# Bake the canonical `demo` image (deploy/demo/) and push it to the local OCI
# registry as demo:warm-1 (localhost:5001, the registry `just dev` runs). Arch +
# transport are detected; no per-backend recipe. ADR 0062: the image carries NO
# harness — the built-in `claude` harness is a per-session selection staged on
# the fleet, not baked in. Requires `just bootstrap` (KEK) and a running local
# registry (it's up under `just dev`, or `just registry-up`). This only PUSHES;
# use `just bake-demo-enable` to also make it live on the coord.
bake-demo:
bash deploy/dev/bake-demo.sh
# Bake the demo image AND make it live on the running coord in one step — the
# inner-loop cycle after editing deploy/demo/ (the Dockerfile). `bake-demo` only pushes; this then registers it and BLOCKS until the
# base-snapshot capture (the enable job) reports ready, exiting non-zero if it
# fails. Because `warm-1` is a fixed tag, a re-bake moves it to a NEW digest: if
# the image is already enabled we `image refresh` (re-fetch the moved tag + force
# a re-capture), since `image enable` is idempotent on an already-enabled URI and
# would keep serving the STALE base snapshot. Requires the stack up
# (`just dev` / `just dev-fc`).
bake-demo-enable:
#!/usr/bin/env bash
set -euo pipefail
bash deploy/dev/bake-demo.sh
# The `engrams` CLI drives the orchestrator; `just dev` seeds the admin
# credential (Tilt's dev-api-key resource → var/dev-api-key).
export ENGRAMS_URL="${ENGRAMS_URL:-http://localhost:8787}"
export ENGRAMS_API_KEY="${ENGRAMS_API_KEY:-$(cat var/dev-api-key 2>/dev/null || true)}"
[ -n "$ENGRAMS_API_KEY" ] || { echo "no ENGRAMS_API_KEY and no var/dev-api-key — is the stack up (just dev)?" >&2; exit 1; }
(cd cli && bun install --silent)
cli() { bun cli/src/main.ts "$@"; }
uri=localhost:5001/demo:warm-1
if cli --json image list \
| python3 -c "import sys,json; sys.exit(0 if any(i.get('image_uri')=='$uri' for i in json.load(sys.stdin).get('images',[])) else 1)"; then
echo "==> $uri already enabled — refreshing (re-fetch moved tag + re-capture base snapshot)"
cli image refresh --uri "$uri" --recapture
else
echo "==> enabling $uri (captures base snapshot)"
cli image enable --uri "$uri" --name demo --vcpus 4
fi
# Fetch the kernel artifact this host's backend needs (VZ → Kata arm64
# kernel; Firecracker → FC test kernel+rootfs; process → nothing).
pull-kernel:
bash deploy/dev/pull-kernel.sh
# Stage the ADR 0027 RO bundles (skills, integrations-cli, browser, ide) as
# UNPACKED trees under var/bundles/ for the dev ProcessBackend, which symlinks
# them in instead of mounting a squashfs. `skills` is a plain copy;
# `integrations-cli`/`browser`/`ide` need Docker (glibc builds) and are
# best-effort — skip them and only skills get wired (no browser/IDE tooling in
# dev). Re-run after editing a skill. Depends on dev-link-shared so a fresh
# worktree stages into the shared store, never a divergent local dir.
bundles: dev-link-shared
deploy/bundles/skills/build.sh --stage var/bundles/skills
deploy/bundles/integrations-cli/build.sh --stage var/bundles/integrations-cli \
|| echo "integrations-cli bundle skipped (needs Docker) — dev sessions get no integration CLIs"
deploy/bundles/browser/build.sh --stage var/bundles/browser \
|| echo "browser bundle skipped (needs Docker) — dev sessions get no browser"
deploy/bundles/ide/build.sh --stage var/bundles/ide \
|| echo "ide bundle skipped (needs Docker) — dev sessions get no IDE"
# Stage the host-native built-in harnesses plus the core skills bundle for the
# Linux ProcessBackend. This is the inner `just dev` path in dev-engrams: no
# squashfs mount exists, so the backend symlinks these unpacked trees into each
# selected dyn slot. The script caches the large upstream CLI payloads.
bundles-process: dev-link-shared
bash deploy/dev/stage-process-bundles.sh
# ADR 0035/0055: build + stage the squashfs bundles CONTENT-ADDRESSED
# (<sha256>.squashfs + current.json stamp) under var/shared/, the
# dev mirror of the FC-host image's /var/lib/engram/shared. Run the
# host-agent with ENGRAM_BUNDLE_DIR=$PWD/var/shared so FC dev sessions
# resolve/capture against it. Needs mksquashfs: on Linux that's
# `apt install squashfs-tools`; on macOS (ADR 0082's fc-colima dev mode —
# FC itself still only ever boots inside the Colima VM) run this from
# `nix develop`, which provides mksquashfs on darwin too. Re-run after
# editing a skill — the stamp repoints and new sessions pick the fresh
# generation up via the §3 swap.
#
# Unchanged bundles are NOT rebuilt: deploy/bundles/_cache.sh fingerprints each
# bundle's inputs and reuses the <sha>.squashfs those inputs produced last time
# (the Docker bundles each cost a container + apt-get + several downloads, paid
# on every Tilt trigger before this). Run with ENGRAM_BUNDLES_FORCE=1 to ignore
# the cache — the fingerprint covers tracked files and pinned versions, not the
# floating apt/base-image layers the Docker bundles pull. Depends on
# dev-link-shared so a fresh worktree builds into (and reuses) the shared
# content-addressed store, never a divergent local dir.
bundles-squashfs: dev-link-shared
#!/usr/bin/env bash
set -euo pipefail
mkdir -p var/shared
. deploy/bundles/_cache.sh
stamp="{"
sep=""
add_stamp() { stamp="$stamp$sep\"$1\": \"$2\""; sep=", "; }
# The host arch selects a different download in several build scripts
# (ttyd, code-server, the CLIs), so it is part of every fingerprint.
arch="arch=$(uname -m)"
# ADR 0055: `sentinel` rides every reserved dyn-* slot; skills/
# integrations-cli/browser/ide are catalog skills swapped in per session;
# guest-tools (ADR 0080 §D) carries the pinned static ttyd for the SHELL
# tab (reserved slot dyn_2). Files are content-keyed (<sha>.squashfs);
# the stamp maps logical name -> sha.
for name in sentinel skills integrations-cli browser ide guest-tools; do
# These bundles assemble their own tree, so their inputs are exactly
# the bundle dir + the shared packer.
fp="$(bundle_fingerprint "deploy/bundles/$name" deploy/bundles/_pack.sh "$arch")"
if sha="$(bundle_cache_get var/shared "$name" "$fp")"; then
echo "==> $name bundle unchanged — reusing $sha.squashfs"
add_stamp "$name" "$sha"
continue
fi
tmp="var/shared/.$name.build.squashfs"
if ! "deploy/bundles/$name/build.sh" "$tmp"; then
echo "$name bundle build failed; skipping (sessions degrade gracefully)" >&2
rm -f "$tmp"
continue
fi
sha="$(sha256sum "$tmp" | cut -d' ' -f1)"
mv "$tmp" "var/shared/$sha.squashfs"
bundle_cache_put var/shared "$name" "$fp" "$sha"
add_stamp "$name" "$sha"
done
# ADR 0062: the built-in `claude` harness rides the stamp like a skill (key
# `harness-claude`, mounted on dyn_0). Its tree — the engram-harness-claude
# entry binary + the pinned `claude` CLI + the committed harness.toml — is
# the ONE bundle not assembled by a per-bundle build.sh: CI hands it in
# pre-built via ENGRAM_HARNESS_CLAUDE_TREE (bake-harness-claude-artifact). A
# local dev stack has no such artifact, so when unset we build the tree HERE
# (mirroring bake-demo's cross-compile + the bundles-vz path), then pack it
# via harness-claude/build.sh. Without this the fleet stamp never carries
# `harness-claude` and `POST /sessions` 400s with "built-in harness `claude`
# squashfs (`harness-claude`) is not staged on any host yet". Requires the
# nix cross toolchain on PATH (this recipe runs under `nix develop`).
harness_tree="${ENGRAM_HARNESS_CLAUDE_TREE:-}"
if [ -z "$harness_tree" ]; then
# PINNED — keep in lockstep with ci.yml's bake-harness-claude-artifact
# and bake-images.yml. Questions and plan approval ride the injected
# MCP tools (ask_user_question / exit_plan_mode) since the CLI removed
# the AskUserQuestion + ExitPlanMode built-ins from headless mode
# (cortexapps/engrams#431). Bump deliberately and re-verify BOTH
# spines: the deferred-tool spine (defer parks the turn, `--resume`
# re-fires id-stable, the MCP bridge serves the stash) and the
# steering behavior (`python3 scripts/claude-steer-probe.py` must
# report STEER — ADR 0052 2026-08-20 update).
CLAUDE_VERSION=2.1.285
case "$(uname -m)" in
arm64 | aarch64) htarget=aarch64-unknown-linux-musl; carch=linux-arm64 ;;
x86_64 | amd64) htarget=x86_64-unknown-linux-musl; carch=linux-x64 ;;
*) echo "harness-claude: unsupported arch $(uname -m); skipping" >&2; htarget="" ;;
esac
if [ -n "$htarget" ]; then
# Best-effort: a cross-build/download failure warns and skips so the
# stack still comes up (without the built-in claude).
tree="$PWD/var/shared/.harness-claude.stage"
cache="var/shared/.cache/claude-$CLAUDE_VERSION-$carch"
ok=1
cargo build --release --target "$htarget" -p engram-harness-claude || ok=0
if [ "$ok" = 1 ] && [ ! -x "$cache" ]; then
mkdir -p "$(dirname "$cache")"
curl -fsSL --retry 3 \
"https://downloads.claude.ai/claude-code-releases/$CLAUDE_VERSION/$carch/claude" \
-o "$cache" && chmod +x "$cache" || ok=0
fi
# The musl build rides alongside; the shim picks it on
# Alpine-class guests.
cachemusl="$cache-musl"
if [ "$ok" = 1 ] && [ ! -x "$cachemusl" ]; then
curl -fsSL --retry 3 \
"https://downloads.claude.ai/claude-code-releases/$CLAUDE_VERSION/$carch-musl/claude" \
-o "$cachemusl" && chmod +x "$cachemusl" || ok=0
fi
if [ "$ok" = 1 ]; then
rm -rf "$tree"; mkdir -p "$tree"
cp -p "target/$htarget/release/engram-harness-claude" "$tree/harness"
cp -p "$cache" "$tree/claude"
cp -p "$cachemusl" "$tree/claude-musl"
cp -p deploy/harness-claude/harness.toml "$tree/harness.toml"
harness_tree="$tree"
else
echo "harness-claude local build failed; skipping (dev stack boots without the built-in claude)" >&2
fi
fi
fi
if [ -n "$harness_tree" ]; then
[ -x "$harness_tree/harness" ] || {
echo "harness tree $harness_tree is missing an executable 'harness' entry binary" >&2
exit 1
}
# The tree IS the content here (build.sh only packs it), so fingerprint
# the staged tree rather than deploy/bundles/harness-claude. The cargo
# build above still runs — it is the only way to learn the binary's
# content — but an unchanged tree skips the pack.
fp="$(bundle_fingerprint "$harness_tree" deploy/bundles/harness-claude deploy/bundles/_pack.sh)"
if sha="$(bundle_cache_get var/shared harness-claude "$fp")"; then
echo "==> harness-claude bundle unchanged — reusing $sha.squashfs"
else
tmp="var/shared/.harness-claude.build.squashfs"
deploy/bundles/harness-claude/build.sh "$harness_tree" "$tmp"
sha="$(sha256sum "$tmp" | cut -d' ' -f1)"
mv "$tmp" "var/shared/$sha.squashfs"
bundle_cache_put var/shared harness-claude "$fp" "$sha"
fi
add_stamp harness-claude "$sha"
# Drop the locally-built stage tree (keep the download cache); an
# externally-provided ENGRAM_HARNESS_CLAUDE_TREE is left untouched.
[ "$harness_tree" = "$PWD/var/shared/.harness-claude.stage" ] && rm -rf "$harness_tree"
fi
# Built-in Codex tree. CI supplies the already-staged tree; local dev
# cross-builds the wrapper and downloads the checksum-verified official
# Linux package for the guest architecture.
codex_tree="${ENGRAM_HARNESS_CODEX_TREE:-}"
if [ -z "$codex_tree" ]; then
case "$(uname -m)" in
arm64 | aarch64) codex_arch=aarch64; codex_target=aarch64-unknown-linux-musl ;;
x86_64 | amd64) codex_arch=x86_64; codex_target=x86_64-unknown-linux-musl ;;
*) codex_arch="" ;;
esac
if [ -n "$codex_arch" ]; then
tree="$PWD/var/shared/.harness-codex.stage"
if cargo build --release --target "$codex_target" -p engram-harness-codex \
&& deploy/harness-codex/stage.sh "$codex_arch" \
"target/$codex_target/release/engram-harness-codex" "$tree"; then
codex_tree="$tree"
else
echo "harness-codex local build failed; skipping" >&2
fi
fi
fi
if [ -n "$codex_tree" ]; then
[ -x "$codex_tree/harness" ] && [ -x "$codex_tree/codex" ] || {
echo "Codex harness tree must contain executable harness + codex" >&2
exit 1
}
fp="$(bundle_fingerprint "$codex_tree" deploy/bundles/harness-codex deploy/bundles/_pack.sh)"
if sha="$(bundle_cache_get var/shared harness-codex "$fp")"; then
echo "==> harness-codex bundle unchanged — reusing $sha.squashfs"
else
tmp="var/shared/.harness-codex.build.squashfs"
deploy/bundles/harness-codex/build.sh "$codex_tree" "$tmp"
sha="$(sha256sum "$tmp" | cut -d' ' -f1)"
mv "$tmp" "var/shared/$sha.squashfs"
bundle_cache_put var/shared harness-codex "$fp" "$sha"
fi
add_stamp harness-codex "$sha"
[ "$codex_tree" = "$PWD/var/shared/.harness-codex.stage" ] && rm -rf "$codex_tree"
fi
# ADR 0080: the agentd bundle (reserved slot dyn_1) — MANDATORY, not
# best-effort: without it no guest can boot (the stage-1 init execs
# agentd out of this mount) and no capture can run. CI/e2e hands the
# built musl binary in via ENGRAM_AGENTD_BIN; a local run cross-builds
# it for the host arch (FC guests match the host).
agentd_bin="${ENGRAM_AGENTD_BIN:-}"
if [ -z "$agentd_bin" ]; then
case "$(uname -m)" in
arm64 | aarch64) atarget=aarch64-unknown-linux-musl ;;
*) atarget=x86_64-unknown-linux-musl ;;
esac
cargo build --release --target "$atarget" -p engram-agentd
agentd_bin="target/$atarget/release/engram-agentd"
fi
fp="$(bundle_fingerprint "$agentd_bin" deploy/bundles/agentd deploy/bundles/_pack.sh)"
if sha="$(bundle_cache_get var/shared agentd "$fp")"; then
echo "==> agentd bundle unchanged — reusing $sha.squashfs"
else
tmp="var/shared/.agentd.build.squashfs"
deploy/bundles/agentd/build.sh "$agentd_bin" "$tmp"
sha="$(sha256sum "$tmp" | cut -d' ' -f1)"
mv "$tmp" "var/shared/$sha.squashfs"
bundle_cache_put var/shared agentd "$fp" "$sha"
fi
add_stamp agentd "$sha"
echo "$stamp}" > var/shared/current.json
cat var/shared/current.json
# ------------------------------------------------------------------
# Smoke / e2e helpers — run against a stack brought up by `just dev`
# (ADR 0024 retired the standalone `integration-up.sh`; the prod-shape
# stack is now just `just dev`, on the dev-vm typically inside tmux).
# ------------------------------------------------------------------
# Smoke-test the full bake → enable → host-prefetch → session flow
# against the running stack. Times each step. Run after `just dev`.
integration-test:
bash deploy/dev/integration-test.sh
# ADR 0018 M4 e2e evacuation test. Run after
# `ENGRAM_INTEG_TWO_HOSTS=1 just dev` (which adds a second host-agent so
# coord sees a 2-host cluster). Creates a session, writes a deterministic
# disk canary, evacuates via the admin endpoint, asserts the session
# reaches Active on a different host with the disk contents preserved.
# The gold-standard "M4 actually works" check.
integration-evac-test:
bash deploy/dev/integration-evac-test.sh
# Persistent dev session — bakes + enables + creates a session and
# leaves it running so you can poke at it with curl, wscat, or the
# web UI. Idempotent: reuses an already-enabled image and an
# already-live session for this commit. Companion to
# integration-test (which always cleans up).
#
# Usage:
# just integration-session
# HARNESS=claude PROMPT='hi' just integration-session
integration-session:
bash deploy/dev/integration-session.sh
# ADR 0080: build a session image from a directory containing a
# Dockerfile and push it to the local OCI registry — a PLAIN
# `docker build && docker push` (the user contract). The coordinator
# materializes the rootfs host-side at enable time; nothing is chunked
# or injected locally. Tag defaults to `warm-<rfc3339>`; override TAG=...
#
# Usage:
# just bake cortex/api ./examples/api
# just bake cortex/api .
# TAG=warm-2 just bake cortex/api ./examples/api
#
# Then enable it (once, applying runtime config out-of-band):
# engram image enable --uri localhost:5001/<repo>:<tag> --config <toml>
# and reference it: `engram session create --image localhost:5001/<repo>:<tag>`.
bake repo dir='.':
@set -e; \
if [ "$(uname -s -m)" = "Darwin arm64" ]; then \
PLATFORM=linux/arm64; \
elif [ "$(uname -s -m)" = "Linux x86_64" ]; then \
PLATFORM=linux/amd64; \
elif [ "$(uname -s -m)" = "Linux aarch64" ]; then \
PLATFORM=linux/arm64; \
else \
echo "unsupported host: $(uname -s -m)" >&2; exit 1; \
fi; \
TAG="${TAG:-warm-$(date -u +%Y%m%dT%H%M%SZ)}"; \
URI="localhost:5001/{{repo}}:$TAG"; \
docker build --platform "$PLATFORM" -t "$URI" -f "{{dir}}/Dockerfile" "{{dir}}"; \
docker push "$URI"; \
echo ""; \
echo "✓ pushed $URI"; \
echo " enable it: engrams image enable --uri $URI --name <name> --vcpus <n>"; \
echo " then: engram session create --image $URI ..."
# ------------------------------------------------------------------
# Apple Silicon — Virtualization.framework codesigning + VZ tests.
#
# `just dev` (Tilt) is the way to run the stack; these recipes are the
# VZ-specific build helpers it and the test suite rely on. The
# standalone `dev-vz` / `dev-firecracker` runners and the per-arch
# `fc-bake-demo` / `vz-bake-demo` / `bake-harness` recipes were retired
# in ADR 0024 — `just dev` + `just bake-demo` + `just pull-kernel`
# cover every host now.
# ------------------------------------------------------------------
# Ad-hoc codesign the engram-sandbox-vz test binaries with the
# com.apple.security.virtualization entitlement. Without this, every
# VZ API call returns NSError "process doesn't have the
# com.apple.security.virtualization entitlement" — see the smoke test
# in crates/engram-sandbox-vz/src/vm.rs.
#
# The build MUST be `cargo nextest run --no-run` — the exact
# invocation shape `vz-test` runs with. A plain `cargo build -p a -p b
# --tests` resolves features differently (AGENTS.md: `-p X -p Y`
# invalidates the cache), so nextest would silently RECOMPILE fresh,
# unsigned test binaries after we signed the stale ones — the live VZ
# tests then die on the missing entitlement (ADR 0096).
#
# Idempotent: re-running on an already-signed binary is a no-op
# beyond a few ms of cycle. `vz-test` and the Tiltfile depend on it
# (the Tiltfile builds + signs `engram-host-agent` itself, atomically
# with its own build — not here).