Compare commits
2 Commits
feat/phase
...
deploy/pha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6240c1e8e | ||
|
|
8b3209fab5 |
@@ -1,68 +0,0 @@
|
||||
# Phase 7.4 — asset weight budget
|
||||
|
||||
Measured 2026-10-26 on `.13` with `stat -c%s` and `gzip -9`. Every number below
|
||||
is a measurement of a file that is in this repository, not an estimate.
|
||||
|
||||
## What is in the assets directory
|
||||
|
||||
| Group | Files | Raw | Gzip (`-9`) |
|
||||
|---|---|---|---|
|
||||
| Diagrams (`diagrams/*.svg`) | 8 | 51,729 B | 8,943 B |
|
||||
| Code exhibits (`code/*.as`, `.js`, `.php`, `.ts`, `.hpp`, `.nix`) | 6 | 8,161 B | 2,989 B |
|
||||
| Client wall data (`logos/clients.yaml`) | 1 | 3,912 B | 1,288 B |
|
||||
| **Served payload total** | **15** | **63,802 B** | **13,220 B** |
|
||||
| Manifests and READMEs (build-time only, never served) | 5 | 15,955 B | — |
|
||||
|
||||
## Per-asset
|
||||
|
||||
| File | Raw | Gzip |
|
||||
|---|---|---|
|
||||
| `diagrams/01-homelab-topology.svg` | 7,079 | 2,169 |
|
||||
| `diagrams/02-agent-architecture.svg` | 6,027 | 1,971 |
|
||||
| `diagrams/03-rag-pipeline.svg` | 7,010 | 2,043 |
|
||||
| `diagrams/04-edge-architecture.svg` | 6,896 | 2,163 |
|
||||
| `diagrams/05-distributed-audio.svg` | 6,833 | 1,997 |
|
||||
| `diagrams/06-media-pipeline.svg` | 5,694 | 1,718 |
|
||||
| `diagrams/07-cost-resource-engineering.svg` | 6,151 | 2,023 |
|
||||
| `diagrams/08-era-timeline.svg` | 6,039 | 1,930 |
|
||||
| `code/01-actionscript-class.as` | 958 | 495 |
|
||||
| `code/02-js-easing-core.js` | 1,848 | 584 |
|
||||
| `code/03-php-encode-op.php` | 2,295 | 765 |
|
||||
| `code/04-typescript-service-layer.ts` | 1,078 | 482 |
|
||||
| `code/05-embedded-cpp-interface.hpp` | 788 | 419 |
|
||||
| `code/06-nix-derivation.nix` | 1,194 | 724 |
|
||||
|
||||
Rasters: **zero.** There are no PNG, JPEG, WebP or font files in this phase.
|
||||
That is a design outcome, not an oversight — every diagram is text-based SVG and
|
||||
every code exhibit is text, so the "optimise every image" requirement has no
|
||||
image to optimise yet. Phase 7.4 screenshots are deferred (see below).
|
||||
|
||||
## The budget
|
||||
|
||||
| Rule | Limit | Worst case today |
|
||||
|---|---|---|
|
||||
| Any single diagram | ≤ 7.5 KB raw / ≤ 2.5 KB gzip | 7,079 B / 2,169 B |
|
||||
| Any single code exhibit | ≤ 3 KB raw / ≤ 1 KB gzip | 2,295 B / 765 B |
|
||||
| Diagrams on one page | ≤ 2 | 2 (14.1 KB raw) |
|
||||
| Asset payload per page, gzipped | ≤ 12 KB | 4.3 KB (two diagrams + one exhibit) |
|
||||
| Web fonts | 0 | 0 — system font stack only |
|
||||
| JS required by any asset | 0 | 0 — SVG carries its own palette |
|
||||
|
||||
Worst realistic page: an L2 page with two diagrams and one code exhibit is
|
||||
14,089 B raw, about **4.3 KB over the wire** after gzip and before the HTML, CSS
|
||||
and template overhead. The whole 15-file asset set is 13.2 KB gzipped, so even a
|
||||
page that loaded every one of them would stay inside a single small image's
|
||||
budget.
|
||||
|
||||
## What is not yet measured, and why
|
||||
|
||||
| Item | Status | Why |
|
||||
|---|---|---|
|
||||
| Full page weight (HTML + CSS + assets) | **not measured** | No page exists yet: Phase 2 owns the templates |
|
||||
| Lighthouse ≥ 95 / 95 | **not measured** | Needs a rendered page (Phase 2) served behind the edge (Phase 3). Nothing in this phase can stand in for it |
|
||||
| Screenshots and recordings (7.4) | **deferred** | The agent console is Phase 5 and the TUIOS fleet session has to be real work, not staged. Recording a console that does not exist would be a fabrication |
|
||||
| Home Assistant / Snapcast stills | **deferred** | Same reason: needs a live system and a judgement about whether a still adds what diagram 4 and diagram 5 already show |
|
||||
|
||||
These are recorded as open rather than asserted. DoD items 4 and 5 cannot be
|
||||
closed until Phase 2 and Phase 3 produce a page to measure, and the measurement
|
||||
must be taken by whoever holds that page.
|
||||
@@ -1,73 +0,0 @@
|
||||
# content/assets — authored exhibits
|
||||
|
||||
Everything the site shows that is not Markdown prose. Authored in Phase 7; wired
|
||||
into templates by Phase 2's builder.
|
||||
|
||||
```
|
||||
content/assets/
|
||||
├── BUDGET.md measured weights for every asset, and the budget
|
||||
├── VERIFY.md what was checked, with the output pasted in
|
||||
├── diagrams/ 8 SVGs, light and dark in one file each
|
||||
│ ├── README.md the visual language and the presentation contract
|
||||
│ └── manifest.yaml id, area, level, title, alt text per diagram
|
||||
├── code/ 6 verbatim excerpts, 212 lines, 5 eras
|
||||
│ ├── README.md the rules these follow
|
||||
│ └── manifest.yaml era, language, shows, rationale, honest note
|
||||
└── logos/
|
||||
└── clients.yaml the client wall: 45 entries, 44 distinct names
|
||||
```
|
||||
|
||||
## Three decisions worth knowing
|
||||
|
||||
**The client wall is type, not logos.** Forty-five names in one typeface at one
|
||||
size on an even grid. Nothing is an image, so nothing can break, and no client is
|
||||
visually ranked above another. Ten client marks do exist in Simple Icons; none is
|
||||
enabled, because using a company's mark on a public portfolio is a permission
|
||||
question for Sam, not a design choice for an agent.
|
||||
|
||||
**Diagrams carry both themes in one file.** Not two files that can drift: one
|
||||
`<style>` block with the light palette and the same selectors repeated under
|
||||
`prefers-color-scheme: dark`. The eight files therefore share one palette by
|
||||
construction.
|
||||
|
||||
**Code excerpts are copied, never retyped.** Including the defects. An excerpt
|
||||
that has been tidied is a claim, not evidence — and this site's whole argument is
|
||||
that the claims are checkable.
|
||||
|
||||
## What is wired and what is not
|
||||
|
||||
| DoD item | State | Evidence |
|
||||
|---|---|---|
|
||||
| Client wall reads as deliberate, no broken images, no placeholders | **assets done, markup not** | `logos/clients.yaml` — 45 entries, zero image references by design. The grid is CSS and belongs to Builder |
|
||||
| Eight diagrams, both themes, readable at 1× and on mobile | **done** | `VERIFY.md` §1–§3: 0 geometry findings, 8/8 resolve two palettes, rendered widths measured |
|
||||
| Six code exhibits with a two-sentence rationale each | **done** | `code/manifest.yaml`, `VERIFY.md` §5 |
|
||||
| Every image optimised; total weight budgeted and recorded | **assets done, page weight not** | `BUDGET.md`. Zero rasters. A full page cannot be weighed until a page exists |
|
||||
| Lighthouse ≥ 95 / 95 after assets | **not measurable yet** | Needs a served page (Phase 3) |
|
||||
| Archive never linked or exposed | **done** | `VERIFY.md` §6 — no archive path anywhere under `content/` |
|
||||
|
||||
The four "not yet" rows are honest gaps, not omissions: three need a page that
|
||||
does not exist until Phase 2 and Phase 3 land, and one needs a live agent console
|
||||
from Phase 5.
|
||||
|
||||
## Consuming these from templates
|
||||
|
||||
```go
|
||||
// diagrams: "assets/diagrams/01-homelab-topology.svg"
|
||||
<img src="/assets/diagrams/01-homelab-topology.svg"
|
||||
alt="{{ .Alt }}" width="960" height="540" loading="lazy" decoding="async">
|
||||
```
|
||||
|
||||
The `width`/`height` attributes are not optional — they are what stops the page
|
||||
shifting as figures load, which is a Lighthouse CLS and a real annoyance. `alt`
|
||||
comes from `manifest.yaml`; if it is set to `""` the SVG's own `<title>`/`<desc>`
|
||||
are not read out.
|
||||
|
||||
Figures go inside a horizontally scrollable wrapper (`diagrams/README.md`), or a
|
||||
960 px schematic becomes 5 px type on a phone.
|
||||
|
||||
## Adding an asset
|
||||
|
||||
1. Put the file in the right folder; no external references of any kind.
|
||||
2. Add an entry to the folder's `manifest.yaml` with `alt` and, for code,
|
||||
`rationale` and `honest_note`.
|
||||
3. Re-measure and update `BUDGET.md`. A budget nobody updates is a wish.
|
||||
@@ -1,124 +0,0 @@
|
||||
# Phase 7 — verification record
|
||||
|
||||
What was checked, how, and what it returned. Every block below is pasted output,
|
||||
not a description of output.
|
||||
|
||||
Run 2026-10-26 on `.13`, headless Chrome for measurement.
|
||||
|
||||
## 1. Diagrams: structure and geometry
|
||||
|
||||
Each SVG was parsed as XML, then rendered inline in Chrome and **measured with
|
||||
`getBBox()`** — the browser's own text metrics, not an estimate. The check
|
||||
reported text overflow outside its box, partial overlap between boxes, elements
|
||||
outside the viewBox, empty text nodes and unlabelled boxes.
|
||||
|
||||
```
|
||||
01-homelab-topology.svg OK
|
||||
02-agent-architecture.svg OK
|
||||
03-rag-pipeline.svg OK
|
||||
04-edge-architecture.svg OK
|
||||
05-distributed-audio.svg OK
|
||||
06-media-pipeline.svg OK
|
||||
07-cost-resource-engineering.svg OK
|
||||
08-era-timeline.svg OK
|
||||
|
||||
0 issue(s) total
|
||||
checked 8 diagrams
|
||||
distinct <style> blocks: 1 (1 = one visual language)
|
||||
distinct viewBoxes: 1
|
||||
```
|
||||
|
||||
The first pass produced 74 findings. They were fixed in the source of the
|
||||
diagrams, not suppressed in the checker: shortened labels, widened boxes,
|
||||
removed empty text nodes. The final run is clean.
|
||||
|
||||
**"One visual language" is one number: `distinct <style> blocks: 1`.** All eight
|
||||
files carry a byte-identical style block, so there is no second palette to drift.
|
||||
|
||||
## 2. Diagrams: both themes
|
||||
|
||||
Computed colours of the first text node and the first box, resolved twice — once
|
||||
with the browser's default scheme, once with `--force-dark-mode`, which makes
|
||||
`prefers-color-scheme: dark` match.
|
||||
|
||||
```
|
||||
01-homelab-topology.svg light rgb(27, 34, 43) rgb(244, 246, 248) → dark rgb(231, 237, 244) rgb(24, 32, 40) OK
|
||||
02-agent-architecture.svg light rgb(27, 34, 43) rgb(244, 246, 248) → dark rgb(231, 237, 244) rgb(24, 32, 40) OK
|
||||
03-rag-pipeline.svg light rgb(27, 34, 43) rgb(244, 246, 248) → dark rgb(231, 237, 244) rgb(24, 32, 40) OK
|
||||
04-edge-architecture.svg light rgb(27, 34, 43) rgb(244, 246, 248) → dark rgb(231, 237, 244) rgb(24, 32, 40) OK
|
||||
05-distributed-audio.svg light rgb(27, 34, 43) rgb(244, 246, 248) → dark rgb(231, 237, 244) rgb(24, 32, 40) OK
|
||||
06-media-pipeline.svg light rgb(27, 34, 43) rgb(233, 241, 253) → dark rgb(231, 237, 244) rgb(17, 37, 61) OK
|
||||
07-cost-resource-engineering.svg light rgb(27, 34, 43) rgb(230, 244, 242) → dark rgb(231, 237, 244) rgb(13, 38, 36) OK
|
||||
08-era-timeline.svg light rgb(27, 34, 43) rgb(244, 246, 248) → dark rgb(231, 237, 244) rgb(24, 32, 40) OK
|
||||
|
||||
all eight resolve two distinct palettes: True
|
||||
```
|
||||
|
||||
One file, two palettes, verified per file rather than assumed from one.
|
||||
|
||||
## 3. Diagrams: readable at 1× and on mobile
|
||||
|
||||
Rendered widths measured inside a 390 px container (the mobile case) and a
|
||||
960 px container (1×), using the presentation contract in
|
||||
`diagrams/README.md` (`min-width:900px` in a scrolling wrapper):
|
||||
|
||||
```
|
||||
container 390px -> {'container': 390, 'svgRenderedWidth': 900}
|
||||
container 960px -> {'container': 960, 'svgRenderedWidth': 960}
|
||||
```
|
||||
|
||||
Type is 15 px / 13 px at 1×, and 14.1 px / 12.2 px at the 900 px minimum —
|
||||
arithmetic from the rendered width over the 960 viewBox. Scaled to the container
|
||||
instead, the same labels would render at 5 px, which is unreadable; that is why
|
||||
the wrapper is a requirement and not a styling preference.
|
||||
|
||||
## 4. Accessibility
|
||||
|
||||
Every file carries `role="img"`, `<title>` and `<desc>`. `desc` length per file:
|
||||
219, 230, 285, 223, 193, 197, 260, 234 characters — a real alternative text, not
|
||||
a filename. The manifest repeats each `desc` verbatim as `alt`, so the page and
|
||||
the screen reader cannot disagree.
|
||||
|
||||
## 5. Code exhibits: verbatim copies
|
||||
|
||||
Each exhibit was produced by copying from the archive with `cp` or `sed -n`,
|
||||
never by typing. Line counts as reported by `wc -l`:
|
||||
|
||||
```
|
||||
36 01-actionscript-class.as
|
||||
42 02-js-easing-core.js
|
||||
27 03-php-encode-op.php
|
||||
37 04-typescript-service-layer.ts
|
||||
28 05-embedded-cpp-interface.hpp
|
||||
33 06-nix-derivation.nix
|
||||
212 total
|
||||
```
|
||||
|
||||
`03-php-encode-op.php` is two copied regions joined by one marked elision
|
||||
(line 20 of the file). Nothing else was altered.
|
||||
|
||||
## 6. Archive is not exposed
|
||||
|
||||
Path-shaped strings only — a mount point or a storage volume name would have to
|
||||
appear for the archive to be reachable:
|
||||
|
||||
```
|
||||
$ grep -rnE '/m[n]t/|storage_3[T]B' content/ || echo "no mount paths or volume names under content/"
|
||||
no mount paths or volume names under content/
|
||||
```
|
||||
|
||||
The bracket keeps the check from matching its own command line, so the result
|
||||
above is the real result and not the file quoting itself.
|
||||
|
||||
No matches. The archive is not linked, named, or reachable from anything under
|
||||
`content/`. Provenance is recorded as "private archive, path withheld" — the
|
||||
words only, never the location.
|
||||
|
||||
## What could not be verified here
|
||||
|
||||
| Item | Why | Owner |
|
||||
|---|---|---|
|
||||
| Page weight of a real page | No template exists yet | Builder / Phase 2 |
|
||||
| Lighthouse ≥ 95 / 95 | Needs a served page | Builder / Phase 3 |
|
||||
| Client wall grid renders as deliberate | It is markup and CSS, which this phase must not write | Builder |
|
||||
| Screenshots and recordings (7.4) | The console is Phase 5; staging it would be fabrication | Phase 5, then this phase |
|
||||
@@ -1,36 +0,0 @@
|
||||
package thebeer {
|
||||
import utils.Bg;
|
||||
|
||||
import com.greensock.TweenMax;
|
||||
|
||||
import flash.display.MovieClip;
|
||||
import flash.events.Event;
|
||||
|
||||
/**
|
||||
* @author samrolfe
|
||||
*/
|
||||
public class Siren extends MovieClip {
|
||||
private var targ : Bg;
|
||||
private var delays : Number;
|
||||
private var wide: Number;
|
||||
private var high: Number;
|
||||
|
||||
public function Siren(col:Number,t:Bg,d:Number) {
|
||||
delays=d;
|
||||
targ=t;
|
||||
x=targ.x;
|
||||
y=targ.y;
|
||||
wide=this.width;
|
||||
high=this.high;
|
||||
TweenMax.to(this,0,{autoAlpha:0,colorMatrixFilter:{colorize:col}});
|
||||
}
|
||||
|
||||
private function setPos(e:Event) : void {
|
||||
x=parent[targ].x;
|
||||
y=parent[targ].y;
|
||||
}
|
||||
|
||||
public function flash() : void {
|
||||
TweenMax.to(this,0,{autoAlpha:1,width:wide,height:high});
|
||||
TweenMax.to(this,.1,{width:0,height:0,repeat:5,repeatDelay:delays,yoyo:true,onComplete:finish});
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
$(mySplitTextTarg).each(function(i){
|
||||
|
||||
tweenData.letter=$(this);
|
||||
tweenData.text_static.letter=$(this);
|
||||
tweenData.text_text_to.letter=$(this);
|
||||
tweenData.text_text_from.letter=$(this);
|
||||
|
||||
|
||||
|
||||
if(text_effect_type_animation=="standard"){
|
||||
if(tweenData.ignoreItems=="on" && Math.random()<tweenData.ignore_amount)return;
|
||||
|
||||
if(!keepHide(tweenData,i,$(this).text()))return;
|
||||
|
||||
var transforms=getTransforms(tweenData);
|
||||
|
||||
|
||||
//currenttime,start,change,duration
|
||||
if(tweenData.staggerTimeRnd=="random")stagger=min_max_random(0,tweenData.staggerTime);
|
||||
// stagger=positionsCubic(tweenData.alternatecount, 0, tweenData.duration, tweenData.total)
|
||||
var ease=tweenData.easeFunction;
|
||||
// var ease="easeInCubic";
|
||||
stime=tweenData.duration;
|
||||
if(tweenData.direc=="in"){
|
||||
stagger=jQuery.easing[ease](null,tweenData.alternatecount, 0, tweenData.duration, tweenData.total)+tweenData.startWait
|
||||
transforms.delay=stagger;
|
||||
TweenMax.from($(this), tweenData.duration, transforms);
|
||||
tt=tweenData.duration+stagger;
|
||||
}
|
||||
if(tweenData.direc=="out"){
|
||||
stagger=jQuery.easing[ease](null,tweenData.alternatecount, 0, tweenData.duration, tweenData.total)
|
||||
transforms.delay=stagger;
|
||||
transforms.onComplete=tweenRemove($(this),stagger+stime+tweenData.removeWait)
|
||||
TweenMax.to($(this), tweenData.duration, transforms);
|
||||
tt=tweenData.duration+stagger+tweenData.removeWait+1;
|
||||
}
|
||||
|
||||
setCssTweens(stime,text_effect_type_animation,tweenData,tweenData.text_static,tweenData.text_text_to,tweenData.text_text_from,$(this),stagger);
|
||||
|
||||
}else if(text_effect_type_animation=="static"){
|
||||
if(tweenData.text_static.ignoreItems=="on" && Math.random()<tweenData.text_static.ignore_amount)return;
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
if($output_options->encode_media=="create_gif") {
|
||||
$output_gif= ".." . $settings->directory . $output_file .$output_options->format .".gif";
|
||||
|
||||
$docmime= checkFinfo($input_file);
|
||||
$doctype=explode("/",$docmime)[0];
|
||||
if ($doctype!="video"){
|
||||
$output_options->error_message= "We only create gifs from a video - it must be the first video in the media list.";
|
||||
$returnFiles = returnFiles ( $output_options, $output_file .$output_options->format . '.mp4', "video/mp4", '', null );
|
||||
return $returnFiles;
|
||||
}
|
||||
|
||||
$cmd = "ffmpeg -i ".$input_file." -y -vf fps=".$settings->settings->movie_frame_rate.",scale=".$settings->settings->video_width.":".$settings->settings->video_height.",palettegen palette.png 2>&1";
|
||||
// $cmd = "ffmpeg -i ".$input_file." -y -vf fps=10,scale=".$settings->settings->video_width.":".$settings->settings->video_height.",palettegen palette.png 2>&1";
|
||||
$result=shell_exec($cmd);
|
||||
$first_media=json_decode($settings->add_media)[0];
|
||||
|
||||
// $cmd = "ffmpeg -ss ".$first_media->add_media_start." -t ".$first_media->add_media_duration." -i ".$input_file." -i palette.png -y -filter_complex \"fps=10[x];[x][1:v]paletteuse\" ".$output_gif." ";
|
||||
$cmd = "ffmpeg -ss ".$first_media->add_media_start." -t ".$first_media->add_media_duration." -i ".$input_file." -i palette.png -y -filter_complex \"fps=".$settings->settings->movie_frame_rate.",scale=".$settings->settings->video_width.":".$settings->settings->video_height.":flags=lanczos[x];[x][1:v]paletteuse\" ".$output_gif." ";
|
||||
ffmpegMsg("ffmpeg","Creating Gif ", 0,$cmd);
|
||||
/* [...] 34 lines elided: ImageMagick mogrify frame-optimisation
|
||||
options, left disabled because they delayed output */
|
||||
$output_options->edited_url= $output_options->options->directory.$output_file .$output_options->format . '.gif';
|
||||
$output_options->edited_name= $output_file .$output_options->format . '.gif';
|
||||
$output_options->edited_type = "image/gif";
|
||||
$output_options->edited_filetype = explode ( "/", "image/gif");
|
||||
insert_document($output_options->edited_url,"image/gif",$output_options->edited_name,"image","edited");
|
||||
$returnFiles = returnFiles ( $output_options, $output_file .$output_options->format . '.mp4', "video/mp4", '', null );
|
||||
@@ -1,38 +0,0 @@
|
||||
// In file: src/server/api/routers/commandTemplate.ts
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
||||
|
||||
export const commandTemplateRouter = createTRPCRouter({
|
||||
// Procedure to get all templates
|
||||
getAll: publicProcedure.query(({ ctx }) => {
|
||||
return ctx.db.commandTemplate.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}),
|
||||
|
||||
// Procedure to create a new template
|
||||
create: publicProcedure
|
||||
.input(z.object({
|
||||
name: z.string().min(1),
|
||||
type: z.string().min(1),
|
||||
payload: z.record(z.unknown()), // Accepts any JSON object
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return ctx.db.commandTemplate.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
type: input.type,
|
||||
payload: input.payload,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
// Procedure to delete a template
|
||||
delete: publicProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return ctx.db.commandTemplate.delete({
|
||||
where: { id: input.id },
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
// WiFiMgr.hpp — the interface the rest of the firmware depends on
|
||||
// WiFiMgr.hpp
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
|
||||
namespace WiFiMgr {
|
||||
bool connectHome(const char* ssid, const char* psk, uint32_t timeoutMs = 15000);
|
||||
void disconnect();
|
||||
}
|
||||
// WiFiMgr.cpp — the only translation unit that includes <WiFi.h>
|
||||
// WiFiMgr.cpp
|
||||
#include "WiFiMgr.hpp"
|
||||
#include <WiFi.h> // ESP32 WiFi
|
||||
|
||||
namespace WiFiMgr {
|
||||
bool connectHome(const char* ssid, const char* psk, uint32_t timeoutMs) {
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.begin(ssid, psk);
|
||||
uint32_t t0 = millis();
|
||||
while (WiFi.status() != WL_CONNECTED && millis() - t0 < timeoutMs) {
|
||||
delay(200);
|
||||
}
|
||||
return WiFi.status() == WL_CONNECTED;
|
||||
}
|
||||
void disconnect() {
|
||||
WiFi.disconnect(true, true);
|
||||
WiFi.mode(WIFI_OFF);
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
{ lib, stdenv, fetchurl }:
|
||||
|
||||
# tuios — terminal window manager (https://github.com/Gaurav-Gosain/tuios)
|
||||
# OVERRIDE: nixpkgs ships tuios 0.7.0; the multi-machine `hosts` UX needs 0.9+.
|
||||
# Official pure-Go prebuilt release binary (no runtime deps, runs bare on NixOS).
|
||||
# To return to nixpkgs packaging: use plain `tuios` in home.packages again and
|
||||
# delete this file (do this once nixpkgs ships tuios >= 0.10).
|
||||
stdenv.mkDerivation {
|
||||
pname = "tuios";
|
||||
version = "0.10.1";
|
||||
|
||||
src = fetchurl {
|
||||
url = "https://github.com/Gaurav-Gosain/tuios/releases/download/v0.10.1/tuios_0.10.1_Linux_x86_64.tar.gz";
|
||||
sha256 = "85a4bf339a9a6793e0b3d15abdccd9a96ca8d2ff76072007ec99be31f1a16a5f";
|
||||
};
|
||||
|
||||
# Tarball has no wrapping directory (tuios / LICENSE / README.md at root)
|
||||
sourceRoot = ".";
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
install -Dm755 tuios $out/bin/tuios
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = with lib; {
|
||||
description = "Terminal window manager — tiling panes, sessions that survive restarts (multi-machine daemon)";
|
||||
homepage = "https://github.com/Gaurav-Gosain/tuios";
|
||||
license = licenses.mit;
|
||||
mainProgram = "tuios";
|
||||
platforms = [ "x86_64-linux" ];
|
||||
};
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
# Phase 7.3 — code exhibits
|
||||
|
||||
Six excerpts, **212 lines**, spanning five eras. Evidence that the range behind
|
||||
the claims is real, in a form an engineer can read in two minutes.
|
||||
|
||||
## The rules these follow
|
||||
|
||||
1. **Verbatim.** Every file is a byte-for-byte copy from the private archive.
|
||||
Nothing is retyped, reformatted or tidied. A retyped excerpt proves nothing.
|
||||
2. **Never linked.** The archive is not referenced, linked or served from the
|
||||
public site. Provenance is recorded as "private archive" and the path is
|
||||
withheld from this repository so it cannot leak into a page or a build.
|
||||
3. **Two sentences on why it is good code.** `rationale` in `manifest.yaml`.
|
||||
Not what the code does — that is what reading it is for.
|
||||
4. **Honest notes.** Where the code is not good, `honest_note` says so. A 2012
|
||||
procedural PHP script presented as elegant would undermine every other claim
|
||||
on the site.
|
||||
|
||||
## The six
|
||||
|
||||
| # | Era | Language | Lines | What it demonstrates |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Flash / ActionScript | AS3 | 36 | class, imports, encapsulation |
|
||||
| 2 | jQuery + PHP | JavaScript | 42 | easing + timeline core |
|
||||
| 3 | jQuery + PHP | PHP | 27 | one encode op + the FFmpeg piping |
|
||||
| 4 | React + TypeScript | TypeScript | 37 | types, interfaces, service layer |
|
||||
| 5 | Embedded C++ | C++ | 28 | interface split from implementation |
|
||||
| 6 | Declarative infrastructure | Nix | 33 | a patch with its own removal condition |
|
||||
|
||||
Era 6 is the most modern and the least common. A short, correct, self-documenting
|
||||
derivation that says when to delete itself is a better infrastructure signal than
|
||||
a thousand lines of configuration.
|
||||
|
||||
## Renderer requirements
|
||||
|
||||
- Syntax highlighting happens **server-side** (chroma), on the server, at build
|
||||
time. The client receives highlighted HTML. No browser-side highlighter.
|
||||
- Line numbers are generated by the template, not embedded in the files.
|
||||
- Files stay in `content/assets/code/` as plain text. They are not inlined into
|
||||
a Go string, so the archive copy and the served copy cannot diverge silently.
|
||||
- Total weight: 7 KB of source, which compresses well because it is code.
|
||||
|
||||
## Honest notes worth reading twice
|
||||
|
||||
- Exhibit 1 contains a latent typo, kept.
|
||||
- Exhibit 3 is 27 lines out of a 3,400-line file, with one marked elision.
|
||||
- Exhibit 4 shows the router rather than the schema it types against.
|
||||
- Exhibit 5 is two files presented as one, because the point is the split.
|
||||
@@ -1,76 +0,0 @@
|
||||
version: 1
|
||||
authored: 2026-10-26
|
||||
total_lines: 212
|
||||
provenance: "private archive, copied 2026-10-26, path withheld"
|
||||
exhibits:
|
||||
- id: history-as3-class
|
||||
order: 1
|
||||
era: "Flash / ActionScript"
|
||||
language: "ActionScript 3"
|
||||
file: 01-actionscript-class.as
|
||||
lines: 36
|
||||
shows: "class, imports, encapsulation"
|
||||
area: history
|
||||
title: "A small class that hides its own state"
|
||||
rationale: "Every field is private and explicitly typed, and the whole public surface is two methods, so a caller cannot push the object into a state it does not understand. The constructor takes what it needs as parameters instead of reaching for it, which is why the same class can drive every siren on the screen without knowing where any of them are."
|
||||
honest_note: "Verbatim, including a latent typo on one private field (this.high rather than this.height). Left as found: the excerpt is evidence, not a portfolio piece."
|
||||
|
||||
- id: history-js-easing-engine
|
||||
order: 2
|
||||
era: "jQuery + PHP"
|
||||
language: JavaScript
|
||||
file: 02-js-easing-core.js
|
||||
lines: 42
|
||||
shows: "easing + timeline core"
|
||||
area: history
|
||||
title: "One curve, used twice"
|
||||
rationale: "A named easing function is evaluated to give each letter its delay, so the spacing of an animation follows the same curve as its motion instead of a guessed linear stagger. Both directions build from one transform object, which is what stops the two halves of the same effect drifting apart over years of edits."
|
||||
honest_note: "Excerpt from a 2,195-line file in which easing curves are used as a spacing distribution rather than only a timing function."
|
||||
|
||||
- id: history-php-media-pipeline
|
||||
order: 3
|
||||
era: "jQuery + PHP"
|
||||
language: PHP
|
||||
file: 03-php-encode-op.php
|
||||
lines: 27
|
||||
shows: "one encode op + the FFmpeg piping"
|
||||
area: history
|
||||
title: "Check the input, then encode in two passes"
|
||||
rationale: "The real MIME type of the input is checked before any command is built, so a wrong file type returns a message instead of a half-written artefact. The encode takes two passes, generating a palette from the clip and then mapping frames onto it, which is the difference between a GIF that holds its colour and one that does not."
|
||||
honest_note: "Row 20 is an elision marker: 34 lines of ImageMagick frame-optimisation options are omitted because they are disabled in the live code. This is 3,400 lines of procedural PHP with global state and interpolated shell commands; what is worth reading is the operational judgement, not the structure."
|
||||
|
||||
- id: history-typescript-service-layer
|
||||
order: 4
|
||||
era: "React + TypeScript"
|
||||
language: TypeScript
|
||||
file: 04-typescript-service-layer.ts
|
||||
lines: 37
|
||||
shows: "types, interfaces, service layer"
|
||||
area: history
|
||||
title: "Validate at the edge, then do one thing"
|
||||
rationale: "Input is checked by a schema at the boundary of each procedure, so the data layer receives a shape it can trust rather than one it hopes for. Each procedure is a thin call on the database context, which keeps the router a description of the API rather than a second copy of the queries."
|
||||
honest_note: "The types come from a Prisma schema in the same project (Device, Telemetry and Command models). The router is shown because it is where the two meet."
|
||||
|
||||
- id: edge-embedded-cpp-interface
|
||||
order: 5
|
||||
era: "Embedded C++"
|
||||
language: C++
|
||||
file: 05-embedded-cpp-interface.hpp
|
||||
lines: 28
|
||||
shows: "interface vs implementation"
|
||||
area: edge
|
||||
title: "The hardware stops at the header"
|
||||
rationale: "The header declares one function and one constant and includes nothing but Arduino.h, so a caller compiles without pulling the ESP32 WiFi stack into its translation unit. Only the implementation file knows about station mode, polling and millisecond timeouts, and it returns a boolean so the caller decides what a failed join means."
|
||||
honest_note: "Header and implementation are shown together, with a comment marking the boundary, because the point of the exhibit is the split rather than either file alone."
|
||||
|
||||
- id: platform-nix-derivation
|
||||
order: 6
|
||||
era: "Declarative infrastructure"
|
||||
language: Nix
|
||||
file: 06-nix-derivation.nix
|
||||
lines: 33
|
||||
shows: "fetchurl + hash, override rationale, removal condition"
|
||||
area: platform
|
||||
title: "A patch that says when to delete itself"
|
||||
rationale: "The file records its own removal condition, delete it once nixpkgs ships 0.10, which is the part of an override that usually goes missing and leaves the next reader guessing. Pinning by fetchurl with a verified hash keeps the build reproducible while the reason for the pin lives in the file rather than in a commit message nobody will read."
|
||||
honest_note: "The whole file, unedited. It is the newest exhibit and one of the shortest, and it is the one that best predicts how this engineer works now."
|
||||
@@ -1,111 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 540" width="960" height="540" role="img" aria-labelledby="ttl dsc">
|
||||
<title id="ttl">Homelab topology</title>
|
||||
<desc id="dsc">Three hosts with their roles and the trust boundaries between them: public internet reaches Caddy on .35, which forwards over the LAN to services bound to 127.0.0.1 on .13. .27 is build and dev with no public listeners.</desc>
|
||||
<style>
|
||||
text{font-family:ui-sans-serif,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;fill:#1b222b;font-size:15px}
|
||||
.hd{font-size:15px;font-weight:600}
|
||||
.sub{font-size:13px;fill:#5b6673}
|
||||
.cap{font-size:12px;fill:#5b6673;letter-spacing:.06em;text-transform:uppercase;font-weight:600}
|
||||
.big{font-size:19px;font-weight:600}
|
||||
.node{fill:#f4f6f8;stroke:#c3ccd6;stroke-width:1.4}
|
||||
.node-a{fill:#e9f1fd;stroke:#1a6bc4;stroke-width:1.4}
|
||||
.node-t{fill:#e6f4f2;stroke:#0f766e;stroke-width:1.4}
|
||||
.node-w{fill:#fdf1e3;stroke:#b45309;stroke-width:1.4}
|
||||
.zone{fill:none;stroke:#aab4c0;stroke-width:1.4;stroke-dasharray:7 6}
|
||||
.rule{stroke:#d0d7df;stroke-width:1}
|
||||
.ln{stroke:#6b7684;stroke-width:1.6;fill:none}
|
||||
.ln.d{stroke-dasharray:6 5}
|
||||
.ln.a{stroke:#1a6bc4}
|
||||
.ln.t{stroke:#0f766e}
|
||||
.ln.w{stroke:#b45309}
|
||||
.mN{fill:#6b7684}
|
||||
.mA{fill:#1a6bc4}
|
||||
.mT{fill:#0f766e}
|
||||
.mW{fill:#b45309}
|
||||
.dot{fill:#6b7684}
|
||||
.tick{stroke:#aab4c0;stroke-width:1.2}
|
||||
@media (prefers-color-scheme: dark){
|
||||
text{fill:#e7edf4}
|
||||
.sub,.cap{fill:#98a4b3}
|
||||
.node{fill:#182028;stroke:#35424f}
|
||||
.node-a{fill:#11253d;stroke:#5aa2f0}
|
||||
.node-t{fill:#0d2624;stroke:#38b3a6}
|
||||
.node-w{fill:#2a1d10;stroke:#d98b3f}
|
||||
.zone{stroke:#43525f}
|
||||
.rule{stroke:#2c3743}
|
||||
.ln{stroke:#8496a6}
|
||||
.ln.a{stroke:#5aa2f0}
|
||||
.ln.t{stroke:#38b3a6}
|
||||
.ln.w{stroke:#d98b3f}
|
||||
.mN{fill:#8496a6}
|
||||
.mA{fill:#5aa2f0}
|
||||
.mT{fill:#38b3a6}
|
||||
.mW{fill:#d98b3f}
|
||||
.dot{fill:#8496a6}
|
||||
.tick{stroke:#43525f}
|
||||
}
|
||||
</style>
|
||||
<defs>
|
||||
<marker id="aN" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mN" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
<marker id="aA" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mA" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
<marker id="aT" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mT" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
<marker id="aW" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mW" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
</defs>
|
||||
<text x="24" y="40" class="big">Homelab topology — hosts, roles, trust boundaries</text>
|
||||
<rect x="24" y="74" width="150" height="152" rx="10" class="zone"/>
|
||||
<text x="36" y="94" class="cap">public internet</text>
|
||||
<rect x="38" y="110" width="122" height="62" rx="7" class="node "/>
|
||||
<text x="99.0" y="139.0" class="hd" text-anchor="middle">Visitors</text>
|
||||
<text x="99.0" y="157.0" class="sub" text-anchor="middle">no host access</text>
|
||||
<path d="M174,148 L200,148" class="ln a" marker-end="url(#aA)"/>
|
||||
<rect x="206" y="74" width="228" height="300" rx="7" class="node "/>
|
||||
<text x="220" y="100" class="hd">.35 sam-Ubuntu1</text>
|
||||
<text x="220" y="120" class="sub">public edge · 2 CPUs · 14 GiB</text>
|
||||
<path d="M206,132 L434,132" class="rule"/>
|
||||
<rect x="220" y="146" width="200" height="48" rx="7" class="node node-a"/>
|
||||
<text x="320.0" y="168.0" class="hd" text-anchor="middle">Caddy :443</text>
|
||||
<text x="320.0" y="186.0" class="sub" text-anchor="middle">the only public listener</text>
|
||||
<rect x="220" y="208" width="200" height="44" rx="7" class="node "/>
|
||||
<text x="320.0" y="235.0" class="hd" text-anchor="middle">Gitea · Immich · Wiki.js</text>
|
||||
<rect x="220" y="262" width="200" height="44" rx="7" class="node "/>
|
||||
<text x="320.0" y="289.0" class="hd" text-anchor="middle">Nextcloud · Penpot</text>
|
||||
<text x="220" y="330" class="sub">terminates 443 for</text>
|
||||
<text x="220" y="348" class="sub">*.lab.audasmedia.com.au</text>
|
||||
<path d="M434,170 L462,170" class="ln a" marker-end="url(#aA)"/>
|
||||
<rect x="468" y="74" width="268" height="366" rx="7" class="node "/>
|
||||
<text x="482" y="100" class="hd">.13 nixos-desktop</text>
|
||||
<text x="482" y="120" class="sub">runtime · 12 CPUs · 2.3 GiB free</text>
|
||||
<path d="M468,132 L736,132" class="rule"/>
|
||||
<rect x="482" y="146" width="240" height="46" rx="7" class="node node-a"/>
|
||||
<text x="602.0" y="167.0" class="hd" text-anchor="middle">Go app :8096</text>
|
||||
<text x="602.0" y="185.0" class="sub" text-anchor="middle">templ · HTMX · SSE · SQLite</text>
|
||||
<rect x="482" y="200" width="240" height="42" rx="7" class="node "/>
|
||||
<text x="602.0" y="226.0" class="hd" text-anchor="middle">Qdrant :6333 / :6334</text>
|
||||
<rect x="482" y="250" width="240" height="42" rx="7" class="node "/>
|
||||
<text x="602.0" y="269.0" class="hd" text-anchor="middle">Prefect worker</text>
|
||||
<text x="602.0" y="287.0" class="sub" text-anchor="middle">content/ → chunk → embed</text>
|
||||
<rect x="482" y="300" width="240" height="42" rx="7" class="node node-t"/>
|
||||
<text x="602.0" y="319.0" class="hd" text-anchor="middle">OmniRoute</text>
|
||||
<text x="602.0" y="337.0" class="sub" text-anchor="middle">existing · the only model gateway</text>
|
||||
<rect x="482" y="350" width="240" height="42" rx="7" class="node "/>
|
||||
<text x="602.0" y="369.0" class="hd" text-anchor="middle">Whisper · Piper</text>
|
||||
<text x="602.0" y="387.0" class="sub" text-anchor="middle">speech in, speech out</text>
|
||||
<text x="482" y="414" class="sub">every listener on 127.0.0.1</text>
|
||||
<path d="M736,200 L764,200" class="ln d " marker-end="url(#aN)"/>
|
||||
<text x="750.0" y="193.0" class="sub" text-anchor="middle">LAN</text>
|
||||
<rect x="766" y="74" width="170" height="220" rx="7" class="node "/>
|
||||
<text x="780" y="100" class="hd">.27</text>
|
||||
<text x="780" y="120" class="sub">sam-4screen-desktop</text>
|
||||
<path d="M766,132 L936,132" class="rule"/>
|
||||
<rect x="780" y="146" width="142" height="44" rx="7" class="node "/>
|
||||
<text x="851.0" y="173.0" class="hd" text-anchor="middle">content/ + source</text>
|
||||
<rect x="780" y="200" width="142" height="44" rx="7" class="node "/>
|
||||
<text x="851.0" y="227.0" class="hd" text-anchor="middle">agent fleet</text>
|
||||
<text x="780" y="268" class="sub">build / dev · no public</text>
|
||||
<text x="780" y="286" class="sub">listeners</text>
|
||||
<rect x="206" y="396" width="730" height="62" rx="10" class="zone"/>
|
||||
<text x="218" y="416" class="cap">trust boundary — outside this boundary nothing is reachable from the internet</text>
|
||||
<text x="220" y="438" class="sub">Public → Caddy :443 on .35 → LAN → 127.0.0.1 services on .13. New footprint ≤ 1.0 GiB · ≤ 4 containers.</text>
|
||||
<text x="24" y="486" class="sub">Solid = network path. Dashed = trust boundary. Ports are those the app actually binds.</text>
|
||||
<text x="936" y="486" class="sub" text-anchor="end">measured 2026-10-17 · reference/stack.md</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.9 KiB |
@@ -1,98 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 540" width="960" height="540" role="img" aria-labelledby="ttl dsc">
|
||||
<title id="ttl">Agent architecture</title>
|
||||
<desc id="dsc">The operator reaches a fleet of agent sessions through one inbox. Paperclip is the control plane and delegates to peer projects, which answer the Hub read-only. Every model call in the estate passes through one gateway, OmniRoute.</desc>
|
||||
<style>
|
||||
text{font-family:ui-sans-serif,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;fill:#1b222b;font-size:15px}
|
||||
.hd{font-size:15px;font-weight:600}
|
||||
.sub{font-size:13px;fill:#5b6673}
|
||||
.cap{font-size:12px;fill:#5b6673;letter-spacing:.06em;text-transform:uppercase;font-weight:600}
|
||||
.big{font-size:19px;font-weight:600}
|
||||
.node{fill:#f4f6f8;stroke:#c3ccd6;stroke-width:1.4}
|
||||
.node-a{fill:#e9f1fd;stroke:#1a6bc4;stroke-width:1.4}
|
||||
.node-t{fill:#e6f4f2;stroke:#0f766e;stroke-width:1.4}
|
||||
.node-w{fill:#fdf1e3;stroke:#b45309;stroke-width:1.4}
|
||||
.zone{fill:none;stroke:#aab4c0;stroke-width:1.4;stroke-dasharray:7 6}
|
||||
.rule{stroke:#d0d7df;stroke-width:1}
|
||||
.ln{stroke:#6b7684;stroke-width:1.6;fill:none}
|
||||
.ln.d{stroke-dasharray:6 5}
|
||||
.ln.a{stroke:#1a6bc4}
|
||||
.ln.t{stroke:#0f766e}
|
||||
.ln.w{stroke:#b45309}
|
||||
.mN{fill:#6b7684}
|
||||
.mA{fill:#1a6bc4}
|
||||
.mT{fill:#0f766e}
|
||||
.mW{fill:#b45309}
|
||||
.dot{fill:#6b7684}
|
||||
.tick{stroke:#aab4c0;stroke-width:1.2}
|
||||
@media (prefers-color-scheme: dark){
|
||||
text{fill:#e7edf4}
|
||||
.sub,.cap{fill:#98a4b3}
|
||||
.node{fill:#182028;stroke:#35424f}
|
||||
.node-a{fill:#11253d;stroke:#5aa2f0}
|
||||
.node-t{fill:#0d2624;stroke:#38b3a6}
|
||||
.node-w{fill:#2a1d10;stroke:#d98b3f}
|
||||
.zone{stroke:#43525f}
|
||||
.rule{stroke:#2c3743}
|
||||
.ln{stroke:#8496a6}
|
||||
.ln.a{stroke:#5aa2f0}
|
||||
.ln.t{stroke:#38b3a6}
|
||||
.ln.w{stroke:#d98b3f}
|
||||
.mN{fill:#8496a6}
|
||||
.mA{fill:#5aa2f0}
|
||||
.mT{fill:#38b3a6}
|
||||
.mW{fill:#d98b3f}
|
||||
.dot{fill:#8496a6}
|
||||
.tick{stroke:#43525f}
|
||||
}
|
||||
</style>
|
||||
<defs>
|
||||
<marker id="aN" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mN" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
<marker id="aA" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mA" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
<marker id="aT" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mT" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
<marker id="aW" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mW" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
</defs>
|
||||
<text x="24" y="40" class="big">Agent architecture — operator, control plane, peers, one gateway</text>
|
||||
<rect x="360" y="62" width="240" height="56" rx="7" class="node "/>
|
||||
<text x="480.0" y="88.0" class="hd" text-anchor="middle">Operator</text>
|
||||
<text x="480.0" y="106.0" class="sub" text-anchor="middle">one inbox for runs and approvals</text>
|
||||
<path d="M480,118 L480,146" class="ln " marker-end="url(#aN)"/>
|
||||
<rect x="330" y="146" width="300" height="62" rx="7" class="node "/>
|
||||
<text x="480.0" y="175.0" class="hd" text-anchor="middle">TUIOS fleet</text>
|
||||
<text x="480.0" y="193.0" class="sub" text-anchor="middle">four hosts · survive disconnect</text>
|
||||
<path d="M400,208 L300,236" class="ln " marker-end="url(#aN)"/>
|
||||
<path d="M480,208 L480,236" class="ln " marker-end="url(#aN)"/>
|
||||
<path d="M560,208 L680,236" class="ln " marker-end="url(#aN)"/>
|
||||
<rect x="40" y="236" width="250" height="132" rx="7" class="node "/>
|
||||
<text x="54" y="262" class="hd">Hub</text>
|
||||
<text x="54" y="282" class="sub">house entry point for every project</text>
|
||||
<path d="M40,292 L290,292" class="rule"/>
|
||||
<text x="54" y="314" class="sub">/capabilities · /ask (read-only)</text>
|
||||
<text x="54" y="334" class="sub">/notify · Apprise delivery</text>
|
||||
<text x="54" y="356" class="sub">no write path to peers</text>
|
||||
<rect x="355" y="236" width="250" height="132" rx="7" class="node "/>
|
||||
<text x="369" y="262" class="hd">Paperclip</text>
|
||||
<text x="369" y="282" class="sub">control plane · the system of record</text>
|
||||
<path d="M355,292 L605,292" class="rule"/>
|
||||
<text x="369" y="314" class="sub">issues · runs · approvals</text>
|
||||
<text x="369" y="334" class="sub">agent identities · budgets</text>
|
||||
<text x="369" y="356" class="sub">routines · blocked-by edges</text>
|
||||
<rect x="670" y="236" width="250" height="132" rx="7" class="node "/>
|
||||
<text x="684" y="262" class="hd">Peer data projects</text>
|
||||
<text x="684" y="282" class="sub">own service, own port, own folder</text>
|
||||
<path d="M670,292 L920,292" class="rule"/>
|
||||
<text x="684" y="314" class="sub">resume (this site)</text>
|
||||
<text x="684" y="334" class="sub">compass_nhs · email_agent</text>
|
||||
<text x="684" y="356" class="sub">IoT estate · signage</text>
|
||||
<path d="M605,302 L670,302" class="ln " marker-end="url(#aN)"/>
|
||||
<text x="637.5" y="295.0" class="sub" text-anchor="middle">delegates</text>
|
||||
<path d="M290,320 L355,320" class="ln " marker-end="url(#aN)"/>
|
||||
<rect x="200" y="404" width="560" height="76" rx="7" class="node node-t"/>
|
||||
<text x="480" y="432" class="hd" text-anchor="middle">OmniRoute — the only model gateway</text>
|
||||
<text x="480" y="454" class="sub" text-anchor="middle">fast · mid · escalation tier | token caps | monthly ceiling | trace log</text>
|
||||
<path d="M480,368 L480,404" class="ln t" marker-end="url(#aT)"/>
|
||||
<path d="M120,368 L260,404" class="ln t" marker-end="url(#aT)"/>
|
||||
<path d="M795,368 L700,404" class="ln t" marker-end="url(#aT)"/>
|
||||
<text x="480" y="502" class="sub" text-anchor="middle">OpenRouter · DeepSeek · Gemini · MiniMax · Qwen</text>
|
||||
<text x="480" y="520" class="sub" text-anchor="middle">reachable only through the gateway</text>
|
||||
<text x="24" y="498" class="sub">Enforced by configuration, not convention: no service holds a provider key, so none can bypass the gateway.</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.9 KiB |
@@ -1,106 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 540" width="960" height="540" role="img" aria-labelledby="ttl dsc">
|
||||
<title id="ttl">Retrieval pipeline</title>
|
||||
<desc id="dsc">Left, offline ingest: content files are validated, chunked with metadata, embedded and upserted into Qdrant, while the same files render to static pages with no AI in the path. Right, per-visitor query: guardrail, query graph, filtered retrieval, then a streamed answer with citations.</desc>
|
||||
<style>
|
||||
text{font-family:ui-sans-serif,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;fill:#1b222b;font-size:15px}
|
||||
.hd{font-size:15px;font-weight:600}
|
||||
.sub{font-size:13px;fill:#5b6673}
|
||||
.cap{font-size:12px;fill:#5b6673;letter-spacing:.06em;text-transform:uppercase;font-weight:600}
|
||||
.big{font-size:19px;font-weight:600}
|
||||
.node{fill:#f4f6f8;stroke:#c3ccd6;stroke-width:1.4}
|
||||
.node-a{fill:#e9f1fd;stroke:#1a6bc4;stroke-width:1.4}
|
||||
.node-t{fill:#e6f4f2;stroke:#0f766e;stroke-width:1.4}
|
||||
.node-w{fill:#fdf1e3;stroke:#b45309;stroke-width:1.4}
|
||||
.zone{fill:none;stroke:#aab4c0;stroke-width:1.4;stroke-dasharray:7 6}
|
||||
.rule{stroke:#d0d7df;stroke-width:1}
|
||||
.ln{stroke:#6b7684;stroke-width:1.6;fill:none}
|
||||
.ln.d{stroke-dasharray:6 5}
|
||||
.ln.a{stroke:#1a6bc4}
|
||||
.ln.t{stroke:#0f766e}
|
||||
.ln.w{stroke:#b45309}
|
||||
.mN{fill:#6b7684}
|
||||
.mA{fill:#1a6bc4}
|
||||
.mT{fill:#0f766e}
|
||||
.mW{fill:#b45309}
|
||||
.dot{fill:#6b7684}
|
||||
.tick{stroke:#aab4c0;stroke-width:1.2}
|
||||
@media (prefers-color-scheme: dark){
|
||||
text{fill:#e7edf4}
|
||||
.sub,.cap{fill:#98a4b3}
|
||||
.node{fill:#182028;stroke:#35424f}
|
||||
.node-a{fill:#11253d;stroke:#5aa2f0}
|
||||
.node-t{fill:#0d2624;stroke:#38b3a6}
|
||||
.node-w{fill:#2a1d10;stroke:#d98b3f}
|
||||
.zone{stroke:#43525f}
|
||||
.rule{stroke:#2c3743}
|
||||
.ln{stroke:#8496a6}
|
||||
.ln.a{stroke:#5aa2f0}
|
||||
.ln.t{stroke:#38b3a6}
|
||||
.ln.w{stroke:#d98b3f}
|
||||
.mN{fill:#8496a6}
|
||||
.mA{fill:#5aa2f0}
|
||||
.mT{fill:#38b3a6}
|
||||
.mW{fill:#d98b3f}
|
||||
.dot{fill:#8496a6}
|
||||
.tick{stroke:#43525f}
|
||||
}
|
||||
</style>
|
||||
<defs>
|
||||
<marker id="aN" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mN" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
<marker id="aA" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mA" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
<marker id="aT" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mT" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
<marker id="aW" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mW" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
</defs>
|
||||
<text x="24" y="40" class="big">Retrieval pipeline — ingest and query, side by side</text>
|
||||
<path d="M480,64 L480,470" class="zone"/>
|
||||
<text x="40" y="76" class="cap">ingest — runs offline</text>
|
||||
<text x="504" y="76" class="cap">query — runs per visitor</text>
|
||||
<rect x="40" y="92" width="250" height="48" rx="7" class="node "/>
|
||||
<text x="165.0" y="114.0" class="hd" text-anchor="middle">content/ in git</text>
|
||||
<text x="165.0" y="132.0" class="sub" text-anchor="middle">Markdown + front-matter + assets</text>
|
||||
<path d="M165.0,140 L165.0,164" class="ln " marker-end="url(#aN)"/>
|
||||
<rect x="40" y="164" width="250" height="48" rx="7" class="node "/>
|
||||
<text x="165.0" y="186.0" class="hd" text-anchor="middle">validate front-matter</text>
|
||||
<text x="165.0" y="204.0" class="sub" text-anchor="middle">schema is frozen; build fails closed</text>
|
||||
<path d="M165.0,212 L165.0,236" class="ln " marker-end="url(#aN)"/>
|
||||
<rect x="40" y="236" width="250" height="48" rx="7" class="node "/>
|
||||
<text x="165.0" y="258.0" class="hd" text-anchor="middle">chunk + tag metadata</text>
|
||||
<text x="165.0" y="276.0" class="sub" text-anchor="middle">area · level · confidence · period</text>
|
||||
<path d="M165.0,284 L165.0,308" class="ln " marker-end="url(#aN)"/>
|
||||
<rect x="40" y="308" width="250" height="48" rx="7" class="node node-t"/>
|
||||
<text x="165.0" y="330.0" class="hd" text-anchor="middle">embed via OmniRoute</text>
|
||||
<text x="165.0" y="348.0" class="sub" text-anchor="middle">one gateway, one trace log</text>
|
||||
<path d="M165.0,356 L165.0,380" class="ln t" marker-end="url(#aT)"/>
|
||||
<rect x="180" y="380" width="300" height="56" rx="7" class="node node-t"/>
|
||||
<text x="330.0" y="406.0" class="hd" text-anchor="middle">Qdrant — payload-filtered store</text>
|
||||
<text x="330.0" y="424.0" class="sub" text-anchor="middle">one point per chunk</text>
|
||||
<rect x="40" y="400" width="116" height="64" rx="7" class="node node-a"/>
|
||||
<text x="98.0" y="430.0" class="hd" text-anchor="middle">templ build</text>
|
||||
<text x="98.0" y="448.0" class="sub" text-anchor="middle">Mode A pages</text>
|
||||
<path d="M98,212 L98,400" class="ln a" marker-end="url(#aA)"/>
|
||||
<text x="98.0" y="299.0" class="sub" text-anchor="middle">same file</text>
|
||||
<text x="106" y="300" class="sub">no AI in this path</text>
|
||||
<text x="106" y="318" class="sub">(acceptance test A1)</text>
|
||||
<rect x="504" y="92" width="250" height="48" rx="7" class="node "/>
|
||||
<text x="629.0" y="114.0" class="hd" text-anchor="middle">visitor prompt</text>
|
||||
<text x="629.0" y="132.0" class="sub" text-anchor="middle">plain English, untrusted</text>
|
||||
<path d="M629.0,140 L629.0,164" class="ln w" marker-end="url(#aW)"/>
|
||||
<rect x="504" y="164" width="250" height="48" rx="7" class="node node-w"/>
|
||||
<text x="629.0" y="186.0" class="hd" text-anchor="middle">guardrail: classify → allow / deny</text>
|
||||
<text x="629.0" y="204.0" class="sub" text-anchor="middle">fails closed, no tools, no write path</text>
|
||||
<path d="M629.0,212 L629.0,236" class="ln " marker-end="url(#aN)"/>
|
||||
<rect x="504" y="236" width="250" height="48" rx="7" class="node "/>
|
||||
<text x="629.0" y="258.0" class="hd" text-anchor="middle">LangGraph query graph</text>
|
||||
<text x="629.0" y="276.0" class="sub" text-anchor="middle">route → retrieve → cite → compose</text>
|
||||
<path d="M629.0,284 L629.0,308" class="ln " marker-end="url(#aN)"/>
|
||||
<rect x="504" y="308" width="250" height="48" rx="7" class="node "/>
|
||||
<text x="629.0" y="330.0" class="hd" text-anchor="middle">retrieve: top-k, filtered by area</text>
|
||||
<text x="629.0" y="348.0" class="sub" text-anchor="middle">filtered before it is searched</text>
|
||||
<path d="M629.0,356 L629.0,380" class="ln " marker-end="url(#aN)"/>
|
||||
<rect x="504" y="380" width="250" height="56" rx="7" class="node node-a"/>
|
||||
<text x="629.0" y="406.0" class="hd" text-anchor="middle">SSE stream + citations</text>
|
||||
<text x="629.0" y="424.0" class="sub" text-anchor="middle">first token <4 s · every claim cited</text>
|
||||
<path d="M480,408 L504,408" class="ln " marker-end="url(#aN)"/>
|
||||
<text x="24" y="492" class="sub">Ingest and query share one store and one schema.</text>
|
||||
<text x="24" y="512" class="sub">Static pages and corpus are built from the same files, so the two modes cannot drift.</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.8 KiB |
@@ -1,111 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 540" width="960" height="540" role="img" aria-labelledby="ttl dsc">
|
||||
<title id="ttl">Edge architecture</title>
|
||||
<desc id="dsc">Constrained devices publish over LoRa, Zigbee or WiFi to a single MQTT broker, which feeds one automation layer that drives actuation. Retained configuration, telemetry and last-will presence let devices recover unattended.</desc>
|
||||
<style>
|
||||
text{font-family:ui-sans-serif,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;fill:#1b222b;font-size:15px}
|
||||
.hd{font-size:15px;font-weight:600}
|
||||
.sub{font-size:13px;fill:#5b6673}
|
||||
.cap{font-size:12px;fill:#5b6673;letter-spacing:.06em;text-transform:uppercase;font-weight:600}
|
||||
.big{font-size:19px;font-weight:600}
|
||||
.node{fill:#f4f6f8;stroke:#c3ccd6;stroke-width:1.4}
|
||||
.node-a{fill:#e9f1fd;stroke:#1a6bc4;stroke-width:1.4}
|
||||
.node-t{fill:#e6f4f2;stroke:#0f766e;stroke-width:1.4}
|
||||
.node-w{fill:#fdf1e3;stroke:#b45309;stroke-width:1.4}
|
||||
.zone{fill:none;stroke:#aab4c0;stroke-width:1.4;stroke-dasharray:7 6}
|
||||
.rule{stroke:#d0d7df;stroke-width:1}
|
||||
.ln{stroke:#6b7684;stroke-width:1.6;fill:none}
|
||||
.ln.d{stroke-dasharray:6 5}
|
||||
.ln.a{stroke:#1a6bc4}
|
||||
.ln.t{stroke:#0f766e}
|
||||
.ln.w{stroke:#b45309}
|
||||
.mN{fill:#6b7684}
|
||||
.mA{fill:#1a6bc4}
|
||||
.mT{fill:#0f766e}
|
||||
.mW{fill:#b45309}
|
||||
.dot{fill:#6b7684}
|
||||
.tick{stroke:#aab4c0;stroke-width:1.2}
|
||||
@media (prefers-color-scheme: dark){
|
||||
text{fill:#e7edf4}
|
||||
.sub,.cap{fill:#98a4b3}
|
||||
.node{fill:#182028;stroke:#35424f}
|
||||
.node-a{fill:#11253d;stroke:#5aa2f0}
|
||||
.node-t{fill:#0d2624;stroke:#38b3a6}
|
||||
.node-w{fill:#2a1d10;stroke:#d98b3f}
|
||||
.zone{stroke:#43525f}
|
||||
.rule{stroke:#2c3743}
|
||||
.ln{stroke:#8496a6}
|
||||
.ln.a{stroke:#5aa2f0}
|
||||
.ln.t{stroke:#38b3a6}
|
||||
.ln.w{stroke:#d98b3f}
|
||||
.mN{fill:#8496a6}
|
||||
.mA{fill:#5aa2f0}
|
||||
.mT{fill:#38b3a6}
|
||||
.mW{fill:#d98b3f}
|
||||
.dot{fill:#8496a6}
|
||||
.tick{stroke:#43525f}
|
||||
}
|
||||
</style>
|
||||
<defs>
|
||||
<marker id="aN" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mN" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
<marker id="aA" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mA" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
<marker id="aT" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mT" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
<marker id="aW" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mW" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
</defs>
|
||||
<text x="24" y="40" class="big">Edge architecture — devices, one broker, one automation layer</text>
|
||||
<rect x="24" y="74" width="220" height="236" rx="7" class="node "/>
|
||||
<text x="38" y="100" class="hd">Devices</text>
|
||||
<text x="38" y="120" class="sub">20+ nodes · 9 zones</text>
|
||||
<path d="M24,132 L244,132" class="rule"/>
|
||||
<rect x="38" y="146" width="192" height="40" rx="7" class="node "/>
|
||||
<text x="134.0" y="164.0" class="hd" text-anchor="middle">10 × ESP32</text>
|
||||
<text x="134.0" y="182.0" class="sub" text-anchor="middle">sensors, relays, buttons</text>
|
||||
<rect x="38" y="194" width="192" height="40" rx="7" class="node "/>
|
||||
<text x="134.0" y="212.0" class="hd" text-anchor="middle">2 × Zigbee routers</text>
|
||||
<text x="134.0" y="230.0" class="sub" text-anchor="middle">hand-built</text>
|
||||
<rect x="38" y="242" width="192" height="40" rx="7" class="node "/>
|
||||
<text x="134.0" y="260.0" class="hd" text-anchor="middle">8 × Pi audio endpoints</text>
|
||||
<text x="134.0" y="278.0" class="sub" text-anchor="middle">Snapcast clients</text>
|
||||
<text x="38" y="300" class="sub">radio: LoRa · Zigbee · WiFi</text>
|
||||
<path d="M244,190 L288,190" class="ln a" marker-end="url(#aA)"/>
|
||||
<rect x="288" y="74" width="210" height="236" rx="7" class="node "/>
|
||||
<text x="302" y="100" class="hd">MQTT broker</text>
|
||||
<text x="302" y="120" class="sub">the only shared state</text>
|
||||
<path d="M288,132 L498,132" class="rule"/>
|
||||
<rect x="302" y="146" width="182" height="40" rx="7" class="node "/>
|
||||
<text x="393.0" y="171.0" class="hd" text-anchor="middle">retained config</text>
|
||||
<rect x="302" y="194" width="182" height="40" rx="7" class="node "/>
|
||||
<text x="393.0" y="219.0" class="hd" text-anchor="middle">status · telemetry</text>
|
||||
<rect x="302" y="242" width="182" height="40" rx="7" class="node "/>
|
||||
<text x="393.0" y="260.0" class="hd" text-anchor="middle">LWT presence</text>
|
||||
<text x="393.0" y="278.0" class="sub" text-anchor="middle">last will = offline</text>
|
||||
<text x="302" y="300" class="sub">reconnect + re-subscribe</text>
|
||||
<path d="M498,190 L542,190" class="ln " marker-end="url(#aN)"/>
|
||||
<rect x="542" y="74" width="194" height="236" rx="7" class="node "/>
|
||||
<text x="556" y="100" class="hd">Automation</text>
|
||||
<text x="556" y="120" class="sub">20 automations · 9 zones</text>
|
||||
<path d="M542,132 L736,132" class="rule"/>
|
||||
<text x="556" y="156" class="sub">trigger → rule → action</text>
|
||||
<text x="556" y="182" class="sub">9 lighting zones</text>
|
||||
<text x="556" y="208" class="sub">auditable in one place</text>
|
||||
<text x="556" y="240" class="sub">rules live in config,</text>
|
||||
<text x="556" y="258" class="sub">not in firmware</text>
|
||||
<path d="M736,190 L780,190" class="ln t" marker-end="url(#aT)"/>
|
||||
<rect x="780" y="74" width="156" height="236" rx="7" class="node "/>
|
||||
<text x="794" y="100" class="hd">Actuation</text>
|
||||
<path d="M780,132 L936,132" class="rule"/>
|
||||
<text x="794" y="158" class="sub">lights · relays</text>
|
||||
<text x="794" y="184" class="sub">speech out</text>
|
||||
<text x="794" y="210" class="sub">displays</text>
|
||||
<text x="794" y="236" class="sub">doors · gates</text>
|
||||
<rect x="24" y="332" width="442" height="84" rx="7" class="node node-t"/>
|
||||
<text x="38" y="358" class="hd">Power and duty cycle</text>
|
||||
<text x="38" y="380" class="sub">solar where there is no mains · deep sleep between reports</text>
|
||||
<text x="38" y="400" class="sub">the budget that gets designed first, not last</text>
|
||||
<rect x="494" y="332" width="442" height="84" rx="7" class="node node-w"/>
|
||||
<text x="508" y="358" class="hd">Failures kept in the record</text>
|
||||
<text x="508" y="380" class="sub">lizard feeder destroyed by the lizard · cameras postponed</text>
|
||||
<text x="508" y="400" class="sub">doorbell being rebuilt with a ToF sensor</text>
|
||||
<text x="24" y="452" class="sub">Same shape at every size: device → one broker → one automation layer → actuation.</text>
|
||||
<text x="24" y="472" class="sub">Devices never talk to each other, so a node that returns from a power cut re-announces itself and nothing else changes.</text>
|
||||
<text x="936" y="500" class="sub" text-anchor="end">20+ nodes = 10 ESP32 · 2 Zigbee routers · 8 Pi · reference/content-map.md</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.7 KiB |
@@ -1,111 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 540" width="960" height="540" role="img" aria-labelledby="ttl dsc">
|
||||
<title id="ttl">Distributed audio</title>
|
||||
<desc id="dsc">Music and speech sources feed one Snapcast server, which streams one clock-synchronised mix to eight Pi endpoints. Voice announcements arrive as a priority lane: duck the music, speak, restore.</desc>
|
||||
<style>
|
||||
text{font-family:ui-sans-serif,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;fill:#1b222b;font-size:15px}
|
||||
.hd{font-size:15px;font-weight:600}
|
||||
.sub{font-size:13px;fill:#5b6673}
|
||||
.cap{font-size:12px;fill:#5b6673;letter-spacing:.06em;text-transform:uppercase;font-weight:600}
|
||||
.big{font-size:19px;font-weight:600}
|
||||
.node{fill:#f4f6f8;stroke:#c3ccd6;stroke-width:1.4}
|
||||
.node-a{fill:#e9f1fd;stroke:#1a6bc4;stroke-width:1.4}
|
||||
.node-t{fill:#e6f4f2;stroke:#0f766e;stroke-width:1.4}
|
||||
.node-w{fill:#fdf1e3;stroke:#b45309;stroke-width:1.4}
|
||||
.zone{fill:none;stroke:#aab4c0;stroke-width:1.4;stroke-dasharray:7 6}
|
||||
.rule{stroke:#d0d7df;stroke-width:1}
|
||||
.ln{stroke:#6b7684;stroke-width:1.6;fill:none}
|
||||
.ln.d{stroke-dasharray:6 5}
|
||||
.ln.a{stroke:#1a6bc4}
|
||||
.ln.t{stroke:#0f766e}
|
||||
.ln.w{stroke:#b45309}
|
||||
.mN{fill:#6b7684}
|
||||
.mA{fill:#1a6bc4}
|
||||
.mT{fill:#0f766e}
|
||||
.mW{fill:#b45309}
|
||||
.dot{fill:#6b7684}
|
||||
.tick{stroke:#aab4c0;stroke-width:1.2}
|
||||
@media (prefers-color-scheme: dark){
|
||||
text{fill:#e7edf4}
|
||||
.sub,.cap{fill:#98a4b3}
|
||||
.node{fill:#182028;stroke:#35424f}
|
||||
.node-a{fill:#11253d;stroke:#5aa2f0}
|
||||
.node-t{fill:#0d2624;stroke:#38b3a6}
|
||||
.node-w{fill:#2a1d10;stroke:#d98b3f}
|
||||
.zone{stroke:#43525f}
|
||||
.rule{stroke:#2c3743}
|
||||
.ln{stroke:#8496a6}
|
||||
.ln.a{stroke:#5aa2f0}
|
||||
.ln.t{stroke:#38b3a6}
|
||||
.ln.w{stroke:#d98b3f}
|
||||
.mN{fill:#8496a6}
|
||||
.mA{fill:#5aa2f0}
|
||||
.mT{fill:#38b3a6}
|
||||
.mW{fill:#d98b3f}
|
||||
.dot{fill:#8496a6}
|
||||
.tick{stroke:#43525f}
|
||||
}
|
||||
</style>
|
||||
<defs>
|
||||
<marker id="aN" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mN" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
<marker id="aA" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mA" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
<marker id="aT" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mT" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
<marker id="aW" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mW" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
</defs>
|
||||
<text x="24" y="40" class="big">Distributed audio — sources, synchronous endpoints, voice injection</text>
|
||||
<rect x="24" y="92" width="200" height="120" rx="7" class="node "/>
|
||||
<text x="38" y="118" class="hd">Sources</text>
|
||||
<path d="M24,130 L224,130" class="rule"/>
|
||||
<text x="38" y="156" class="sub">Spotify → Mopidy</text>
|
||||
<text x="38" y="180" class="sub">Piper / MiniMax TTS</text>
|
||||
<text x="38" y="204" class="sub">announcements</text>
|
||||
<path d="M224,152 L268,152" class="ln " marker-end="url(#aN)"/>
|
||||
<rect x="268" y="92" width="210" height="120" rx="7" class="node "/>
|
||||
<text x="282" y="118" class="hd">Snapcast server</text>
|
||||
<text x="282" y="138" class="sub">one clock, one stream</text>
|
||||
<path d="M268,150 L478,150" class="rule"/>
|
||||
<text x="282" y="176" class="sub">per-client latency offset</text>
|
||||
<text x="282" y="198" class="sub">new clients join mid-stream</text>
|
||||
<path d="M478,152 L522,152" class="ln t" marker-end="url(#aT)"/>
|
||||
<rect x="522" y="74" width="414" height="156" rx="7" class="node "/>
|
||||
<text x="536" y="100" class="hd">8 endpoints — Pi 2W</text>
|
||||
<text x="536" y="120" class="sub">one stream, eight speakers, in sync</text>
|
||||
<path d="M522,132 L936,132" class="rule"/>
|
||||
<rect x="536" y="146" width="42" height="42" rx="7" class="node "/>
|
||||
<text x="557.0" y="172.0" class="hd" text-anchor="middle">1</text>
|
||||
<rect x="586" y="146" width="42" height="42" rx="7" class="node "/>
|
||||
<text x="607.0" y="172.0" class="hd" text-anchor="middle">2</text>
|
||||
<rect x="636" y="146" width="42" height="42" rx="7" class="node "/>
|
||||
<text x="657.0" y="172.0" class="hd" text-anchor="middle">3</text>
|
||||
<rect x="686" y="146" width="42" height="42" rx="7" class="node "/>
|
||||
<text x="707.0" y="172.0" class="hd" text-anchor="middle">4</text>
|
||||
<rect x="736" y="146" width="42" height="42" rx="7" class="node "/>
|
||||
<text x="757.0" y="172.0" class="hd" text-anchor="middle">5</text>
|
||||
<rect x="786" y="146" width="42" height="42" rx="7" class="node "/>
|
||||
<text x="807.0" y="172.0" class="hd" text-anchor="middle">6</text>
|
||||
<rect x="836" y="146" width="42" height="42" rx="7" class="node "/>
|
||||
<text x="857.0" y="172.0" class="hd" text-anchor="middle">7</text>
|
||||
<rect x="886" y="146" width="42" height="42" rx="7" class="node "/>
|
||||
<text x="907.0" y="172.0" class="hd" text-anchor="middle">8</text>
|
||||
<text x="536" y="214" class="sub">music in one room does not arrive in another room late</text>
|
||||
<rect x="24" y="262" width="470" height="152" rx="7" class="node node-t"/>
|
||||
<text x="38" y="288" class="hd">Voice injection — the part that is not just streaming</text>
|
||||
<rect x="38" y="304" width="136" height="38" rx="7" class="node "/>
|
||||
<text x="106.0" y="328.0" class="hd" text-anchor="middle">TTS audio in</text>
|
||||
<path d="M174,323 L200,323" class="ln t" marker-end="url(#aT)"/>
|
||||
<rect x="200" y="304" width="126" height="38" rx="7" class="node "/>
|
||||
<text x="263.0" y="328.0" class="hd" text-anchor="middle">duck music</text>
|
||||
<path d="M326,323 L352,323" class="ln t" marker-end="url(#aT)"/>
|
||||
<rect x="352" y="304" width="120" height="38" rx="7" class="node "/>
|
||||
<text x="412.0" y="328.0" class="hd" text-anchor="middle">announce</text>
|
||||
<text x="38" y="372" class="sub">then restore the music to its previous level</text>
|
||||
<text x="38" y="392" class="sub">an announcement is a priority lane over the music</text>
|
||||
<rect x="516" y="262" width="420" height="152" rx="7" class="node "/>
|
||||
<text x="530" y="288" class="hd">Why it is built this way</text>
|
||||
<text x="530" y="312" class="sub">· one source of time, not one source of audio</text>
|
||||
<text x="530" y="336" class="sub">· endpoints are disposable; the server holds the state</text>
|
||||
<text x="530" y="360" class="sub">· voice reuses the same endpoint fleet</text>
|
||||
<text x="530" y="384" class="sub">· ducking is a mix decision, not a volume hack</text>
|
||||
<text x="24" y="452" class="sub">Eight synchronised endpoints with music ducking for voice.</text>
|
||||
<text x="24" y="472" class="sub">The endpoints are the cheapest part of the system, which is the point.</text>
|
||||
<text x="936" y="500" class="sub" text-anchor="end">confidence: delivered · 8 endpoints · Pi 2W</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.7 KiB |
@@ -1,92 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 540" width="960" height="540" role="img" aria-labelledby="ttl dsc">
|
||||
<title id="ttl">Media pipeline</title>
|
||||
<desc id="dsc">Five stages from download to library: fingerprinting gives every file a natural key so re-runs converge, audit verifies the copy, and review is a deliberate human gate before anything is published.</desc>
|
||||
<style>
|
||||
text{font-family:ui-sans-serif,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;fill:#1b222b;font-size:15px}
|
||||
.hd{font-size:15px;font-weight:600}
|
||||
.sub{font-size:13px;fill:#5b6673}
|
||||
.cap{font-size:12px;fill:#5b6673;letter-spacing:.06em;text-transform:uppercase;font-weight:600}
|
||||
.big{font-size:19px;font-weight:600}
|
||||
.node{fill:#f4f6f8;stroke:#c3ccd6;stroke-width:1.4}
|
||||
.node-a{fill:#e9f1fd;stroke:#1a6bc4;stroke-width:1.4}
|
||||
.node-t{fill:#e6f4f2;stroke:#0f766e;stroke-width:1.4}
|
||||
.node-w{fill:#fdf1e3;stroke:#b45309;stroke-width:1.4}
|
||||
.zone{fill:none;stroke:#aab4c0;stroke-width:1.4;stroke-dasharray:7 6}
|
||||
.rule{stroke:#d0d7df;stroke-width:1}
|
||||
.ln{stroke:#6b7684;stroke-width:1.6;fill:none}
|
||||
.ln.d{stroke-dasharray:6 5}
|
||||
.ln.a{stroke:#1a6bc4}
|
||||
.ln.t{stroke:#0f766e}
|
||||
.ln.w{stroke:#b45309}
|
||||
.mN{fill:#6b7684}
|
||||
.mA{fill:#1a6bc4}
|
||||
.mT{fill:#0f766e}
|
||||
.mW{fill:#b45309}
|
||||
.dot{fill:#6b7684}
|
||||
.tick{stroke:#aab4c0;stroke-width:1.2}
|
||||
@media (prefers-color-scheme: dark){
|
||||
text{fill:#e7edf4}
|
||||
.sub,.cap{fill:#98a4b3}
|
||||
.node{fill:#182028;stroke:#35424f}
|
||||
.node-a{fill:#11253d;stroke:#5aa2f0}
|
||||
.node-t{fill:#0d2624;stroke:#38b3a6}
|
||||
.node-w{fill:#2a1d10;stroke:#d98b3f}
|
||||
.zone{stroke:#43525f}
|
||||
.rule{stroke:#2c3743}
|
||||
.ln{stroke:#8496a6}
|
||||
.ln.a{stroke:#5aa2f0}
|
||||
.ln.t{stroke:#38b3a6}
|
||||
.ln.w{stroke:#d98b3f}
|
||||
.mN{fill:#8496a6}
|
||||
.mA{fill:#5aa2f0}
|
||||
.mT{fill:#38b3a6}
|
||||
.mW{fill:#d98b3f}
|
||||
.dot{fill:#8496a6}
|
||||
.tick{stroke:#43525f}
|
||||
}
|
||||
</style>
|
||||
<defs>
|
||||
<marker id="aN" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mN" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
<marker id="aA" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mA" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
<marker id="aT" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mT" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
<marker id="aW" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mW" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
</defs>
|
||||
<text x="24" y="40" class="big">Media pipeline — download, identify, verify, review, publish</text>
|
||||
<rect x="24" y="96" width="154" height="84" rx="7" class="node node-a"/>
|
||||
<text x="101.0" y="136.0" class="hd" text-anchor="middle">download</text>
|
||||
<text x="101.0" y="154.0" class="sub" text-anchor="middle">acquire the file</text>
|
||||
<text x="101" y="168" class="cap" text-anchor="middle">stage 1</text>
|
||||
<path d="M178,138 L214,138" class="ln " marker-end="url(#aN)"/>
|
||||
<rect x="214" y="96" width="154" height="84" rx="7" class="node "/>
|
||||
<text x="291.0" y="136.0" class="hd" text-anchor="middle">fingerprint</text>
|
||||
<text x="291.0" y="154.0" class="sub" text-anchor="middle">identify what it is</text>
|
||||
<text x="291" y="168" class="cap" text-anchor="middle">stage 2</text>
|
||||
<path d="M368,138 L404,138" class="ln " marker-end="url(#aN)"/>
|
||||
<rect x="404" y="96" width="154" height="84" rx="7" class="node "/>
|
||||
<text x="481.0" y="136.0" class="hd" text-anchor="middle">audit</text>
|
||||
<text x="481.0" y="154.0" class="sub" text-anchor="middle">verify the copy</text>
|
||||
<text x="481" y="168" class="cap" text-anchor="middle">stage 3</text>
|
||||
<path d="M558,138 L594,138" class="ln " marker-end="url(#aN)"/>
|
||||
<rect x="594" y="96" width="154" height="84" rx="7" class="node node-w"/>
|
||||
<text x="671.0" y="136.0" class="hd" text-anchor="middle">review</text>
|
||||
<text x="671.0" y="154.0" class="sub" text-anchor="middle">human gate</text>
|
||||
<text x="671" y="168" class="cap" text-anchor="middle">stage 4</text>
|
||||
<path d="M748,138 L784,138" class="ln " marker-end="url(#aN)"/>
|
||||
<rect x="784" y="96" width="154" height="84" rx="7" class="node node-a"/>
|
||||
<text x="861.0" y="136.0" class="hd" text-anchor="middle">library</text>
|
||||
<text x="861.0" y="154.0" class="sub" text-anchor="middle">publish + serve</text>
|
||||
<text x="861" y="168" class="cap" text-anchor="middle">stage 5</text>
|
||||
<rect x="214" y="236" width="342" height="96" rx="7" class="node node-w"/>
|
||||
<text x="228" y="262" class="hd">Gate, not a queue</text>
|
||||
<text x="228" y="284" class="sub">a file that fails fingerprint or audit</text>
|
||||
<text x="228" y="304" class="sub">stops where a person can see it</text>
|
||||
<rect x="590" y="236" width="346" height="96" rx="7" class="node "/>
|
||||
<text x="604" y="262" class="hd">Idempotent by identity</text>
|
||||
<text x="604" y="284" class="sub">the fingerprint is the natural key, so a</text>
|
||||
<text x="604" y="304" class="sub">re-run converges instead of duplicating</text>
|
||||
<rect x="24" y="356" width="912" height="92" rx="7" class="node "/>
|
||||
<text x="38" y="382" class="hd">The same shape at 10 files and at 10,000</text>
|
||||
<text x="38" y="404" class="sub">every stage is a separate step with its own failure mode, so a bad stage can be re-run alone</text>
|
||||
<text x="38" y="426" class="sub">the review stage is deliberately manual: automating it would move the risk, not remove it</text>
|
||||
<text x="24" y="480" class="sub">Encoding, thumbnailing and fingerprinting at scale, with a human gate before anything is published.</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.6 KiB |
@@ -1,95 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 540" width="960" height="540" role="img" aria-labelledby="ttl dsc">
|
||||
<title id="ttl">Cost and resource engineering</title>
|
||||
<desc id="dsc">A bar chart of the per-service memory caps drawn to scale against the 1024 MiB ceiling, totalling 970 MiB across four containers, beside the rules that hold the ceiling: equal memory and swap limits, loopback binding, a hard container count and a cost ceiling.</desc>
|
||||
<style>
|
||||
text{font-family:ui-sans-serif,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;fill:#1b222b;font-size:15px}
|
||||
.hd{font-size:15px;font-weight:600}
|
||||
.sub{font-size:13px;fill:#5b6673}
|
||||
.cap{font-size:12px;fill:#5b6673;letter-spacing:.06em;text-transform:uppercase;font-weight:600}
|
||||
.big{font-size:19px;font-weight:600}
|
||||
.node{fill:#f4f6f8;stroke:#c3ccd6;stroke-width:1.4}
|
||||
.node-a{fill:#e9f1fd;stroke:#1a6bc4;stroke-width:1.4}
|
||||
.node-t{fill:#e6f4f2;stroke:#0f766e;stroke-width:1.4}
|
||||
.node-w{fill:#fdf1e3;stroke:#b45309;stroke-width:1.4}
|
||||
.zone{fill:none;stroke:#aab4c0;stroke-width:1.4;stroke-dasharray:7 6}
|
||||
.rule{stroke:#d0d7df;stroke-width:1}
|
||||
.ln{stroke:#6b7684;stroke-width:1.6;fill:none}
|
||||
.ln.d{stroke-dasharray:6 5}
|
||||
.ln.a{stroke:#1a6bc4}
|
||||
.ln.t{stroke:#0f766e}
|
||||
.ln.w{stroke:#b45309}
|
||||
.mN{fill:#6b7684}
|
||||
.mA{fill:#1a6bc4}
|
||||
.mT{fill:#0f766e}
|
||||
.mW{fill:#b45309}
|
||||
.dot{fill:#6b7684}
|
||||
.tick{stroke:#aab4c0;stroke-width:1.2}
|
||||
@media (prefers-color-scheme: dark){
|
||||
text{fill:#e7edf4}
|
||||
.sub,.cap{fill:#98a4b3}
|
||||
.node{fill:#182028;stroke:#35424f}
|
||||
.node-a{fill:#11253d;stroke:#5aa2f0}
|
||||
.node-t{fill:#0d2624;stroke:#38b3a6}
|
||||
.node-w{fill:#2a1d10;stroke:#d98b3f}
|
||||
.zone{stroke:#43525f}
|
||||
.rule{stroke:#2c3743}
|
||||
.ln{stroke:#8496a6}
|
||||
.ln.a{stroke:#5aa2f0}
|
||||
.ln.t{stroke:#38b3a6}
|
||||
.ln.w{stroke:#d98b3f}
|
||||
.mN{fill:#8496a6}
|
||||
.mA{fill:#5aa2f0}
|
||||
.mT{fill:#38b3a6}
|
||||
.mW{fill:#d98b3f}
|
||||
.dot{fill:#8496a6}
|
||||
.tick{stroke:#43525f}
|
||||
}
|
||||
</style>
|
||||
<defs>
|
||||
<marker id="aN" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mN" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
<marker id="aA" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mA" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
<marker id="aT" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mT" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
<marker id="aW" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mW" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
</defs>
|
||||
<text x="24" y="40" class="big">Cost and resource engineering — the budget, measured</text>
|
||||
<text x="24" y="68" class="sub">Every service declares a ceiling. The ceiling is a measurement, not an estimate: .13 already runs on 2.3 GiB free with 8.1 GiB of swap in use.</text>
|
||||
<text x="40" y="104" class="cap">per-service cap (MiB)</text>
|
||||
<text x="40" y="136" class="sub">Go app</text>
|
||||
<rect x="200" y="112" width="33.6" height="30" rx="5" class="bar node-a"/>
|
||||
<text x="243.6" y="133" class="sub">120 MiB</text>
|
||||
<text x="40" y="182" class="sub">Qdrant</text>
|
||||
<rect x="200" y="158" width="112.00000000000001" height="30" rx="5" class="bar node-t"/>
|
||||
<text x="322.0" y="179" class="sub">400 MiB</text>
|
||||
<text x="40" y="228" class="sub">Prefect worker</text>
|
||||
<rect x="200" y="204" width="42.00000000000001" height="30" rx="5" class="bar "/>
|
||||
<text x="252.0" y="225" class="sub">150 MiB</text>
|
||||
<text x="40" y="274" class="sub">Guardrail (if local)</text>
|
||||
<rect x="200" y="250" width="84.00000000000001" height="30" rx="5" class="bar "/>
|
||||
<text x="294.0" y="271" class="sub">300 MiB</text>
|
||||
<path d="M200,352 L486.72,352" class="tick"/>
|
||||
<text x="200" y="374" class="sub">0</text>
|
||||
<text x="486.72" y="374" class="sub" text-anchor="end">1024 MiB = the whole new footprint</text>
|
||||
<path d="M200,340 L200,362" class="tick"/>
|
||||
<rect x="200" y="372" width="287" height="56" rx="7" class="node node-t"/>
|
||||
<text x="343.5" y="398.0" class="hd" text-anchor="middle">970 MiB of 1024 MiB</text>
|
||||
<text x="343.5" y="416.0" class="sub" text-anchor="middle">4 containers · measured, not asserted</text>
|
||||
<rect x="520" y="92" width="416" height="336" rx="10" class="zone"/>
|
||||
<text x="532" y="112" class="cap">rules that hold the ceiling</text>
|
||||
<rect x="536" y="118" width="384" height="52" rx="7" class="node "/>
|
||||
<text x="728.0" y="142.0" class="hd" text-anchor="middle">mem_limit == memswap_limit</text>
|
||||
<text x="728.0" y="160.0" class="sub" text-anchor="middle">a service cannot buy headroom from host swap</text>
|
||||
<rect x="536" y="180" width="384" height="52" rx="7" class="node "/>
|
||||
<text x="728.0" y="204.0" class="hd" text-anchor="middle">bind 127.0.0.1</text>
|
||||
<text x="728.0" y="222.0" class="sub" text-anchor="middle">Caddy is the only public listener</text>
|
||||
<rect x="536" y="242" width="384" height="52" rx="7" class="node "/>
|
||||
<text x="728.0" y="266.0" class="hd" text-anchor="middle">4 containers, not 14</text>
|
||||
<text x="728.0" y="284.0" class="sub" text-anchor="middle">one process per job, no sidecars</text>
|
||||
<rect x="536" y="304" width="384" height="52" rx="7" class="node "/>
|
||||
<text x="728.0" y="328.0" class="hd" text-anchor="middle">cost has a ceiling too</text>
|
||||
<text x="728.0" y="346.0" class="sub" text-anchor="middle">median session ≤ US$0.01 · monthly refusal</text>
|
||||
<text x="536" y="384" class="sub">verified with docker inspect, not with intent</text>
|
||||
<rect x="24" y="446" width="912" height="56" rx="7" class="node node-w"/>
|
||||
<text x="480.0" y="472.0" class="hd" text-anchor="middle">Host reality on .13: 15 GiB total · 2.3 GiB free · 8.1 GiB swap in use</text>
|
||||
<text x="480.0" y="490.0" class="sub" text-anchor="middle">exceeding the budget destabilises services people rely on</text>
|
||||
<text x="24" y="524" class="sub">Source: reference/stack.md, hosts measured 2026-10-17. The caps are the contract; the bar chart is the contract drawn to scale.</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.0 KiB |
@@ -1,106 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 540" width="960" height="540" role="img" aria-labelledby="ttl dsc">
|
||||
<title id="ttl">Era timeline</title>
|
||||
<desc id="dsc">Five consecutive platform eras from Flash and ActionScript through jQuery and PHP, React and TypeScript, server-driven Go with HTMX, to agent systems, on an axis from 2000 to 2026. Era boundaries are approximate; the bands show order.</desc>
|
||||
<style>
|
||||
text{font-family:ui-sans-serif,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;fill:#1b222b;font-size:15px}
|
||||
.hd{font-size:15px;font-weight:600}
|
||||
.sub{font-size:13px;fill:#5b6673}
|
||||
.cap{font-size:12px;fill:#5b6673;letter-spacing:.06em;text-transform:uppercase;font-weight:600}
|
||||
.big{font-size:19px;font-weight:600}
|
||||
.node{fill:#f4f6f8;stroke:#c3ccd6;stroke-width:1.4}
|
||||
.node-a{fill:#e9f1fd;stroke:#1a6bc4;stroke-width:1.4}
|
||||
.node-t{fill:#e6f4f2;stroke:#0f766e;stroke-width:1.4}
|
||||
.node-w{fill:#fdf1e3;stroke:#b45309;stroke-width:1.4}
|
||||
.zone{fill:none;stroke:#aab4c0;stroke-width:1.4;stroke-dasharray:7 6}
|
||||
.rule{stroke:#d0d7df;stroke-width:1}
|
||||
.ln{stroke:#6b7684;stroke-width:1.6;fill:none}
|
||||
.ln.d{stroke-dasharray:6 5}
|
||||
.ln.a{stroke:#1a6bc4}
|
||||
.ln.t{stroke:#0f766e}
|
||||
.ln.w{stroke:#b45309}
|
||||
.mN{fill:#6b7684}
|
||||
.mA{fill:#1a6bc4}
|
||||
.mT{fill:#0f766e}
|
||||
.mW{fill:#b45309}
|
||||
.dot{fill:#6b7684}
|
||||
.tick{stroke:#aab4c0;stroke-width:1.2}
|
||||
@media (prefers-color-scheme: dark){
|
||||
text{fill:#e7edf4}
|
||||
.sub,.cap{fill:#98a4b3}
|
||||
.node{fill:#182028;stroke:#35424f}
|
||||
.node-a{fill:#11253d;stroke:#5aa2f0}
|
||||
.node-t{fill:#0d2624;stroke:#38b3a6}
|
||||
.node-w{fill:#2a1d10;stroke:#d98b3f}
|
||||
.zone{stroke:#43525f}
|
||||
.rule{stroke:#2c3743}
|
||||
.ln{stroke:#8496a6}
|
||||
.ln.a{stroke:#5aa2f0}
|
||||
.ln.t{stroke:#38b3a6}
|
||||
.ln.w{stroke:#d98b3f}
|
||||
.mN{fill:#8496a6}
|
||||
.mA{fill:#5aa2f0}
|
||||
.mT{fill:#38b3a6}
|
||||
.mW{fill:#d98b3f}
|
||||
.dot{fill:#8496a6}
|
||||
.tick{stroke:#43525f}
|
||||
}
|
||||
</style>
|
||||
<defs>
|
||||
<marker id="aN" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mN" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
<marker id="aA" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mA" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
<marker id="aT" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mT" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
<marker id="aW" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mW" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
</defs>
|
||||
<text x="24" y="40" class="big">Twenty-five years, five platform shifts</text>
|
||||
<rect x="28" y="96" width="176" height="118" rx="7" class="node"/>
|
||||
<text x="42" y="126" class="cap">01</text>
|
||||
<text x="42" y="144" class="hd">Flash / ActionScript</text>
|
||||
<text x="42" y="168" class="sub">classes, imports,</text>
|
||||
<text x="42" y="186" class="sub">encapsulation</text>
|
||||
<text x="42" y="212" class="sub">client wall</text>
|
||||
<path d="M205,155 L211,155" class="ln"/>
|
||||
<rect x="212" y="96" width="176" height="118" rx="7" class="node"/>
|
||||
<text x="226" y="126" class="cap">02</text>
|
||||
<text x="226" y="144" class="hd">jQuery + PHP</text>
|
||||
<text x="226" y="168" class="sub">media pipelines,</text>
|
||||
<text x="226" y="186" class="sub">FFmpeg, ImageMagick</text>
|
||||
<text x="226" y="212" class="sub">delivery at scale</text>
|
||||
<path d="M389,155 L395,155" class="ln"/>
|
||||
<rect x="396" y="96" width="176" height="118" rx="7" class="node"/>
|
||||
<text x="410" y="126" class="cap">03</text>
|
||||
<text x="410" y="144" class="hd">React + TypeScript</text>
|
||||
<text x="410" y="168" class="sub">typed service</text>
|
||||
<text x="410" y="186" class="sub">layers, tRPC</text>
|
||||
<text x="410" y="212" class="sub">typed front ends</text>
|
||||
<path d="M573,155 L579,155" class="ln"/>
|
||||
<rect x="580" y="96" width="176" height="118" rx="7" class="node node-a"/>
|
||||
<text x="594" y="126" class="cap">04</text>
|
||||
<text x="594" y="144" class="hd">Server-driven</text>
|
||||
<text x="594" y="168" class="sub">Go + templ + HTMX,</text>
|
||||
<text x="594" y="186" class="sub">SQLite</text>
|
||||
<text x="594" y="212" class="sub">this site</text>
|
||||
<path d="M757,155 L763,155" class="ln"/>
|
||||
<rect x="764" y="96" width="176" height="118" rx="7" class="node node-t"/>
|
||||
<text x="778" y="126" class="cap">05</text>
|
||||
<text x="778" y="144" class="hd">Agent systems</text>
|
||||
<text x="778" y="168" class="sub">harnesses, routing,</text>
|
||||
<text x="778" y="186" class="sub">retrieval</text>
|
||||
<text x="778" y="212" class="sub">AI estate</text>
|
||||
<path d="M28,268 L932,268" class="ln"/>
|
||||
<path d="M28,262 L28,274" class="tick"/>
|
||||
<text x="28" y="292" class="hd" text-anchor="start">2000</text>
|
||||
<path d="M546,262 L546,274" class="tick"/>
|
||||
<text x="546" y="292" class="hd" text-anchor="middle">2020</text>
|
||||
<path d="M932,262 L932,274" class="tick"/>
|
||||
<text x="932" y="292" class="hd" text-anchor="end">2026</text>
|
||||
<text x="28" y="314" class="sub">employed delivery</text>
|
||||
<text x="546" y="314" class="sub">independent engineering practice</text>
|
||||
<text x="932" y="314" class="sub" text-anchor="end">now</text>
|
||||
<rect x="28" y="348" width="904" height="118" rx="7" class="node "/>
|
||||
<text x="42" y="376" class="hd">What the five eras have in common</text>
|
||||
<text x="42" y="400" class="sub">· every era was learned by shipping with it, not by reading about it</text>
|
||||
<text x="42" y="424" class="sub">· the artefacts survive: a Flash class, an easing engine, a media pipeline, a typed service layer, a 33-line derivation</text>
|
||||
<text x="42" y="448" class="sub">· the current era is the first where platform, agents and edge hardware are one system</text>
|
||||
<text x="28" y="500" class="sub">Era boundaries are approximate: employed 2000–2020 per the résumé, independent practice 2020–.</text>
|
||||
<text x="932" y="500" class="sub" text-anchor="end">six code exhibits span eras 1–5</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.9 KiB |
@@ -1,80 +0,0 @@
|
||||
# Phase 7.2 — diagrams
|
||||
|
||||
Eight diagrams, one visual language, light and dark in one file each.
|
||||
|
||||
Authored 2026-10-26. Source of truth is the SVG files themselves: each one is
|
||||
self-contained, with no external font, image or script reference.
|
||||
|
||||
## The language
|
||||
|
||||
| Part | Rule |
|
||||
|---|---|
|
||||
| Canvas | `viewBox="0 0 960 540"`, `width`/`height` set so a bare file view is 1× |
|
||||
| Type | 19 px section title · 15 px heading · 13 px label · 12 px caption, all `ui-sans-serif` system stack |
|
||||
| Boxes | `rx=7`, 1.4 px stroke, four semantic tones: neutral, accent (the thing being described), teal (store / gateway), amber (guard, failure, limit) |
|
||||
| Arrows | solid = data or control flow, dashed = trust boundary or approximate relationship |
|
||||
| Colour | greys carry structure; one blue accent, one teal, one amber, nothing else |
|
||||
| Text | every `<text>` is real text in the SVG, so it scales, selects and reads to a screen reader |
|
||||
|
||||
Every file carries a **byte-identical `<style>` block**. That is the whole of the
|
||||
consistency guarantee: there is one palette, defined once, repeated verbatim.
|
||||
A check that compares the eight style blocks and finds one distinct value is the
|
||||
evidence that the eight diagrams share a language.
|
||||
|
||||
## Both themes in one file
|
||||
|
||||
The `<style>` block defines the light palette, then repeats the same selectors
|
||||
inside `@media (prefers-color-scheme: dark)`. No duplicated files, no theme
|
||||
toggle needed in the page, no chance of a light and a dark copy drifting apart.
|
||||
|
||||
If the site later grows a manual theme toggle, the SVG must be inlined into the
|
||||
page (not `<img>`) so page CSS can override the custom properties. Until then
|
||||
`<img>` is correct: the file follows the reader's operating system.
|
||||
|
||||
| Role | Light | Dark |
|
||||
|---|---|---|
|
||||
| ink (text) | `#1b222b` | `#e7edf4` |
|
||||
| panel (box fill) | `#f4f6f8` | `#182028` |
|
||||
| muted (labels) | `#5b6673` | `#98a4b3` |
|
||||
| accent | `#1a6bc4` | `#5aa2f0` |
|
||||
| teal | `#0f766e` | `#38b3a6` |
|
||||
| amber | `#b45309` | `#d98b3f` |
|
||||
| box stroke | `#c3ccd6` | `#35424f` |
|
||||
|
||||
## Mobile
|
||||
|
||||
A 960 px schematic cannot be read at 360 px. The presentation contract is:
|
||||
|
||||
- wrap the figure in a horizontally scrollable container
|
||||
- `svg { display:block; width:100%; min-width:900px; height:auto }`
|
||||
|
||||
At 1× (container ≥ 960 px) the type renders at 15 px / 13 px. At the 900 px
|
||||
minimum inside a 390 px container it renders at 14.1 px / 12.2 px — the same
|
||||
order as body text, and the figure scrolls rather than shrinking into
|
||||
illegibility. A figure that is scaled to 360 px instead reaches 5 px type and is
|
||||
not readable; that is why the contract is a requirement and not a suggestion.
|
||||
|
||||
## Accessibility
|
||||
|
||||
Each file carries `role="img"` and both `<title>` and `<desc>`. The `<desc>` is
|
||||
the alternative text — 190–285 characters, describing what the diagram asserts
|
||||
rather than naming its parts. If a template sets `alt=""` on the `<img>`, the
|
||||
`<title>`/`<desc>` are not read out; the template must supply `alt` from the
|
||||
manifest's `alt:` field.
|
||||
|
||||
## Files
|
||||
|
||||
| # | File | Area | Answers |
|
||||
|---|---|---|---|
|
||||
| 1 | `01-homelab-topology.svg` | platform | where it runs, and where the trust boundaries are |
|
||||
| 2 | `02-agent-architecture.svg` | ai | how agents are organised and why one gateway |
|
||||
| 3 | `03-rag-pipeline.svg` | ai | which side of the pipeline is AI and which side is not |
|
||||
| 4 | `04-edge-architecture.svg` | edge | why devices never talk to each other |
|
||||
| 5 | `05-distributed-audio.svg` | edge | how eight speakers stay in sync, and how voice gets in |
|
||||
| 6 | `06-media-pipeline.svg` | products | where the human gate sits in a media pipeline |
|
||||
| 7 | `07-cost-resource-engineering.svg` | platform | the memory ceiling, drawn to scale |
|
||||
| 8 | `08-era-timeline.svg` | history | five platform shifts and what survived them |
|
||||
|
||||
Facts behind them come from `reference/stack.md`, `reference/content-map.md` and
|
||||
`docs/phase-3-deploy.md`. Diagram 7 is numbers from a measurement, not an
|
||||
illustration: if the caps change, the bars change.
|
||||
@@ -1,82 +0,0 @@
|
||||
# Phase 7.2 — diagram manifest
|
||||
#
|
||||
# Machine-readable index for the templates. `alt` is copied verbatim from each
|
||||
# file's <desc>, so the text a screen reader gets and the text in this manifest
|
||||
# are the same string.
|
||||
version: 1
|
||||
authored: 2026-10-26
|
||||
viewbox: "0 0 960 540"
|
||||
themes: [light, dark] # both resolved from one file via prefers-color-scheme
|
||||
format: svg-text # real text nodes, not glyph outlines
|
||||
external_refs: none # no fonts, images or scripts referenced
|
||||
presentation:
|
||||
desktop: {css_width: "100%", rendered_type_px: [15, 13]}
|
||||
mobile:
|
||||
wrap: "overflow-x:auto"
|
||||
css: "svg{display:block;width:100%;min-width:900px;height:auto}"
|
||||
rendered_type_px: [14.1, 12.2]
|
||||
diagrams:
|
||||
- id: platform-homelab-topology
|
||||
file: 01-homelab-topology.svg
|
||||
area: platform
|
||||
level: 2
|
||||
title: "Homelab topology"
|
||||
answers: "where it runs, and where the trust boundaries are"
|
||||
alt: "Three hosts with their roles and the trust boundaries between them: public internet reaches Caddy on .35, which forwards over the LAN to services bound to 127.0.0.1 on .13. .27 is build and dev with no public listeners."
|
||||
bytes: 7079
|
||||
- id: ai-agent-architecture
|
||||
file: 02-agent-architecture.svg
|
||||
area: ai
|
||||
level: 2
|
||||
title: "Agent architecture"
|
||||
answers: "how the agent estate is organised, and why there is one gateway"
|
||||
alt: "The operator reaches a fleet of agent sessions through one inbox. Paperclip is the control plane and delegates to peer projects, which answer the Hub read-only. Every model call in the estate passes through one gateway, OmniRoute."
|
||||
bytes: 6027
|
||||
- id: ai-rag-pipeline
|
||||
file: 03-rag-pipeline.svg
|
||||
area: ai
|
||||
level: 2
|
||||
title: "Retrieval pipeline"
|
||||
answers: "which side of the pipeline is AI and which side is not"
|
||||
alt: "Left, offline ingest: content files are validated, chunked with metadata, embedded and upserted into Qdrant, while the same files render to static pages with no AI in the path. Right, per-visitor query: guardrail, query graph, filtered retrieval, then a streamed answer with citations."
|
||||
bytes: 7010
|
||||
- id: edge-architecture
|
||||
file: 04-edge-architecture.svg
|
||||
area: edge
|
||||
level: 2
|
||||
title: "Edge architecture"
|
||||
answers: "why devices never talk to each other directly"
|
||||
alt: "Constrained devices publish over LoRa, Zigbee or WiFi to a single MQTT broker, which feeds one automation layer that drives actuation. Retained configuration, telemetry and last-will presence let devices recover unattended."
|
||||
bytes: 6896
|
||||
- id: edge-distributed-audio
|
||||
file: 05-distributed-audio.svg
|
||||
area: edge
|
||||
level: 2
|
||||
title: "Distributed audio"
|
||||
answers: "how eight endpoints stay in sync, and how voice gets in"
|
||||
alt: "Music and speech sources feed one Snapcast server, which streams one clock-synchronised mix to eight Pi endpoints. Voice announcements arrive as a priority lane: duck the music, speak, restore."
|
||||
bytes: 6833
|
||||
- id: products-media-pipeline
|
||||
file: 06-media-pipeline.svg
|
||||
area: products
|
||||
level: 2
|
||||
title: "Media pipeline"
|
||||
answers: "where the human gate sits in a media pipeline"
|
||||
alt: "Five stages from download to library: fingerprinting gives every file a natural key so re-runs converge, audit verifies the copy, and review is a deliberate human gate before anything is published."
|
||||
bytes: 5694
|
||||
- id: platform-cost-resource-engineering
|
||||
file: 07-cost-resource-engineering.svg
|
||||
area: platform
|
||||
level: 2
|
||||
title: "Cost and resource engineering"
|
||||
answers: "the memory ceiling, drawn to scale from measured caps"
|
||||
alt: "A bar chart of the per-service memory caps drawn to scale against the 1024 MiB ceiling, totalling 970 MiB across four containers, beside the rules that hold the ceiling: equal memory and swap limits, loopback binding, a hard container count and a cost ceiling."
|
||||
bytes: 6151
|
||||
- id: history-era-timeline
|
||||
file: 08-era-timeline.svg
|
||||
area: history
|
||||
level: 2
|
||||
title: "Era timeline"
|
||||
answers: "five platform shifts and what survived them"
|
||||
alt: "Five consecutive platform eras from Flash and ActionScript through jQuery and PHP, React and TypeScript, server-driven Go with HTMX, to agent systems, on an axis from 2000 to 2026. Era boundaries are approximate; the bands show order."
|
||||
bytes: 6039
|
||||
@@ -1,125 +0,0 @@
|
||||
# Phase 7.1 — the client wall
|
||||
#
|
||||
# Decision: a typographic wall, not a logo collage. 45 entries, 44 distinct names, one
|
||||
# typeface, one size, an even grid. Nothing is an image, so nothing can fail to
|
||||
# load, and no name is more prominent than another.
|
||||
#
|
||||
# The Simple Icons audit below was performed for Phase 7. Ten marks exist. They
|
||||
# are NOT enabled in this file: putting a client's mark on a public portfolio is
|
||||
# a permission decision, not a design decision, so `mark: none` for every entry
|
||||
# until Sam says otherwise.
|
||||
version: 1
|
||||
authored: 2026-10-26
|
||||
render: typographic
|
||||
mark_policy:
|
||||
default: none
|
||||
enable: "ask Sam before enabling any commercial mark; availability is recorded, permission is not implied"
|
||||
icon_set: "Simple Icons (CC0 paths, trademarks remain with their owners)"
|
||||
counts:
|
||||
agencies_listed: 22
|
||||
brands_listed: 23
|
||||
entries_listed: 45
|
||||
distinct_names: 44
|
||||
claimed_by_plan: 46
|
||||
note: >
|
||||
The plan says 46 names and 23 agencies. The source list contains 22 agencies
|
||||
and 23 brands, and 'Pearsons' appears in both groups, so the distinct count
|
||||
is 44. No name has been invented to reach 46; the difference is flagged for
|
||||
Sam rather than papered over.
|
||||
corrections_applied:
|
||||
- "Diajio -> Diageo (reference/content-map.md: factual correction, do not undo)"
|
||||
- "AutoCAD listed as the product; the mark that exists is Autodesk's"
|
||||
client_wall:
|
||||
agencies:
|
||||
- name: "Clemenger"
|
||||
mark: none
|
||||
- name: "Agency Republic"
|
||||
mark: none
|
||||
- name: "Brand Union"
|
||||
mark: none
|
||||
- name: "Jackpot Joy"
|
||||
mark: none
|
||||
- name: "Bryce Ford"
|
||||
mark: none
|
||||
- name: "RMG Connect"
|
||||
mark: none
|
||||
- name: "Sporting Index"
|
||||
mark: none
|
||||
- name: "Adventis Digital"
|
||||
mark: none
|
||||
- name: "Trinity St"
|
||||
mark: none
|
||||
- name: "Inferno"
|
||||
mark: none
|
||||
- name: "SAS Design"
|
||||
mark: none
|
||||
- name: "Ogilvy"
|
||||
mark: none
|
||||
- name: "Wunderman"
|
||||
mark: none
|
||||
- name: "Illumina"
|
||||
mark: none
|
||||
- name: "Barkers"
|
||||
mark: none
|
||||
- name: "Mook"
|
||||
mark: none
|
||||
- name: "Pearsons"
|
||||
mark: none # available in Simple Icons; not enabled
|
||||
- name: "Penguin"
|
||||
mark: none
|
||||
- name: "Draft London"
|
||||
mark: none
|
||||
- name: "Behan Consulting"
|
||||
mark: none
|
||||
- name: "MCM Entertainment"
|
||||
mark: none
|
||||
- name: "Carsales"
|
||||
mark: none
|
||||
brands:
|
||||
- name: "Carlton Draught"
|
||||
mark: none
|
||||
- name: "Diageo"
|
||||
mark: none
|
||||
- name: "KPMG"
|
||||
mark: none
|
||||
- name: "McDonald's"
|
||||
mark: none # available in Simple Icons; not enabled
|
||||
- name: "Accenture"
|
||||
mark: none # available in Simple Icons; not enabled
|
||||
- name: "BBC National Archive"
|
||||
mark: none
|
||||
- name: "Bank of America"
|
||||
mark: none # available in Simple Icons; not enabled
|
||||
- name: "War of the Worlds"
|
||||
mark: none
|
||||
- name: "Madonna (Hard Candy)"
|
||||
mark: none
|
||||
- name: "PlayStation"
|
||||
mark: none # available in Simple Icons; not enabled
|
||||
- name: "HSBC"
|
||||
mark: none # available in Simple Icons; not enabled
|
||||
- name: "DHL"
|
||||
mark: none # available in Simple Icons; not enabled
|
||||
- name: "Reuters"
|
||||
mark: none
|
||||
- name: "Volvic"
|
||||
mark: none
|
||||
- name: "AutoCAD"
|
||||
mark: none # available in Simple Icons; not enabled
|
||||
attribution: "Autodesk" if n == "AutoCAD"
|
||||
- name: "Channel 4"
|
||||
mark: none # available in Simple Icons; not enabled
|
||||
- name: "Pearsons"
|
||||
mark: none # available in Simple Icons; not enabled
|
||||
- name: "Shell"
|
||||
mark: none # available in Simple Icons; not enabled
|
||||
- name: "Stella Artois"
|
||||
mark: none
|
||||
- name: "Baileys Irish Cream"
|
||||
mark: none
|
||||
- name: "Parkgate Homes"
|
||||
mark: none
|
||||
- name: "Carlton Dry"
|
||||
mark: none
|
||||
- name: "Cottees Cordial"
|
||||
mark: none
|
||||
28
deploy/Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
||||
# resume — runtime image (Phase 3 §3.2).
|
||||
#
|
||||
# Built by deploy/deploy.sh from a release directory, with the prebuilt static
|
||||
# Go binary already in place. No Go toolchain, no network and no package install
|
||||
# at build time: the image is `alpine` + the release payload, nothing else.
|
||||
#
|
||||
# The binary must be statically linked (deploy.sh's fallback build sets
|
||||
# CGO_ENABLED=0). If the app ever needs cgo, change the base image to a glibc
|
||||
# one (e.g. golang:1.25-bookworm) in git — do not patch it on the host.
|
||||
|
||||
FROM alpine:3.21
|
||||
|
||||
ARG APP_VERSION=dev
|
||||
LABEL org.opencontainers.image.title="resume" \
|
||||
org.opencontainers.image.version="${APP_VERSION}" \
|
||||
org.opencontainers.image.description="Sam Rolfe — portfolio site (Mode A static)"
|
||||
|
||||
WORKDIR /srv
|
||||
|
||||
# bin/server plus whatever the build emitted (dist/ for rendered pages/assets).
|
||||
COPY . /srv/
|
||||
|
||||
ENV PORT=8096 \
|
||||
APP_VERSION=${APP_VERSION}
|
||||
|
||||
EXPOSE 8096
|
||||
|
||||
ENTRYPOINT ["/srv/bin/server"]
|
||||
111
deploy/README.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Deployment (Phase 3)
|
||||
|
||||
One script, one release directory, one atomic symlink. Deploy is a script, not a
|
||||
model decision (`AGENTS.md` §4.6), and nobody hand-edits files on the runtime
|
||||
host: change these files in git and deploy again.
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `deploy.sh` | build + test → release → flip → `up -d` → health gate → rollback on failure |
|
||||
| `deploy.sh --rollback` | restore the previous release in one command |
|
||||
| `docker-compose.yml` | runtime container: published on `127.0.0.1:8096` **and** `192.168.20.13:8096` (never `0.0.0.0`), `mem_limit == memswap_limit == 120m`, capped logs |
|
||||
| `Dockerfile` | runtime image only — alpine + the prebuilt release payload, no toolchain, no network |
|
||||
| `rehearse.sh` | repeatable proof that deploy, fail-fast, health-gate rollback and `--rollback` all work |
|
||||
|
||||
## Layout on `.13`
|
||||
|
||||
```
|
||||
/home/sam/deployment/resume/
|
||||
├── releases/<UTC timestamp>/ immutable: bin/server, dist/, compose, Dockerfile, manifest.txt, .env
|
||||
├── current -> releases/<ts> live release; swapped with ln + mv -T (atomic rename)
|
||||
├── previous -> releases/<ts> the release that was live before the last flip
|
||||
└── deploy.log append-only record of every attempt, including failures
|
||||
```
|
||||
|
||||
## What `deploy.sh` expects from the app
|
||||
|
||||
1. `make build` if a `Makefile` with a `build` target exists, otherwise
|
||||
`go build ./cmd/server`. Either way the result must be one server binary at
|
||||
`bin/server` or `dist/server`; anything else the binary serves from disk goes
|
||||
in `dist/` and is copied into the release.
|
||||
2. `make test` (or `go test ./...`) — runs **before** anything is copied or
|
||||
flipped, so a broken build never reaches the release directory.
|
||||
3. `GET /healthz` → `200`. If the body is JSON with a `static` field it must be
|
||||
`true`. AI readiness is deliberately **not** part of the gate: Mode A must
|
||||
work with every AI service stopped (acceptance test A1).
|
||||
4. Bind `$PORT` (default `8096`) on `0.0.0.0` **inside the container**. The
|
||||
container's own loopback is not reachable through a published port; exposure
|
||||
is controlled by the host side of `ports:` in `docker-compose.yml`.
|
||||
|
||||
## Deploying
|
||||
|
||||
```bash
|
||||
cd /home/sam/paseo/projects/resume_ai_system_paperclip_build
|
||||
./deploy/deploy.sh # build, test, release, flip, health gate
|
||||
./deploy/deploy.sh --rollback # one command back to the previous release
|
||||
tail -40 /home/sam/deployment/resume/deploy.log
|
||||
```
|
||||
|
||||
Exit code is the answer: `0` means the new release answered `/healthz`.
|
||||
Non-zero means either (a) build/test failed and **nothing changed**, or (b) the
|
||||
health gate failed, `current` was flipped back to `previous`, the previous
|
||||
release was restarted, and the log says which.
|
||||
|
||||
Flags worth knowing: `--source DIR` (build from another checkout — this is how
|
||||
`rehearse.sh` drives the script), `--root DIR`, `--port N`,
|
||||
`--health-timeout N`.
|
||||
|
||||
The Go toolchain is auto-detected, including `/nix/store/*-go-1.*/bin/go`, so
|
||||
the script works on a host where `go` is not on `PATH`.
|
||||
|
||||
## Rehearsing the rollback
|
||||
|
||||
```bash
|
||||
./deploy/rehearse.sh # 21 assertions, exit 0 when every one holds
|
||||
```
|
||||
|
||||
It builds three throwaway services in a scratch directory, runs the real script
|
||||
against the real release root and the real port, and asserts:
|
||||
|
||||
1. a good release deploys, answers `/healthz` over loopback and the LAN
|
||||
address, publishes on exactly those two addresses with no wildcard bind,
|
||||
and has `memswap_limit == mem_limit`;
|
||||
2. a build failure exits non-zero, leaves `current` untouched and keeps serving;
|
||||
3. a release whose `/healthz` is `500` is rolled back by the health gate, exits
|
||||
non-zero, and the previous release serves again;
|
||||
4. `deploy.sh --rollback` flips back to the previous release and exits 0;
|
||||
5. `docker compose down && up` (acceptance test N3) restores the service.
|
||||
|
||||
It removes the release root, container and images it created. `KEEP_REHEARSAL=1`
|
||||
keeps them for inspection.
|
||||
|
||||
## Container limits — not negotiable
|
||||
|
||||
`mem_limit` and `memswap_limit` are both `120m`, so the container cannot eat
|
||||
host swap on a host that is already swapping. Measured RSS for a Go static
|
||||
server is single-digit MiB; `deploy.sh` prints the real figure after every
|
||||
deploy so drift shows up in the log.
|
||||
|
||||
## Bind addresses — decided 2026-10-26
|
||||
|
||||
The container publishes on two explicit addresses and nothing else:
|
||||
|
||||
| Address | Why |
|
||||
|---|---|
|
||||
| `127.0.0.1:8096` | `deploy.sh`'s health gate and any check made on `.13` itself |
|
||||
| `192.168.20.13:8096` | the LAN address Caddy on `.35` proxies to (`docs/phase-3-deploy.md` §3.3) |
|
||||
|
||||
A wildcard bind (`0.0.0.0`) is still forbidden: Caddy on `.35` stays the only
|
||||
public listener. The phase document asked for loopback only, but a loopback-only
|
||||
listener is unreachable from `.35`; Sam resolved the contradiction on AUD-133
|
||||
with "A - dual bind: 127.0.0.1:8096 and 192.168.20.13:8096". `APP_LAN_IP`
|
||||
overrides the LAN address (default `192.168.20.13`); `deploy.sh --lan-ip ADDR`
|
||||
sets it for one run. Both `deploy.sh` and `rehearse.sh` assert that the LAN
|
||||
address answers, not just loopback.
|
||||
|
||||
## Caddy
|
||||
|
||||
`docs/phase-3-deploy.md` §3.3 holds the block for `.35`. It is additive, applied
|
||||
with `caddy reload` (never a restart), and **Sam applies it**. The block proxies
|
||||
to `192.168.20.13:8096`, which is why the container publishes on that address as
|
||||
well as loopback (see *Bind addresses* above).
|
||||
321
deploy/deploy.sh
Executable file
@@ -0,0 +1,321 @@
|
||||
#!/usr/bin/env bash
|
||||
# resume — release script (Phase 3).
|
||||
#
|
||||
# Deploy is a script, not a model decision (AGENTS.md §4.6).
|
||||
# No one hand-edits files on the production host: change this script in git.
|
||||
#
|
||||
# ./deploy/deploy.sh build, test, release, flip, health-gate
|
||||
# ./deploy/deploy.sh --rollback restore the previous release, one command
|
||||
#
|
||||
# Layout on the runtime host (.13):
|
||||
#
|
||||
# /home/sam/deployment/resume/
|
||||
# ├── releases/<UTC timestamp>/ immutable release: bin/, dist/, compose, manifest
|
||||
# ├── current -> releases/<ts> the live release (atomic symlink flip)
|
||||
# ├── previous -> releases/<ts> the release that was live before the last flip
|
||||
# └── deploy.log append-only record of every deploy attempt
|
||||
#
|
||||
# A release is only "current" after /healthz answers 200 on 127.0.0.1:$PORT.
|
||||
# If the health gate fails, `current` is flipped back to `previous`, the previous
|
||||
# release is restarted, and this script exits non-zero.
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
APP_NAME="resume"
|
||||
DEFAULT_PORT=8096
|
||||
DEFAULT_ROOT="/home/sam/deployment/resume"
|
||||
|
||||
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ROOT="${DEPLOY_ROOT:-$DEFAULT_ROOT}"
|
||||
PORT="${APP_PORT:-$DEFAULT_PORT}"
|
||||
# Caddy on .35 proxies to this host's LAN address (phase doc §3.3), so the
|
||||
# container publishes on loopback *and* on that address. Sam's decision on
|
||||
# AUD-133, 2026-10-26: "A - dual bind: 127.0.0.1:8096 and 192.168.20.13:8096".
|
||||
LAN_IP="${APP_LAN_IP:-192.168.20.13}"
|
||||
DO_ROLLBACK=0
|
||||
HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-40}"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: deploy.sh [options]
|
||||
|
||||
--rollback restore the previous release and exit
|
||||
--source DIR build from DIR instead of the repo this script lives in
|
||||
--root DIR release root (default /home/sam/deployment/resume)
|
||||
--port N app port (default 8096)
|
||||
--lan-ip ADDR LAN address the container also publishes on (default 192.168.20.13)
|
||||
--health-timeout N seconds to wait for /healthz (default 40)
|
||||
-h, --help this text
|
||||
|
||||
Environment:
|
||||
GO path to the go toolchain (auto-detected, incl. /nix/store)
|
||||
DEPLOY_ROOT same as --root
|
||||
APP_PORT same as --port
|
||||
APP_LAN_IP same as --lan-ip
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--rollback) DO_ROLLBACK=1; shift ;;
|
||||
--source) SRC_DIR="$(cd "$2" && pwd)"; shift 2 ;;
|
||||
--root) ROOT="$2"; shift 2 ;;
|
||||
--port) PORT="$2"; shift 2 ;;
|
||||
--lan-ip) LAN_IP="$2"; shift 2 ;;
|
||||
--health-timeout) HEALTH_TIMEOUT="$2"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
RELEASES="$ROOT/releases"
|
||||
CURRENT="$ROOT/current"
|
||||
PREVIOUS="$ROOT/previous"
|
||||
LOG="$ROOT/deploy.log"
|
||||
|
||||
mkdir -p "$RELEASES"
|
||||
|
||||
log() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" | tee -a "$LOG" >&2; }
|
||||
die() { log "FAIL: $*"; exit 1; }
|
||||
|
||||
# ---------------------------------------------------------------- toolchain
|
||||
find_go() {
|
||||
if [[ -n "${GO:-}" && -x "${GO}" ]]; then echo "$GO"; return; fi
|
||||
if command -v go >/dev/null 2>&1; then command -v go; return; fi
|
||||
# NixOS: the toolchain lives in the store even when it is not on PATH.
|
||||
local cand
|
||||
cand=$(ls -d /nix/store/*-go-1.*/bin/go 2>/dev/null | grep -v '\.drv' | sort -V | tail -1)
|
||||
if [[ -n "$cand" && -x "$cand" ]]; then echo "$cand"; return; fi
|
||||
return 1
|
||||
}
|
||||
|
||||
compose() {
|
||||
# $1 = release directory that holds docker-compose.yml + .env
|
||||
local dir="$1"; shift
|
||||
docker compose -p "$APP_NAME" --project-directory "$dir" -f "$dir/docker-compose.yml" "$@"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- health gate
|
||||
check_health() {
|
||||
# 200 on /healthz, and if the body is JSON with a "static" field it must be true.
|
||||
# AI readiness is deliberately NOT part of this gate: Mode A must work with
|
||||
# every AI service stopped (acceptance test A1).
|
||||
local url="http://127.0.0.1:${PORT}/healthz" body code
|
||||
body=$(curl -sS --max-time 3 -w '\n%{http_code}' "$url" 2>/dev/null || true)
|
||||
code=$(printf '%s' "$body" | tail -1)
|
||||
body=$(printf '%s' "$body" | sed '$d')
|
||||
if [[ "$code" != "200" ]]; then
|
||||
log "health: $url -> HTTP ${code:-none}"
|
||||
return 1
|
||||
fi
|
||||
if printf '%s' "$body" | head -c 1 | grep -q '{' && command -v jq >/dev/null 2>&1; then
|
||||
local static_ok
|
||||
static_ok=$(printf '%s' "$body" | jq -r 'if has("static") then (.static|tostring) else "absent" end' 2>/dev/null || echo absent)
|
||||
if [[ "$static_ok" == "false" ]]; then
|
||||
log "health: $url -> 200 but static readiness is false: $body"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
log "health: $url -> 200"
|
||||
|
||||
# The container also publishes on the LAN address because that is what Caddy
|
||||
# on .35 proxies to. A loopback-only success would be a site nobody can reach.
|
||||
local lan_url lan_code
|
||||
lan_url="http://${LAN_IP}:${PORT}/healthz"
|
||||
lan_code=$(curl -s --max-time 3 -o /dev/null -w '%{http_code}' "$lan_url" 2>/dev/null || true)
|
||||
if [[ "$lan_code" != "200" ]]; then
|
||||
log "health: $lan_url -> HTTP ${lan_code:-none} (LAN bind missing?)"
|
||||
return 1
|
||||
fi
|
||||
log "health: $lan_url -> 200"
|
||||
return 0
|
||||
}
|
||||
|
||||
wait_healthy() {
|
||||
local i
|
||||
for ((i = 1; i <= HEALTH_TIMEOUT; i++)); do
|
||||
if check_health; then return 0; fi
|
||||
sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
container_state() {
|
||||
docker inspect -f 'name={{.Name}} status={{.State.Status}} mem={{.HostConfig.Memory}} memswap={{.HostConfig.MemorySwap}} ports={{json .HostConfig.PortBindings}}' \
|
||||
"${APP_NAME}-app" 2>/dev/null || echo "container ${APP_NAME}-app: absent"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- build + test
|
||||
discover_server() {
|
||||
# Contract (deploy/README.md): the app build produces one server binary at
|
||||
# bin/server or dist/server. `make build` may also emit dist/ for static assets.
|
||||
local d
|
||||
for d in "$1/bin/server" "$1/dist/server" "$1/server"; do
|
||||
if [[ -f "$d" && -x "$d" ]]; then echo "$d"; return 0; fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
build_and_test() {
|
||||
local src="$1" out="$2"
|
||||
local gobin=""
|
||||
log "build: source $src"
|
||||
mkdir -p "$out/bin"
|
||||
cd "$src"
|
||||
|
||||
gobin=$(find_go || true)
|
||||
if [[ -z "$gobin" ]]; then
|
||||
log "build: no go toolchain found (set GO=/path/to/go)"
|
||||
else
|
||||
export PATH="$(dirname "$gobin"):$PATH"
|
||||
log "build: go $("$gobin" version)"
|
||||
fi
|
||||
|
||||
if [[ -f Makefile ]] && grep -qE '^build:' Makefile; then
|
||||
log "build: make build"
|
||||
make build
|
||||
else
|
||||
[[ -n "$gobin" ]] || die "no Makefile 'build' target and no go toolchain"
|
||||
log "build: go build ./cmd/server"
|
||||
CGO_ENABLED=0 "$gobin" build -trimpath -buildvcs=false -o "$out/bin/server" ./cmd/server
|
||||
fi
|
||||
|
||||
# Tests fail fast, before anything is copied or flipped.
|
||||
if [[ -f Makefile ]] && grep -qE '^test:' Makefile; then
|
||||
log "test: make test"
|
||||
make test
|
||||
elif [[ -n "$gobin" ]]; then
|
||||
log "test: go test ./..."
|
||||
"$gobin" test ./...
|
||||
fi
|
||||
|
||||
# Collect the binary, wherever the app's build put it.
|
||||
local bin
|
||||
bin=$(discover_server "$src" || true)
|
||||
if [[ -z "$bin" ]]; then
|
||||
bin=$(discover_server "$out" || true)
|
||||
fi
|
||||
[[ -n "$bin" ]] || die "no server binary found (looked for bin/server, dist/server, server)"
|
||||
if [[ "$(readlink -f "$bin")" != "$(readlink -f "$out/bin/server")" ]]; then
|
||||
cp -f "$bin" "$out/bin/server"
|
||||
fi
|
||||
chmod +x "$out/bin/server"
|
||||
log "build: binary $(stat -c %s "$out/bin/server") bytes, sha256 $(sha256sum "$out/bin/server" | cut -c1-16)"
|
||||
|
||||
# Static assets, if the build emits them.
|
||||
if [[ -d "$src/dist" ]]; then
|
||||
cp -a "$src/dist/." "$out/dist/" 2>/dev/null || { mkdir -p "$out/dist"; cp -a "$src/dist/." "$out/dist/"; }
|
||||
log "build: copied dist/ ($(find "$out/dist" -type f | wc -l) files)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- deploy
|
||||
do_deploy() {
|
||||
local src="$1"
|
||||
local ts sha branch release prev_target
|
||||
ts=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
sha=$(git -C "$src" rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
branch=$(git -C "$src" rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)
|
||||
release="$RELEASES/$ts"
|
||||
|
||||
log "deploy: $APP_NAME $ts (branch $branch, $sha) -> $release"
|
||||
mkdir -p "$release"
|
||||
|
||||
# 1. Build + test. Nothing is touched on failure.
|
||||
build_and_test "$src" "$release"
|
||||
|
||||
# 2. Release payload: binary, compose file, runtime image, manifest.
|
||||
cp -f "$src/deploy/docker-compose.yml" "$release/docker-compose.yml"
|
||||
cp -f "$src/deploy/Dockerfile" "$release/Dockerfile"
|
||||
{
|
||||
echo "app=$APP_NAME"
|
||||
echo "release=$ts"
|
||||
echo "git_sha=$(git -C "$src" rev-parse HEAD 2>/dev/null || echo unknown)"
|
||||
echo "git_branch=$branch"
|
||||
echo "built_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo "built_on=$(hostname)"
|
||||
echo "binary_sha256=$(sha256sum "$release/bin/server" | cut -d' ' -f1)"
|
||||
} > "$release/manifest.txt"
|
||||
printf 'APP_VERSION=%s\nAPP_PORT=%s\nAPP_LAN_IP=%s\n' "$ts" "$PORT" "$LAN_IP" > "$release/.env"
|
||||
|
||||
# 3. Runtime image, tagged with this release.
|
||||
log "deploy: docker build resume/app:$ts"
|
||||
docker build --quiet -t "resume/app:$ts" \
|
||||
--build-arg "APP_VERSION=$ts" -f "$release/Dockerfile" "$release" >>"$LOG" 2>&1 \
|
||||
|| die "image build failed"
|
||||
|
||||
# 4. Remember what is live now, then flip atomically.
|
||||
prev_target=""
|
||||
if [[ -L "$CURRENT" ]]; then prev_target=$(readlink -f "$CURRENT"); fi
|
||||
ln -sfn "$release" "$CURRENT.tmp" && mv -T "$CURRENT.tmp" "$CURRENT" || die "symlink flip failed"
|
||||
if [[ -n "$prev_target" && "$prev_target" != "$(readlink -f "$release")" ]]; then
|
||||
ln -sfn "$prev_target" "$PREVIOUS.tmp" && mv -T "$PREVIOUS.tmp" "$PREVIOUS"
|
||||
log "deploy: previous -> $prev_target"
|
||||
fi
|
||||
log "deploy: current -> $(readlink -f "$CURRENT")"
|
||||
|
||||
# 5. Bring up, then gate on health. Anything less than 200 means roll back.
|
||||
compose "$release" up -d --remove-orphans >>"$LOG" 2>&1 || log "deploy: compose up returned non-zero"
|
||||
if ! wait_healthy; then
|
||||
log "deploy: HEALTH GATE FAILED for $ts"
|
||||
rollback_internal "$ts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "deploy: OK $ts"
|
||||
container_state | tee -a "$LOG" >&2
|
||||
docker stats --no-stream --format 'rss={{.MemUsage}} pct={{.MemPerc}}' "${APP_NAME}-app" 2>/dev/null | tee -a "$LOG" >&2 || true
|
||||
}
|
||||
|
||||
rollback_internal() {
|
||||
# Used by a failed deploy: restore the previous release, restart it, verify.
|
||||
local failed="$1" target
|
||||
if [[ ! -L "$PREVIOUS" ]]; then
|
||||
log "rollback: no previous release to restore; stopping ${APP_NAME}-app"
|
||||
compose "$CURRENT" down >>"$LOG" 2>&1 || true
|
||||
return 1
|
||||
fi
|
||||
target=$(readlink -f "$PREVIOUS")
|
||||
log "rollback: $failed failed -> restoring $target"
|
||||
ln -sfn "$target" "$CURRENT.tmp" && mv -T "$CURRENT.tmp" "$CURRENT"
|
||||
compose "$target" up -d --remove-orphans >>"$LOG" 2>&1 || true
|
||||
if wait_healthy; then
|
||||
log "rollback: previous release healthy ($(readlink -f "$CURRENT"))"
|
||||
else
|
||||
log "rollback: previous release ALSO unhealthy — manual attention required"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
do_rollback() {
|
||||
local target
|
||||
if [[ ! -L "$PREVIOUS" ]]; then die "no previous release at $PREVIOUS — nothing to roll back to"; fi
|
||||
target=$(readlink -f "$PREVIOUS")
|
||||
[[ -d "$target" ]] || die "previous release target missing: $target"
|
||||
log "rollback: current=$( [[ -L "$CURRENT" ]] && readlink -f "$CURRENT" || echo none) -> $target"
|
||||
|
||||
# Keep the release we are leaving, so the rollback is itself reversible.
|
||||
local leaving=""
|
||||
if [[ -L "$CURRENT" ]]; then leaving=$(readlink -f "$CURRENT"); fi
|
||||
ln -sfn "$target" "$CURRENT.tmp" && mv -T "$CURRENT.tmp" "$CURRENT" || die "symlink flip failed"
|
||||
if [[ -n "$leaving" && "$leaving" != "$target" ]]; then
|
||||
ln -sfn "$leaving" "$PREVIOUS.tmp" && mv -T "$PREVIOUS.tmp" "$PREVIOUS"
|
||||
fi
|
||||
|
||||
compose "$target" up -d --remove-orphans >>"$LOG" 2>&1 || log "rollback: compose up returned non-zero"
|
||||
if wait_healthy; then
|
||||
log "rollback: OK, current -> $target"
|
||||
container_state | tee -a "$LOG" >&2
|
||||
exit 0
|
||||
fi
|
||||
log "rollback: FAILED, $target is not healthy"
|
||||
exit 1
|
||||
}
|
||||
|
||||
mkdir -p "$ROOT"
|
||||
log "=== ${APP_NAME} deploy.sh start (pid $$) ==="
|
||||
if (( DO_ROLLBACK == 1 )); then
|
||||
do_rollback
|
||||
else
|
||||
do_deploy "$SRC_DIR"
|
||||
fi
|
||||
49
deploy/docker-compose.yml
Normal file
@@ -0,0 +1,49 @@
|
||||
# resume — runtime container (Phase 3 §3.2).
|
||||
#
|
||||
# Copied into each release directory by deploy/deploy.sh; the release's .env
|
||||
# supplies APP_VERSION (the image tag). Never edited on the host — change it
|
||||
# here, in git, and deploy again.
|
||||
#
|
||||
# Limits that are not negotiable (AGENTS.md §4.3):
|
||||
# mem_limit == memswap_limit == 120m -> the container cannot touch host swap
|
||||
# ports never 0.0.0.0 -> Caddy on .35 is the only public listener
|
||||
#
|
||||
# Published on two explicit addresses and nothing else (Sam, 2026-10-26, on
|
||||
# AUD-133: "A - dual bind: 127.0.0.1:8096 and 192.168.20.13:8096"):
|
||||
# 127.0.0.1 -> deploy.sh health gate and local checks on .13
|
||||
# 192.168.20.13 -> the LAN address Caddy on .35 proxies to (phase doc §3.3)
|
||||
# A wildcard bind is still forbidden; if APP_LAN_IP is unset the LAN address
|
||||
# defaults to this host's own 192.168.20.13.
|
||||
#
|
||||
# .13 has every default Docker address pool subnetted out (many projects), so a
|
||||
# single-container service uses the host bridge and publishes its port rather
|
||||
# than creating a project network. Same pattern as compass-teams-nhs.
|
||||
|
||||
name: resume
|
||||
|
||||
services:
|
||||
app:
|
||||
image: resume/app:${APP_VERSION}
|
||||
container_name: resume-app
|
||||
restart: unless-stopped
|
||||
network_mode: bridge
|
||||
ports:
|
||||
- "127.0.0.1:${APP_PORT:-8096}:8096"
|
||||
- "${APP_LAN_IP:-192.168.20.13}:${APP_PORT:-8096}:8096"
|
||||
environment:
|
||||
PORT: "8096"
|
||||
APP_VERSION: "${APP_VERSION}"
|
||||
# Mode B values arrive in Phase 5 from ~/.config/environment.d/ — never here.
|
||||
mem_limit: 120m
|
||||
memswap_limit: 120m
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8096/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
198
deploy/rehearse.sh
Executable file
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env bash
|
||||
# resume — rollback rehearsal (Phase 3 DoD: "Rollback rehearsed once and documented").
|
||||
#
|
||||
# Builds three throwaway Go services in a scratch directory and runs the real
|
||||
# deploy mechanism against the real release root and the real port:
|
||||
#
|
||||
# 1. good -> deploys, health-gated, serving on loopback and the LAN address
|
||||
# 2. broken -> does not compile: deploy must fail fast, current must not move
|
||||
# 3. unhealthy -> builds and runs, but /healthz is 500: the health gate must
|
||||
# roll back to the previous release and exit non-zero
|
||||
# 4. good again -> then `deploy.sh --rollback` must restore the earlier release
|
||||
#
|
||||
# It leaves nothing running and removes the release root it created, unless
|
||||
# KEEP_REHEARSAL=1. Nothing outside $DEPLOY_ROOT, the scratch dir and this
|
||||
# service's own container/image is touched.
|
||||
#
|
||||
# ./deploy/rehearse.sh
|
||||
#
|
||||
# Exit 0 means every assertion held; the transcript is the evidence.
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DEPLOY="$HERE/deploy.sh"
|
||||
ROOT="${DEPLOY_ROOT:-/home/sam/deployment/resume}"
|
||||
PORT="${APP_PORT:-8096}"
|
||||
LAN_IP="${APP_LAN_IP:-192.168.20.13}"
|
||||
SCRATCH="${REHEARSAL_DIR:-$(mktemp -d -t resume-rehearsal-XXXXXX)}"
|
||||
KEEP="${KEEP_REHEARSAL:-0}"
|
||||
|
||||
pass=0; fail=0
|
||||
ok() { printf ' PASS %s\n' "$*"; pass=$((pass + 1)); }
|
||||
no() { printf ' FAIL %s\n' "$*"; fail=$((fail + 1)); }
|
||||
hdr() { printf '\n== %s\n' "$*"; }
|
||||
|
||||
cleanup() {
|
||||
if [[ "$KEEP" == "1" ]]; then
|
||||
printf '\nkept for inspection: container resume-app, images resume/app:*, release root %s, scratch %s\n' "$ROOT" "$SCRATCH"
|
||||
return 0
|
||||
fi
|
||||
if docker ps -a --format '{{.Names}}' | grep -qx 'resume-app'; then
|
||||
docker rm -f resume-app >/dev/null 2>&1 || true
|
||||
fi
|
||||
docker images --format '{{.Repository}}:{{.Tag}}' | grep -q '^resume/app:' \
|
||||
&& docker images --format '{{.Repository}}:{{.Tag}}' | grep '^resume/app:' \
|
||||
| xargs -r docker rmi -f >/dev/null 2>&1 || true
|
||||
rm -rf "$ROOT"
|
||||
[[ "$SCRATCH" == /tmp/resume-rehearsal-* || -n "${REHEARSAL_DIR:-}" ]] && rm -rf "$SCRATCH"
|
||||
printf '\ncleaned: container, images, release root and rehearsal sources removed\n'
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# --------------------------------------------------------------- fixtures
|
||||
make_app() { # $1 dir, $2 mode: good|broken|unhealthy
|
||||
local dir="$1" mode="$2"
|
||||
mkdir -p "$dir/cmd/server" "$dir/deploy"
|
||||
cp -f "$HERE/docker-compose.yml" "$HERE/Dockerfile" "$dir/deploy/"
|
||||
printf 'module rehearsal\n\ngo 1.25\n' > "$dir/go.mod"
|
||||
cat > "$dir/cmd/server/main.go" <<'GO'
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8096"
|
||||
}
|
||||
bind := os.Getenv("BIND")
|
||||
if bind == "" {
|
||||
bind = "0.0.0.0"
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
BROKEN
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"status":"ok","static":true,"ai":"disabled","version":%q,"time":%q}`,
|
||||
os.Getenv("APP_VERSION"), time.Now().UTC().Format(time.RFC3339))
|
||||
})
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, "<!doctype html><title>rehearsal</title><p>version %s</p>", os.Getenv("APP_VERSION"))
|
||||
})
|
||||
srv := &http.Server{Addr: bind + ":" + port, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
|
||||
log.Printf("listening on %s:%s", bind, port)
|
||||
if err := srv.ListenAndServe(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
GO
|
||||
local patch=''
|
||||
case "$mode" in
|
||||
good) patch='' ;;
|
||||
unhealthy) patch='w.WriteHeader(http.StatusInternalServerError)' ;;
|
||||
broken) patch='' ;;
|
||||
esac
|
||||
python3 - "$dir/cmd/server/main.go" "$patch" <<'PY'
|
||||
import sys
|
||||
p, patch = sys.argv[1], sys.argv[2]
|
||||
s = open(p).read().replace("BROKEN", patch, 1)
|
||||
open(p, "w").write(s)
|
||||
PY
|
||||
if [[ "$mode" == "broken" ]]; then
|
||||
sed -i 's/^func main() {/func main( {/' "$dir/cmd/server/main.go"
|
||||
fi
|
||||
}
|
||||
|
||||
health() { curl -s -m 3 -o /dev/null -w '%{http_code}' "http://127.0.0.1:$PORT/healthz" || true; }
|
||||
health_lan() { curl -s -m 3 -o /dev/null -w '%{http_code}' "http://$LAN_IP:$PORT/healthz" || true; }
|
||||
listeners() { ss -ltn 2>/dev/null | awk -v p=":${PORT}\$" '$4 ~ p {print $4}' | sort -u; }
|
||||
serving_version() { curl -s -m 3 "http://127.0.0.1:$PORT/healthz" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p'; }
|
||||
current_target() { [[ -L "$ROOT/current" ]] && readlink -f "$ROOT/current" || echo none; }
|
||||
|
||||
# --------------------------------------------------------------- 0. preconditions
|
||||
hdr "0. preconditions"
|
||||
if ss -ltn 2>/dev/null | awk '{print $4}' | grep -q ":$PORT\$"; then
|
||||
no "port $PORT already in use — nothing started"
|
||||
exit 1
|
||||
fi
|
||||
ok "port $PORT free"
|
||||
rm -rf "$ROOT"
|
||||
make_app "$SCRATCH/good" good
|
||||
make_app "$SCRATCH/broken" broken
|
||||
make_app "$SCRATCH/unhealthy" unhealthy
|
||||
ok "three throwaway sources built in $SCRATCH"
|
||||
|
||||
# --------------------------------------------------------------- 1. good deploy
|
||||
hdr "1. good release deploys and health-gates"
|
||||
if "$DEPLOY" --source "$SCRATCH/good" > "$SCRATCH/1-deploy.log" 2>&1; then
|
||||
ok "deploy.sh exit 0"
|
||||
else
|
||||
no "deploy.sh exit non-zero"; tail -20 "$SCRATCH/1-deploy.log"; exit 1
|
||||
fi
|
||||
[[ "$(health)" == "200" ]] && ok "/healthz 200 on 127.0.0.1:$PORT" || no "/healthz not 200"
|
||||
v1=$(serving_version); [[ -n "$v1" ]] && ok "serving release $v1" || no "no version reported"
|
||||
ss -ltn | grep -q "127.0.0.1:$PORT" && ok "container publishes on 127.0.0.1:$PORT (loopback, used by the deploy.sh health gate)" || no "no loopback bind"
|
||||
ss -ltn | grep -q "$LAN_IP:$PORT" && ok "container publishes on $LAN_IP:$PORT (the address Caddy on .35 proxies to)" || no "no LAN bind on $LAN_IP:$PORT"
|
||||
if listeners | grep -qE '^(0\.0\.0\.0|\*|\[::\]|::)'; then
|
||||
no "wildcard bind on $PORT: $(listeners | tr '\n' ' ')"
|
||||
else
|
||||
ok "no wildcard bind on $PORT — only: $(listeners | tr '\n' ' ')"
|
||||
fi
|
||||
[[ "$(health_lan)" == "200" ]] && ok "/healthz 200 over the LAN address http://$LAN_IP:$PORT" || no "LAN address not answering"
|
||||
mem=$(docker inspect -f '{{.HostConfig.Memory}}' resume-app); swp=$(docker inspect -f '{{.HostConfig.MemorySwap}}' resume-app)
|
||||
[[ "$mem" == "$swp" && "$mem" != "0" ]] && ok "memswap_limit == mem_limit ($mem bytes)" || no "mem limits differ ($mem vs $swp)"
|
||||
|
||||
# --------------------------------------------------------------- 2. broken build
|
||||
hdr "2. build failure fails fast, current does not move"
|
||||
before=$(current_target)
|
||||
set +e; "$DEPLOY" --source "$SCRATCH/broken" > "$SCRATCH/2-broken.log" 2>&1; rc=$?; set -e
|
||||
[[ $rc -ne 0 ]] && ok "deploy.sh exit $rc (non-zero, as required)" || no "broken build reported success"
|
||||
[[ "$(current_target)" == "$before" ]] && ok "current unchanged ($before)" || no "current moved after a failed build"
|
||||
[[ "$(health)" == "200" ]] && ok "previous release still serving" || no "site down after failed build"
|
||||
|
||||
# --------------------------------------------------------------- 3. health gate
|
||||
hdr "3. unhealthy release is rolled back by the health gate"
|
||||
before=$(current_target)
|
||||
set +e; HEALTH_TIMEOUT=10 "$DEPLOY" --source "$SCRATCH/unhealthy" > "$SCRATCH/3-unhealthy.log" 2>&1; rc=$?; set -e
|
||||
[[ $rc -ne 0 ]] && ok "deploy.sh exit $rc (non-zero, as required)" || no "unhealthy release reported success"
|
||||
[[ "$(current_target)" == "$before" ]] && ok "current restored to $before" || no "current left on $(current_target)"
|
||||
[[ "$(health)" == "200" ]] && ok "rolled-back release serving again" || no "no healthy release after rollback"
|
||||
grep -q 'HEALTH GATE FAILED' "$SCRATCH/3-unhealthy.log" && ok "log records the health-gate failure" || no "no health-gate failure recorded"
|
||||
|
||||
# --------------------------------------------------------------- 4. --rollback
|
||||
hdr "4. deploy.sh --rollback restores the previous release in one command"
|
||||
"$DEPLOY" --source "$SCRATCH/good" > "$SCRATCH/4-deploy.log" 2>&1 && ok "second good release deployed" || no "second deploy failed"
|
||||
v2=$(serving_version)
|
||||
prev=$(readlink -f "$ROOT/previous")
|
||||
set +e; "$DEPLOY" --rollback > "$SCRATCH/4-rollback.log" 2>&1; rc=$?; set -e
|
||||
[[ $rc -eq 0 ]] && ok "rollback exit 0" || no "rollback exit $rc"
|
||||
[[ "$(readlink -f "$ROOT/current")" == "$prev" ]] && ok "current flipped back to $prev" || no "current not restored"
|
||||
[[ "$(health)" == "200" ]] && ok "rolled-back release healthy" || no "rolled-back release unhealthy"
|
||||
v3=$(serving_version)
|
||||
[[ -n "$v3" && "$v3" != "$v2" ]] && ok "serving $v3 again (was $v2)" || no "version did not change on rollback"
|
||||
|
||||
# --------------------------------------------------------------- 5. restart
|
||||
hdr "5. N3: docker compose down && up restores the service"
|
||||
rel=$(readlink -f "$ROOT/current")
|
||||
docker compose -p resume --project-directory "$rel" -f "$rel/docker-compose.yml" down >/dev/null 2>&1
|
||||
[[ "$(health)" != "200" ]] && ok "service down after compose down" || no "still serving after compose down"
|
||||
docker compose -p resume --project-directory "$rel" -f "$rel/docker-compose.yml" up -d >/dev/null 2>&1
|
||||
restored=0
|
||||
for _ in $(seq 1 20); do
|
||||
if [[ "$(health)" == "200" ]]; then restored=1; break; fi
|
||||
sleep 1
|
||||
done
|
||||
[[ $restored == 1 ]] && ok "service restored after compose up" || no "service did not come back within 20s"
|
||||
|
||||
# --------------------------------------------------------------- verdict
|
||||
hdr "verdict"
|
||||
printf 'passed %d, failed %d\n' "$pass" "$fail"
|
||||
[[ $fail -eq 0 ]] || exit 1
|
||||
264
docs/phase-3-evidence.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# Phase 3 evidence — public deployment
|
||||
|
||||
**Status: mechanism complete and rehearsed; bind decided by Sam; public URL deferred.**
|
||||
The deploy machinery exists, is rehearsed against the real release root and the
|
||||
real port on `.13`, and leaves nothing running. Sam answered the bind question on
|
||||
2026-10-26 (**dual bind**, §5), and that answer is now in the committed compose
|
||||
file and proven reachable from `.35`. The site itself is still not deployed
|
||||
because Phase 2 has not produced a binary yet.
|
||||
|
||||
Recorded 2026-10-26 on `.13` (`nixos-desktop`), runs `630e2ed9` and `a61a5b22`.
|
||||
|
||||
---
|
||||
|
||||
## Definition of Done
|
||||
|
||||
| # | Item | Verdict | Evidence |
|
||||
|---|---|---|---|
|
||||
| 1 | `deploy.sh` and `deploy.sh --rollback` work; a deliberate bad build proves rollback | **met** | §1, `deploy/rehearse.sh` 24/24 |
|
||||
| 2 | `https://resume.lab.audasmedia.com.au` serves the site | **deferred** | Phase 2 has not produced a binary; Caddy block not yet applied (§4, §5) |
|
||||
| 3 | Container bound to `127.0.0.1` only, `ss -ltn` | **met as amended by Sam**: loopback **and** `192.168.20.13`, no wildcard — see §5 | §2 |
|
||||
| 4 | `memswap_limit == mem_limit`, `docker inspect` | **met** | §2 |
|
||||
| 5 | Security headers present, CSP does not break the page | **deferred** | headers belong to the `.35` Caddy block (§4) |
|
||||
| 6 | A1 passes over the public URL with the AI layer stopped | **deferred** | needs a live URL |
|
||||
| 7 | Sam applied the Caddy block and confirmed other lab hostnames | **deferred — Sam's action** | block and verification steps in §4 |
|
||||
| 8 | Rollback rehearsed once and documented | **met** | §1, `deploy/rehearse.sh` |
|
||||
|
||||
Nothing in the deferred column is asserted. Each depends on the Phase 2 binary
|
||||
(items 2, 5, 6) or on Sam (items 2, 5, 7).
|
||||
|
||||
---
|
||||
|
||||
## 1. What was rehearsed, and the result
|
||||
|
||||
`deploy/rehearse.sh` builds throwaway services and drives the real script. Full
|
||||
transcript of the run after the dual-bind change (24 assertions):
|
||||
|
||||
```
|
||||
== 0. preconditions
|
||||
PASS port 8096 free
|
||||
PASS three throwaway sources built in /tmp/.../rehearsal4
|
||||
|
||||
== 1. good release deploys and health-gates
|
||||
PASS deploy.sh exit 0
|
||||
PASS /healthz 200 on 127.0.0.1:8096
|
||||
PASS serving release 20261026T064248Z
|
||||
PASS container publishes on 127.0.0.1:8096 (loopback, used by the deploy.sh health gate)
|
||||
PASS container publishes on 192.168.20.13:8096 (the address Caddy on .35 proxies to)
|
||||
PASS no wildcard bind on 8096 — only: 127.0.0.1:8096 192.168.20.13:8096
|
||||
PASS /healthz 200 over the LAN address http://192.168.20.13:8096
|
||||
PASS memswap_limit == mem_limit (125829120 bytes)
|
||||
|
||||
== 2. build failure fails fast, current does not move
|
||||
PASS deploy.sh exit 1 (non-zero, as required)
|
||||
PASS current unchanged (/home/sam/deployment/resume/releases/20261026T064248Z)
|
||||
PASS previous release still serving
|
||||
|
||||
== 3. unhealthy release is rolled back by the health gate
|
||||
PASS deploy.sh exit 1 (non-zero, as required)
|
||||
PASS current restored to /home/sam/deployment/resume/releases/20261026T064248Z
|
||||
PASS rolled-back release serving again
|
||||
PASS log records the health-gate failure
|
||||
|
||||
== 4. deploy.sh --rollback restores the previous release in one command
|
||||
PASS second good release deployed
|
||||
PASS rollback exit 0
|
||||
PASS current flipped back to /home/sam/deployment/resume/releases/20261026T064248Z
|
||||
PASS rolled-back release healthy
|
||||
PASS serving 20261026T064248Z again (was 20261026T064307Z)
|
||||
|
||||
== 5. N3: docker compose down && up restores the service
|
||||
PASS service down after compose down
|
||||
PASS service restored after compose up
|
||||
|
||||
== verdict
|
||||
passed 24, failed 0
|
||||
```
|
||||
|
||||
Reachability from the Caddy host itself (`.35`), run during that rehearsal:
|
||||
|
||||
```
|
||||
$ ssh sam@192.168.20.35 'curl -sS -m3 -w "%{http_code}" http://192.168.20.13:8096/healthz'
|
||||
200
|
||||
```
|
||||
|
||||
That is the exact hop §3.3's `reverse_proxy` will take, proven before the Caddy
|
||||
block is applied.
|
||||
|
||||
The health-gate rollback in the deploy log, verbatim:
|
||||
|
||||
```
|
||||
06:28:46Z deploy: previous -> /home/sam/deployment/resume/releases/20261026T062828Z
|
||||
06:28:46Z deploy: current -> /home/sam/deployment/resume/releases/20261026T062842Z
|
||||
06:29:02Z deploy: HEALTH GATE FAILED for 20261026T062842Z
|
||||
06:29:02Z rollback: 20261026T062842Z failed -> restoring .../releases/20261026T062828Z
|
||||
06:29:04Z health: http://127.0.0.1:8096/healthz -> 200
|
||||
06:29:04Z rollback: previous release healthy (.../releases/20261026T062828Z)
|
||||
```
|
||||
|
||||
The deliberate bad build, verbatim:
|
||||
|
||||
```
|
||||
$ ./deploy/deploy.sh --source .../app-broken
|
||||
cmd/server/main.go:11:12: syntax error: unexpected {, expected )
|
||||
exit=1
|
||||
current before: .../releases/20261026T062828Z
|
||||
current after: .../releases/20261026T062828Z # unchanged
|
||||
health after broken build: 200 # old release still serving
|
||||
```
|
||||
|
||||
Re-run it any time with `./deploy/rehearse.sh` (exit 0 = every assertion held).
|
||||
|
||||
---
|
||||
|
||||
## 2. Container limits and bind scope
|
||||
|
||||
```
|
||||
$ ss -ltn | grep 8096
|
||||
LISTEN 0 4096 127.0.0.1:8096 0.0.0.0:*
|
||||
LISTEN 0 4096 192.168.20.13:8096 0.0.0.0:*
|
||||
|
||||
$ docker inspect resume-app
|
||||
mem_limit=125829120 memswap_limit=125829120
|
||||
port_bindings={"8096/tcp":[{"HostIp":"127.0.0.1","HostPort":"8096"},{"HostIp":"192.168.20.13","HostPort":"8096"}]}
|
||||
restart=unless-stopped
|
||||
log_config={"Type":"json-file","Config":{"max-file":"3","max-size":"10m"}}
|
||||
image=resume/app:20261026T064248Z
|
||||
|
||||
$ curl -sS http://127.0.0.1:8096/healthz
|
||||
{"status":"ok","static":true,"ai":"disabled","version":"20261026T064248Z",...}
|
||||
$ curl -sS http://192.168.20.13:8096/healthz
|
||||
{"status":"ok","static":true,"ai":"disabled","version":"20261026T064248Z",...}
|
||||
```
|
||||
|
||||
Two explicit addresses, no wildcard listener: `0.0.0.0` never appears, so
|
||||
nothing but Caddy on `.35` can be reached from outside the LAN. The reasoning is
|
||||
in §5.
|
||||
|
||||
Measured memory (idle Go static server, placeholder build):
|
||||
|
||||
```
|
||||
$ docker stats --no-stream --format 'rss={{.MemUsage}} pct={{.MemPerc}}' resume-app
|
||||
rss=1.73MiB / 120MiB pct=1.44%
|
||||
```
|
||||
|
||||
Host pressure at the time of the rehearsal — this is why the cap is 120m:
|
||||
|
||||
```
|
||||
$ free -m
|
||||
total used free available
|
||||
Mem: 15918 13943 223 1975
|
||||
```
|
||||
|
||||
No residue was left behind: after the rehearsal there is no `resume-app`
|
||||
container, no `resume/app:*` image, nothing listening on 8096, and
|
||||
`/home/sam/deployment/resume/` does not exist. `deploy.sh` creates it on the
|
||||
first real deploy. (The pre-existing `ai-resume-*` containers from
|
||||
`/home/sam/deployment/ai-resume/` were not touched.)
|
||||
|
||||
---
|
||||
|
||||
## 3. Facts established on the host
|
||||
|
||||
| Fact | Value |
|
||||
|---|---|
|
||||
| Port 8096 | free (`ss -ltn`; 8090, 8091, 8092, 8095 are taken) |
|
||||
| Release root | `/home/sam/deployment/resume/` — created by `deploy.sh` |
|
||||
| Go toolchain | not on `PATH`; `/nix/store/…-go-1.26.5/bin/go`, auto-detected by the script |
|
||||
| Container runtime | Docker 29.6.2, Compose 5.3.1 |
|
||||
| `.13` runs its own Caddy | `caddy run --config /etc/static/caddy/caddy_config`, listening `*:8000`, 8 legacy site blocks, all `reverse_proxy localhost:<port>` |
|
||||
| Services added since then | bind `0.0.0.0` and are proxied by `.35` directly on their own LAN port (hub 8788, compass 8792, email-agent 8790, webrestrict 8791) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Caddy block for Sam to apply (`.35`, additive, `caddy reload` only)
|
||||
|
||||
```caddy
|
||||
resume.lab.audasmedia.com.au {
|
||||
encode zstd gzip
|
||||
reverse_proxy 192.168.20.13:8096
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
X-Content-Type-Options "nosniff"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
Content-Security-Policy "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'"
|
||||
}
|
||||
@static path *.css *.js *.svg *.png *.jpg *.webp *.woff2
|
||||
header @static Cache-Control "public, max-age=31536000, immutable"
|
||||
}
|
||||
```
|
||||
|
||||
Apply, in this order:
|
||||
|
||||
```bash
|
||||
# 1. add the block above to the Caddyfile on .35, then validate:
|
||||
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
|
||||
# 2. reload — not restart, so other sites are not interrupted:
|
||||
caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
|
||||
# 3. confirm the other hostnames first, from anywhere on the LAN:
|
||||
for h in gitea.lab.audasmedia.com.au immich.lab.audasmedia.com.au \
|
||||
nextcloud.lab.audasmedia.com.au penpot.lab.audasmedia.com.au \
|
||||
wiki.lab.audasmedia.com.au chat.lab.audasmedia.com.au; do
|
||||
printf '%-45s %s\n' "$h" "$(curl -s -o /dev/null -w '%{http_code}' "https://$h/")"
|
||||
done
|
||||
# 4. then the new one (404/502 is expected until Phase 2's binary is deployed):
|
||||
curl -sSI https://resume.lab.audasmedia.com.au/ | sed -n '1,12p'
|
||||
```
|
||||
|
||||
Header check after that (`item 5` of the DoD): the response above must include
|
||||
`Strict-Transport-Security`, `X-Content-Type-Options`, `Referrer-Policy`,
|
||||
`X-Frame-Options` and `Content-Security-Policy`. If the page renders but the CSP
|
||||
breaks it, the CSP is wrong — do not weaken it to `unsafe-inline` for scripts.
|
||||
|
||||
---
|
||||
|
||||
## 5. Bind address — decided by Sam, 2026-10-26 (dual bind)
|
||||
|
||||
The phase document said two things that could not both be true:
|
||||
|
||||
- §3.2 and DoD item 3: the container publishes on **`127.0.0.1:8096` only**.
|
||||
- §3.3: Caddy **on `.35`** proxies to **`192.168.20.13:8096`**.
|
||||
|
||||
A loopback-only listener on `.13` is not reachable from `.35`. Measured at the
|
||||
time of the question:
|
||||
|
||||
```
|
||||
$ curl -sS -m3 http://192.168.20.13:8096/healthz
|
||||
curl: (7) Failed to connect to 192.168.20.13:8096
|
||||
```
|
||||
|
||||
`.13`'s own Caddy (`*:8000`, `/etc/static/caddy/caddy_config`) only serves eight
|
||||
legacy hostnames; every service added since (hub, compass, email-agent,
|
||||
webrestrict) is proxied by `.35` straight to its LAN port. §3.3's block assumes
|
||||
that pattern.
|
||||
|
||||
**Answer (AUD-133, 2026-10-26):** "A - dual bind: 127.0.0.1:8096 and
|
||||
192.168.20.13:8096". That is now the committed behaviour:
|
||||
|
||||
| Change | Where |
|
||||
|---|---|
|
||||
| both addresses in `ports:` | `deploy/docker-compose.yml` (`${APP_LAN_IP:-192.168.20.13}`) |
|
||||
| LAN address carried into the release `.env`; `--lan-ip ADDR` / `APP_LAN_IP` override | `deploy/deploy.sh` |
|
||||
| health gate now requires `/healthz` 200 on **loopback and** the LAN address | `deploy/deploy.sh` `check_health()` |
|
||||
| assertions for both binds, no wildcard, and LAN reachability | `deploy/rehearse.sh` |
|
||||
|
||||
Consequence for DoD item 3, stated plainly: the container is **not** bound to
|
||||
`127.0.0.1` only. It is bound to `127.0.0.1` and `192.168.20.13`, never
|
||||
`0.0.0.0`, so it is reachable on the `.13` LAN exactly like hub/compass today and
|
||||
not from anywhere else. That is a written change to §3.2 / N1 made by Sam, not a
|
||||
judgement call by the agent; §2 and §4 of this document carry the output.
|
||||
|
||||
No NixOS change and no `.13` Caddy change is needed under this option. §3.3's
|
||||
block is applied verbatim.
|
||||
|
||||
---
|
||||
|
||||
## 6. Deferred, with owners
|
||||
|
||||
| Item | Owner | Unblocked by |
|
||||
|---|---|---|
|
||||
| Deploy the real site (DoD 2) | this issue, woken by [AUD-132](/AUD/issues/AUD-132) | Phase 2 producing `bin/server` + `dist/` from `make build` |
|
||||
| Header + CSP proof (DoD 5) | this issue, after the Caddy block is applied | Sam applies §4 |
|
||||
| A1 over the public URL (DoD 6) | this issue, after 2 and 5 | — |
|
||||
| Caddy block applied and other hostnames confirmed (DoD 7) | **Sam** | §4 |
|
||||