Merge pull request 'feat: pin the worker container and gate readiness on the loaded model (#15)' (#75) from timmy/15-container-worker-receipts into main
This commit is contained in:
commit
5744969faf
27 files changed
+1167
-14
No files matched your search
@@ -0,0 +1,12 @@
|
||||
# The worker image is built from source; weights, releases, and private data never enter it.
|
||||
.git/
|
||||
node_modules/
|
||||
models/
|
||||
*.gguf
|
||||
artifacts/
|
||||
video/
|
||||
research/source-pages/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.env.*
|
||||
@@ -56,6 +56,7 @@ jobs:
|
||||
npm run test:provisional
|
||||
npm run test:followup
|
||||
npm run test:vision-refusal
|
||||
npm run test:worker-readiness
|
||||
- name: Dependency audit
|
||||
run: npm audit --audit-level=high
|
||||
- name: Syntax checks
|
||||
|
||||
@@ -176,6 +176,9 @@ async function loadVisionStatus(){
|
||||
}
|
||||
function visionStatusHtml(){
|
||||
if(!visionStatus)return '<div class="model-status checking">◌ Checking the vision worker…</div>';
|
||||
// Reachable is not loaded (#15): a worker answering before its pinned model is loaded must not be
|
||||
// shown as ready, because the AI controls are withheld until the model can actually answer.
|
||||
if(visionStatus.providerReady&&visionStatus.modelSeen!==true)return '<div class="model-status loading">◌ Vision worker up · the model is not loaded yet · manual logging is available</div>';
|
||||
if(visionStatus.profile==='selfhost'&&visionStatus.providerReady)return `<div class="model-status ready">● Self-hosted model ready · ${esc(visionStatus.model)}</div>`;
|
||||
if(visionStatus.providerReady)return `<div class="model-status ready">● Vision provider ready · ${esc(visionStatus.model)}</div>`;
|
||||
return '<div class="model-status offline">○ Vision worker offline · manual logging is still available</div>';
|
||||
@@ -192,7 +195,7 @@ function showPhotoFirst(mode='pick',error=''){
|
||||
photoFirstMode=mode;
|
||||
document.querySelector('.sheet-backdrop')?.remove();const wrap=document.createElement('div');wrap.className='sheet-backdrop';wrap.innerHTML=`<section class="sheet scan-sheet" role="dialog" aria-modal="true" aria-labelledby="scan-title"><div class="sheet-handle"></div><div class="sheet-header"><div><span class="eyebrow">Photo-first log</span><h2 id="scan-title">${mode==='result'?'Review Timmy’s suggestion':mode==='analyzing'?'Analyzing privately':'Start with the camera'}</h2></div><button class="icon-btn" id="close-sheet" aria-label="Close">×</button></div>${photoFirstBody(mode,error)}</section>`;document.body.append(wrap);document.querySelector('#close-sheet').onclick=()=>wrap.remove();wrap.onclick=e=>{if(e.target===wrap)wrap.remove()};
|
||||
document.querySelectorAll('#camera-photo,#gallery-photo').forEach(file=>{file.onchange=handleAiPhoto;file.addEventListener('cancel',()=>showPhotoFirst('pick','Camera or photo picker closed. If permission was denied, allow camera access in browser settings, choose from the gallery, or continue without AI.'))});
|
||||
const consent=document.querySelector('#ai-consent'),analyze=document.querySelector('#analyze-photo');if(consent&&analyze)consent.onchange=()=>analyze.disabled=!consent.checked||visionStatus?.providerReady===false;if(analyze)analyze.onclick=runAiAnalysis;
|
||||
const consent=document.querySelector('#ai-consent'),analyze=document.querySelector('#analyze-photo');if(consent&&analyze)consent.onchange=()=>analyze.disabled=!consent.checked||!visionReadiness(visionStatus).ready;if(analyze)analyze.onclick=runAiAnalysis;
|
||||
document.querySelector('#retake-photo')?.addEventListener('click',()=>{form.photoDataUrl='';photoHint='';resetSuggestion();showPhotoFirst('pick')});
|
||||
document.querySelector('#manual-from-scan')?.addEventListener('click',()=>{resetSuggestion();showLogStep(1)});
|
||||
document.querySelector('#use-suggestion')?.addEventListener('click',()=>{suggestionState=confirmSuggestion(suggestionState);form=mergeVisualSuggestion(form,aiSuggestion);form.typeChosen=true;showLogStep(1)});
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 249 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 207 KiB |
@@ -0,0 +1,67 @@
|
||||
# Timmy self-hosted vision worker (issue #15).
|
||||
#
|
||||
# Reproducible by construction: the llama.cpp revision and both model hashes are pinned here and in
|
||||
# deploy/worker/receipts.json, and tests/worker-container.test.js fails if either drifts from
|
||||
# scripts/bootstrap_selfhost_smolvlm.sh on the host.
|
||||
#
|
||||
# The weights are deliberately NOT baked into the image. They are mounted read-only at /models and
|
||||
# hash-verified against the pinned receipts by the entrypoint before the server starts.
|
||||
|
||||
ARG LLAMA_CPP_COMMIT=6d05498314db1b57f81c271080018aa2d0b89be9
|
||||
ARG MODEL_SHA256=0cf76814555b8665149075b74ab6b5c1d428ea1d3d01c1918c12012e8d7c9f58
|
||||
ARG MMPROJ_SHA256=ae07ea1facd07dd3230c4483b63e8cda96c6944ad2481f33d531f79e892dd024
|
||||
|
||||
# Digest-pinned bases, recorded in deploy/worker/receipts.json. A tag alone can be repointed, so
|
||||
# the digest is what makes this build reproducible.
|
||||
FROM nvidia/cuda:12.4.1-devel-ubuntu22.04@sha256:da6791294b0b04d7e65d87b7451d6f2390b4d36225ab0701ee7dfec5769829f5 AS build
|
||||
|
||||
ARG LLAMA_CPP_COMMIT
|
||||
RUN apt-get update \
|
||||
&& apt-get install --yes --no-install-recommends git ca-certificates cmake build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /src
|
||||
RUN git init llama.cpp \
|
||||
&& cd llama.cpp \
|
||||
&& git remote add origin https://github.com/ggml-org/llama.cpp \
|
||||
&& git fetch --depth 1 origin "${LLAMA_CPP_COMMIT}" \
|
||||
&& git checkout --detach FETCH_HEAD
|
||||
RUN cmake -S llama.cpp -B llama.cpp/build -DCMAKE_BUILD_TYPE=Release -DLLAMA_CURL=OFF -DGGML_CUDA=ON \
|
||||
&& cmake --build llama.cpp/build --parallel "$(nproc)" --target llama-server
|
||||
|
||||
FROM nvidia/cuda:12.4.1-runtime-ubuntu22.04@sha256:517da2300c184c9999ec203c2665244bdebd3578d12fcc7065e83667932643d9 AS runtime
|
||||
|
||||
ARG MODEL_SHA256
|
||||
ARG MMPROJ_SHA256
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install --yes --no-install-recommends curl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& mkdir -p /models
|
||||
|
||||
COPY --from=build /src/llama.cpp/build/bin/ /usr/local/bin/
|
||||
COPY deploy/worker/entrypoint.sh /usr/local/bin/timmy-worker-entrypoint
|
||||
COPY scripts/worker_ready.sh /usr/local/bin/timmy-worker-ready
|
||||
RUN chmod 0755 /usr/local/bin/timmy-worker-entrypoint /usr/local/bin/timmy-worker-ready && ldconfig
|
||||
|
||||
# No credential is configured: the worker is a private service the app reaches over host loopback.
|
||||
# 0.0.0.0 here is the address inside this container's own network namespace; the run recipe decides
|
||||
# exposure and publishes on 127.0.0.1 only.
|
||||
ENV TIMMY_MODEL_DIR=/models \
|
||||
TIMMY_MODEL_FILE=/models/SmolVLM2-2.2B-Instruct-Q4_K_M.gguf \
|
||||
TIMMY_MMPROJ_FILE=/models/mmproj-SmolVLM2-2.2B-Instruct-Q8_0.gguf \
|
||||
TIMMY_MODEL_SHA256=${MODEL_SHA256} \
|
||||
TIMMY_MMPROJ_SHA256=${MMPROJ_SHA256} \
|
||||
TIMMY_MODEL_ALIAS=SmolVLM2-2.2B-Instruct \
|
||||
TIMMY_MODEL_HOST=0.0.0.0 \
|
||||
TIMMY_MODEL_PORT=8080
|
||||
|
||||
USER 10001:10001
|
||||
EXPOSE 8080
|
||||
|
||||
# Health is the post-load gate: the worker is not healthy until the pinned model is being served,
|
||||
# so an orchestrator cannot route to a worker that cannot answer. start-period covers a cold load.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=600s --retries=3 \
|
||||
CMD ["/usr/local/bin/timmy-worker-ready", "--host", "127.0.0.1", "--port", "8080", "--alias", "SmolVLM2-2.2B-Instruct"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/timmy-worker-entrypoint"]
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the pinned Timmy worker image (issue #15).
|
||||
#
|
||||
# Every pin is checked before docker is invoked, so an unpinned or floating reference fails here
|
||||
# rather than producing an image nobody can reproduce.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
RECEIPTS="$REPO_ROOT/deploy/worker/receipts.json"
|
||||
IMAGE="${TIMMY_WORKER_IMAGE:-timmy-vision-worker}"
|
||||
TAG="${TIMMY_WORKER_TAG:-$(date -u +%Y%m%d)}"
|
||||
PUBLISH_HOST="${TIMMY_WORKER_PUBLISH_HOST:-127.0.0.1}"
|
||||
PORT="${TIMMY_MODEL_PORT:-8080}"
|
||||
|
||||
receipt_field() {
|
||||
python3 - "$RECEIPTS" "$1" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
document = json.load(open(sys.argv[1]))
|
||||
value = document
|
||||
for part in sys.argv[2].split('.'):
|
||||
value = value[int(part)] if part.isdigit() else value[part]
|
||||
print(value)
|
||||
PY
|
||||
}
|
||||
|
||||
# An explicit value still wins, but an empty one is treated as an invalid pin rather than a default.
|
||||
LLAMA_CPP_COMMIT="${TIMMY_LLAMA_CPP_COMMIT-$(receipt_field llamaCpp.commit)}"
|
||||
MODEL_SHA256="${TIMMY_MODEL_SHA256-$(receipt_field models.0.sha256)}"
|
||||
MMPROJ_SHA256="${TIMMY_MMPROJ_SHA256-$(receipt_field models.1.sha256)}"
|
||||
MODEL_FILE="$(receipt_field models.0.file)"
|
||||
MMPROJ_FILE="$(receipt_field models.1.file)"
|
||||
BUILD_BASE="$(receipt_field baseImages.build.reference)@$(receipt_field baseImages.build.digest)"
|
||||
RUNTIME_BASE="$(receipt_field baseImages.runtime.reference)@$(receipt_field baseImages.runtime.digest)"
|
||||
|
||||
if [[ ! "$LLAMA_CPP_COMMIT" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
printf 'Refusing to build: the llama.cpp pin must be a 40-hex commit, got %s (floating refs are not reproducible).\n' "${LLAMA_CPP_COMMIT:-<empty>}" >&2
|
||||
exit 64
|
||||
fi
|
||||
for pin in "$MODEL_SHA256" "$MMPROJ_SHA256"; do
|
||||
if [[ ! "$pin" =~ ^[0-9a-f]{64}$ ]]; then
|
||||
printf 'Refusing to build: every model hash must be a pinned sha256, got %s.\n' "${pin:-<empty>}" >&2
|
||||
exit 64
|
||||
fi
|
||||
done
|
||||
for base in "$BUILD_BASE" "$RUNTIME_BASE"; do
|
||||
if [[ ! "$base" =~ @sha256:[0-9a-f]{64}$ ]]; then
|
||||
printf 'Refusing to build: base images must be digest-pinned, got %s.\n' "$base" >&2
|
||||
exit 64
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ! "$PUBLISH_HOST" =~ ^(127\.0\.0\.1|::1|localhost)$ ]]; then
|
||||
printf 'Refusing to publish the worker outside loopback (127.0.0.1): got %s.\n' "$PUBLISH_HOST" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
print_plan() {
|
||||
printf 'image: %s:%s\n' "$IMAGE" "$TAG"
|
||||
printf 'llama.cpp commit: %s\n' "$LLAMA_CPP_COMMIT"
|
||||
printf '%s sha256: %s\n' "$MODEL_FILE" "$MODEL_SHA256"
|
||||
printf '%s sha256: %s\n' "$MMPROJ_FILE" "$MMPROJ_SHA256"
|
||||
printf 'base image (build): %s\n' "$BUILD_BASE"
|
||||
printf 'base image (runtime): %s\n' "$RUNTIME_BASE"
|
||||
printf 'bind address: %s:%s\n' "$PUBLISH_HOST" "$PORT"
|
||||
printf 'publish: %s:%s\n' "$PUBLISH_HOST" "$PORT"
|
||||
printf 'docker build --file deploy/worker/Dockerfile --build-arg LLAMA_CPP_COMMIT=%s --build-arg MODEL_SHA256=%s --build-arg MMPROJ_SHA256=%s --tag %s:%s .\n' \
|
||||
"$LLAMA_CPP_COMMIT" "$MODEL_SHA256" "$MMPROJ_SHA256" "$IMAGE" "$TAG"
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == '--print-plan' ]]; then
|
||||
print_plan
|
||||
exit 0
|
||||
fi
|
||||
|
||||
print_plan
|
||||
cd "$REPO_ROOT"
|
||||
docker build \
|
||||
--file deploy/worker/Dockerfile \
|
||||
--build-arg "LLAMA_CPP_COMMIT=$LLAMA_CPP_COMMIT" \
|
||||
--build-arg "MODEL_SHA256=$MODEL_SHA256" \
|
||||
--build-arg "MMPROJ_SHA256=$MMPROJ_SHA256" \
|
||||
--tag "$IMAGE:$TAG" \
|
||||
.
|
||||
printf 'Built %s:%s from pinned receipts. Run it with deploy/worker/run_worker_container.sh\n' "$IMAGE" "$TAG"
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# Start the pinned worker after verifying the mounted weights (issue #15).
|
||||
#
|
||||
# Fails closed before the server is allowed to answer: a missing pin, an unreadable model, or a
|
||||
# hash mismatch stops the container instead of serving suggestions from an unknown model.
|
||||
set -euo pipefail
|
||||
|
||||
MODEL="${TIMMY_MODEL_FILE:-/models/SmolVLM2-2.2B-Instruct-Q4_K_M.gguf}"
|
||||
MMPROJ="${TIMMY_MMPROJ_FILE:-/models/mmproj-SmolVLM2-2.2B-Instruct-Q8_0.gguf}"
|
||||
MODEL_SHA256="${TIMMY_MODEL_SHA256:-}"
|
||||
MMPROJ_SHA256="${TIMMY_MMPROJ_SHA256:-}"
|
||||
ALIAS="${TIMMY_MODEL_ALIAS:-SmolVLM2-2.2B-Instruct}"
|
||||
HOST="${TIMMY_MODEL_HOST:-0.0.0.0}"
|
||||
PORT="${TIMMY_MODEL_PORT:-8080}"
|
||||
THREADS="${TIMMY_MODEL_THREADS:-4}"
|
||||
|
||||
for pin in "$MODEL_SHA256" "$MMPROJ_SHA256"; do
|
||||
if [[ ! "$pin" =~ ^[0-9a-f]{64}$ ]]; then
|
||||
printf 'Refusing to start: the pinned model hashes are missing or malformed.\n' >&2
|
||||
exit 78
|
||||
fi
|
||||
done
|
||||
|
||||
for file in "$MODEL" "$MMPROJ"; do
|
||||
if [[ ! -f "$file" ]]; then
|
||||
printf 'Refusing to start: %s is not mounted. Mount the verified model directory read-only at /models.\n' "$file" >&2
|
||||
exit 78
|
||||
fi
|
||||
done
|
||||
|
||||
printf '%s %s\n%s %s\n' "$MODEL_SHA256" "$MODEL" "$MMPROJ_SHA256" "$MMPROJ" | sha256sum --check --status || {
|
||||
printf 'Refusing to start: mounted weights do not match the pinned receipts.\n' >&2
|
||||
exit 78
|
||||
}
|
||||
|
||||
printf 'Starting %s from pinned weights on %s:%s (private namespace)\n' "$ALIAS" "$HOST" "$PORT"
|
||||
exec llama-server \
|
||||
--model "$MODEL" \
|
||||
--mmproj "$MMPROJ" \
|
||||
--alias "$ALIAS" \
|
||||
--host "$HOST" \
|
||||
--port "$PORT" \
|
||||
--ctx-size 4096 \
|
||||
--parallel 1 \
|
||||
--threads "$THREADS" \
|
||||
--no-webui
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"issue": 15,
|
||||
"llamaCpp": {
|
||||
"repository": "https://github.com/ggml-org/llama.cpp",
|
||||
"commit": "6d05498314db1b57f81c271080018aa2d0b89be9"
|
||||
},
|
||||
"baseImages": {
|
||||
"build": {
|
||||
"reference": "nvidia/cuda:12.4.1-devel-ubuntu22.04",
|
||||
"digest": "sha256:da6791294b0b04d7e65d87b7451d6f2390b4d36225ab0701ee7dfec5769829f5"
|
||||
},
|
||||
"runtime": {
|
||||
"reference": "nvidia/cuda:12.4.1-runtime-ubuntu22.04",
|
||||
"digest": "sha256:517da2300c184c9999ec203c2665244bdebd3578d12fcc7065e83667932643d9"
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"file": "SmolVLM2-2.2B-Instruct-Q4_K_M.gguf",
|
||||
"sha256": "0cf76814555b8665149075b74ab6b5c1d428ea1d3d01c1918c12012e8d7c9f58",
|
||||
"mount": "/models/SmolVLM2-2.2B-Instruct-Q4_K_M.gguf"
|
||||
},
|
||||
{
|
||||
"file": "mmproj-SmolVLM2-2.2B-Instruct-Q8_0.gguf",
|
||||
"sha256": "ae07ea1facd07dd3230c4483b63e8cda96c6944ad2481f33d531f79e892dd024",
|
||||
"mount": "/models/mmproj-SmolVLM2-2.2B-Instruct-Q8_0.gguf"
|
||||
}
|
||||
],
|
||||
"alias": "SmolVLM2-2.2B-Instruct",
|
||||
"bind": {
|
||||
"publishHost": "127.0.0.1",
|
||||
"containerPort": 8080
|
||||
},
|
||||
"notes": [
|
||||
"Model weights are never baked into the image; they are mounted read-only and hash-verified at start-up.",
|
||||
"The pins are asserted equal to scripts/bootstrap_selfhost_smolvlm.sh by tests/worker-container.test.js so host and image cannot drift.",
|
||||
"The worker needs no provider credential: it is a private loopback-only service."
|
||||
]
|
||||
}
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run the pinned worker with the least privilege it can work under (issue #15).
|
||||
#
|
||||
# The worker is a private service. It is published on loopback only, mounts the verified weights
|
||||
# read-only, keeps an immutable root filesystem, and holds no credential of any kind.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
RECEIPTS="$REPO_ROOT/deploy/worker/receipts.json"
|
||||
IMAGE="${TIMMY_WORKER_IMAGE:-timmy-vision-worker}"
|
||||
TAG="${TIMMY_WORKER_TAG:-$(date -u +%Y%m%d)}"
|
||||
PORT="${TIMMY_MODEL_PORT:-8080}"
|
||||
PUBLISH_HOST="${TIMMY_WORKER_PUBLISH_HOST:-127.0.0.1}"
|
||||
MODEL_DIR="${TIMMY_WORKER_MODEL_DIR:-${TIMMY_SELFHOST_ROOT:-$HOME/.local/share/timmy-selfhost}/models}"
|
||||
NAME="${TIMMY_WORKER_CONTAINER:-timmy-vision-worker}"
|
||||
ALIAS="${TIMMY_MODEL_ALIAS:-SmolVLM2-2.2B-Instruct}"
|
||||
|
||||
# Exposure is the one decision this recipe must never get wrong, so a non-loopback publish host is
|
||||
# refused rather than warned about.
|
||||
if [[ ! "$PUBLISH_HOST" =~ ^(127\.0\.0\.1|::1|localhost)$ ]]; then
|
||||
printf 'Refusing to publish the worker outside loopback (127.0.0.1): got %s.\n' "$PUBLISH_HOST" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
if [[ ! "$PORT" =~ ^[0-9]{1,5}$ ]] || (( PORT < 1 || PORT > 65535 )); then
|
||||
printf 'Refusing to start: the port must be a number between 1 and 65535, got %s.\n' "$PORT" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
RUN_ARGS=(
|
||||
run
|
||||
--detach
|
||||
--name "$NAME"
|
||||
--read-only
|
||||
--cap-drop=ALL
|
||||
--security-opt no-new-privileges
|
||||
--tmpfs /tmp:rw,noexec,nosuid,size=64m
|
||||
--volume "$MODEL_DIR:/models:ro"
|
||||
--publish "127.0.0.1:${PORT}:8080"
|
||||
--env "TIMMY_MODEL_PORT=8080"
|
||||
--env "TIMMY_MODEL_THREADS=${TIMMY_MODEL_THREADS:-4}"
|
||||
"$IMAGE:$TAG"
|
||||
)
|
||||
|
||||
if [[ "${1:-}" == '--print-plan' ]]; then
|
||||
printf 'image: %s:%s\n' "$IMAGE" "$TAG"
|
||||
printf 'model directory (read-only mount): %s\n' "$MODEL_DIR"
|
||||
printf 'bind address: %s:%s\n' "$PUBLISH_HOST" "$PORT"
|
||||
printf 'publish: %s:%s\n' "$PUBLISH_HOST" "$PORT"
|
||||
printf 'health: %s ready (post-load gate)\n' "$ALIAS"
|
||||
printf 'docker %s\n' "${RUN_ARGS[*]}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ! -d "$MODEL_DIR" ]]; then
|
||||
printf 'Refusing to start: %s is not a directory. Install the pinned weights with scripts/bootstrap_selfhost_smolvlm.sh install.\n' "$MODEL_DIR" >&2
|
||||
exit 78
|
||||
fi
|
||||
|
||||
docker "${RUN_ARGS[@]}"
|
||||
printf 'Worker container %s started on %s:%s. Readiness: docker exec %s timmy-worker-ready\n' "$NAME" "$PUBLISH_HOST" "$PORT" "$NAME"
|
||||
+4
-3
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/draft.test.js tests/suggestion.test.js tests/followup.test.js tests/vision-readiness.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/service-worker-runtime.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js tests/staging-config.test.js tests/threat-model.test.js tests/vision-corpus.test.js tests/vision-boundary.test.js",
|
||||
"test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/draft.test.js tests/suggestion.test.js tests/followup.test.js tests/vision-readiness.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/service-worker-runtime.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js tests/staging-config.test.js tests/threat-model.test.js tests/vision-corpus.test.js tests/vision-boundary.test.js tests/worker-benchmark.test.js tests/worker-ready.test.js tests/worker-container.test.js",
|
||||
"test:ui": "node tests/ui.acceptance.mjs",
|
||||
"test:photo": "node tests/photo-first.acceptance.mjs",
|
||||
"test:mobile-capture": "node tests/mobile-capture.acceptance.mjs",
|
||||
@@ -14,9 +14,10 @@
|
||||
"test:followup": "node tests/followup.acceptance.mjs",
|
||||
"test:vision-refusal": "node tests/vision-refusal.acceptance.mjs",
|
||||
"test:staging-smoke": "node tests/staging.acceptance.mjs",
|
||||
"check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/draft.js && node --check src/suggestion.js && node --check src/followup.js && node --check src/vision-readiness.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check src/vision-corpus.js && node --check scripts/record_release_demo.mjs && node --check tests/staging.acceptance.mjs && node --check tests/manual-fallback.acceptance.mjs && node --check tests/provisional-suggestion.acceptance.mjs && node --check tests/followup.acceptance.mjs && node --check tests/vision-refusal.acceptance.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py scripts/build_vision_corpus.py",
|
||||
"check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/draft.js && node --check src/suggestion.js && node --check src/followup.js && node --check src/vision-readiness.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check src/vision-corpus.js && node --check scripts/record_release_demo.mjs && node --check tests/staging.acceptance.mjs && node --check tests/manual-fallback.acceptance.mjs && node --check tests/provisional-suggestion.acceptance.mjs && node --check tests/followup.acceptance.mjs && node --check tests/vision-refusal.acceptance.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py scripts/build_vision_corpus.py && node --check scripts/record_worker_benchmark.mjs && node --check tests/worker-readiness.acceptance.mjs && bash -n scripts/worker_ready.sh && bash -n deploy/worker/entrypoint.sh && bash -n deploy/worker/build_image.sh && bash -n deploy/worker/run_worker_container.sh",
|
||||
"check:diff": "bash scripts/check_diff.sh",
|
||||
"start": "node server.mjs"
|
||||
"start": "node server.mjs",
|
||||
"test:worker-readiness": "node tests/worker-readiness.acceptance.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"playwright": "^1.62.1"
|
||||
|
||||
@@ -137,6 +137,10 @@ case "${1:-}" in
|
||||
start)
|
||||
start_worker
|
||||
;;
|
||||
ready)
|
||||
# Post-load readiness (issue #15): delegate so the host and the container gate cannot drift.
|
||||
exec "$(dirname "$0")/worker_ready.sh" --host "$HOST" --port "$PORT" --alias SmolVLM2-2.2B-Instruct "${@:2}"
|
||||
;;
|
||||
health)
|
||||
health_worker
|
||||
;;
|
||||
@@ -144,7 +148,7 @@ case "${1:-}" in
|
||||
stop_worker
|
||||
;;
|
||||
*)
|
||||
printf 'Usage: %s {install|start|health|stop|receipt|verify}\n' "$0" >&2
|
||||
printf 'Usage: %s {install|start|health|ready|stop|receipt|verify}\n' "$0" >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
@@ -39,6 +39,7 @@ DEMO_REQUIRED_CAPTIONS = (
|
||||
"Urgent language is intercepted deterministically before Hermes",
|
||||
"An out-of-range answer fails closed: nothing is applied and no provider detail reaches you",
|
||||
"A filled-in field that is out of range still fails closed, even when the other field came back blank",
|
||||
"Reachable is not loaded: no AI control, no consent, and no upload until the pinned model is resident",
|
||||
)
|
||||
|
||||
|
||||
@@ -136,6 +137,7 @@ def main() -> int:
|
||||
run(["npm", "run", "test:provisional"], tree)
|
||||
run(["npm", "run", "test:followup"], tree)
|
||||
run(["npm", "run", "test:vision-refusal"], tree)
|
||||
run(["npm", "run", "test:worker-readiness"], tree)
|
||||
demo_raw = release_dir / f"timmy-talking-turd-{version}-demo.raw.webm"
|
||||
caption_receipt = release_dir / f"timmy-talking-turd-{version}-demo-captions.json"
|
||||
demo_env = dict(server_env)
|
||||
@@ -177,10 +179,14 @@ def main() -> int:
|
||||
if not contact_sheet.exists() or contact_sheet.stat().st_size == 0:
|
||||
raise SystemExit("Release demo contact sheet was not produced")
|
||||
run(["npm", "audit", "--audit-level=high"], tree)
|
||||
for file in ("app.js", "server.mjs", "service-worker.js", "src/analysis.js", "src/domain.js", "src/draft.js", "src/suggestion.js", "src/followup.js", "src/vision-readiness.js", "src/hermes-agent-service.js", "src/vision-config.js", "src/vision-service.js", "src/vision-corpus.js", "tests/staging.acceptance.mjs", "tests/vision-refusal.acceptance.mjs"):
|
||||
for file in ("app.js", "server.mjs", "service-worker.js", "src/analysis.js", "src/domain.js", "src/draft.js", "src/suggestion.js", "src/followup.js", "src/vision-readiness.js", "src/hermes-agent-service.js", "src/vision-config.js", "src/vision-service.js", "src/vision-corpus.js", "scripts/record_worker_benchmark.mjs", "tests/staging.acceptance.mjs", "tests/vision-refusal.acceptance.mjs", "tests/worker-readiness.acceptance.mjs"):
|
||||
run(["node", "--check", file], tree)
|
||||
run(["bash", "-n", "scripts/bootstrap_selfhost_smolvlm.sh"], tree)
|
||||
run(["bash", "-n", "scripts/run_selfhost_smolvlm.sh"], tree)
|
||||
run(["bash", "-n", "scripts/worker_ready.sh"], tree)
|
||||
run(["bash", "-n", "deploy/worker/entrypoint.sh"], tree)
|
||||
run(["bash", "-n", "deploy/worker/build_image.sh"], tree)
|
||||
run(["bash", "-n", "deploy/worker/run_worker_container.sh"], tree)
|
||||
run(["python3", "-m", "py_compile", "scripts/ingest_training_photo.py", "scripts/build_release.py", "scripts/build_vision_corpus.py"], tree)
|
||||
run(["git", "diff", "--check", commit], tree)
|
||||
|
||||
@@ -209,6 +215,9 @@ def main() -> int:
|
||||
"- AI suggestions stay provisional: suggested, confirmed, and corrected states are distinct and screen-reader described; correcting one clears the AI-confirmed state without losing user context\n"
|
||||
"- Hard-stool follow-up: a Bristol 1-2 entry asks user-reported questions (difficulty passing, soreness, visible blood, truly black/tarry versus dark brown, hydration); mild reports get non-diagnostic self-care, and a confirmed red-flag answer lands on the same deterministic escalation path as manual logging\n"
|
||||
"- Vision corpus: a committed CC0 synthetic corpus (stool, non-stool, blur, glare, empty bowl, malformed response) proves every reply is schema-valid or fails closed; a schema-valid reply with unreadable fields abstains instead of erroring, and unparsable or out-of-range replies return plain copy with nothing applied — including a reply that fills in one field and leaves the other blank, where an out-of-range value still fails closed instead of being reported as an unreadable photo (issue #73)\n"
|
||||
"- Self-hosted worker container: a digest-pinned image that builds the pinned llama.cpp revision, mounts hash-verified weights read-only, runs unprivileged with no credential, and is published on loopback only (issue #15)\n"
|
||||
"- Post-load readiness: the app offers AI only once the pinned model is actually loaded, so a reachable-but-unloaded worker falls back to manual logging in one tap instead of failing after the upload (issue #15)\n"
|
||||
"- Target-GPU benchmark: scripts/record_worker_benchmark.mjs records cold start, warm latency, and VRAM on the target GPU and refuses to write a receipt it did not measure; no GPU measurement is bundled with this release\n"
|
||||
"- Safety: suggestions are observable-field assistance, never diagnosis; user confirmation is required\n"
|
||||
"- Known limitation: open-weight VLM acceptance is proven, Bristol accuracy is not clinically validated\n"
|
||||
"- Package excludes model weights, medical-image corpora, raw research pages, generated screenshots, and video production assets\n"
|
||||
@@ -265,6 +274,8 @@ def main() -> int:
|
||||
"provisional_suggestion_state": "passed",
|
||||
"hard_stool_followup": "passed",
|
||||
"vision_corpus_refusal": "passed",
|
||||
"worker_container_receipts": "passed",
|
||||
"worker_post_load_readiness": "passed",
|
||||
"dependency_audit_high": "passed",
|
||||
"syntax": "passed",
|
||||
"secret_scan": "passed",
|
||||
|
||||
@@ -49,6 +49,9 @@ const errors = [];
|
||||
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
let visionOffline = false;
|
||||
// Reachable is not loaded: the worker can answer /v1/models before its pinned model is resident,
|
||||
// and the recording shows the app withholding AI in exactly that state (issue #15).
|
||||
let modelLoaded = true;
|
||||
await page.route('**/api/vision-status', route => route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
@@ -59,7 +62,7 @@ await page.route('**/api/vision-status', route => route.fulfill({
|
||||
model: 'SmolVLM2-2.2B-Instruct',
|
||||
// The second pass flips the app's own readiness contract so no AI control is offered at all.
|
||||
providerReady: !visionOffline,
|
||||
modelSeen: !visionOffline,
|
||||
modelSeen: !visionOffline && modelLoaded,
|
||||
}),
|
||||
}));
|
||||
// The happy path keeps one deterministic suggestion. The refusal scene swaps this for a real
|
||||
@@ -231,6 +234,38 @@ await tap('#discard-draft', 500);
|
||||
await caption('Start over clears the kept draft — nothing lingers silently', 1500);
|
||||
await tap('#close-sheet', 450);
|
||||
|
||||
// Post-load readiness pass (issue #15): the worker answers, but its pinned model is not resident
|
||||
// yet. The app must withhold AI instead of offering a control that fails after the upload. Every
|
||||
// claim below is asserted in the browser, so the recording cannot show a state the app does not have.
|
||||
modelLoaded = false;
|
||||
visionOffline = false;
|
||||
await caption('Now the same worker while its pinned model is still loading', 1700);
|
||||
await tap('[data-scan]', 550);
|
||||
await page.getByText(/model is not loaded yet/i).first().waitFor();
|
||||
if (await page.getByText(/Self-hosted model ready/i).count() !== 0) {
|
||||
throw new Error('A reachable worker without its loaded model must never be reported ready');
|
||||
}
|
||||
const loadingAmber = await page.locator('.model-status.loading').first().evaluate(el => getComputedStyle(el).backgroundColor);
|
||||
if (loadingAmber !== 'rgb(253, 243, 216)') throw new Error('The not-loaded state must be visually distinct from ready and offline');
|
||||
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
|
||||
await page.getByText(/Ready for your review/i).waitFor();
|
||||
if (await page.locator('#analyze-photo').count() !== 0) throw new Error('No AI control may exist before the model is loaded');
|
||||
if (await page.locator('.consent-card').count() !== 0) throw new Error('No consent may be requested before the model is loaded');
|
||||
await caption('Reachable is not loaded: no AI control, no consent, and no upload until the pinned model is resident', 2900);
|
||||
await tap('#manual-from-scan', 500);
|
||||
await page.getByText('Pick the closest form').waitFor();
|
||||
await caption('One tap still reaches manual Bristol entry while the model finishes loading', 2000);
|
||||
await tap('#close-sheet', 450);
|
||||
modelLoaded = true;
|
||||
await caption('Once the pinned model is loaded, the same screen offers the AI analysis again', 2200);
|
||||
await tap('[data-scan]', 550);
|
||||
await page.getByText(/Self-hosted model ready/i).waitFor();
|
||||
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
|
||||
await page.getByText(/Ready for your review/i).waitFor();
|
||||
if (await page.locator('.consent-card').isVisible() !== true) throw new Error('The AI path must return once the model is loaded');
|
||||
if (await page.locator('#analyze-photo').count() !== 1) throw new Error('The analyze control must return once the model is loaded');
|
||||
await tap('#close-sheet', 450);
|
||||
|
||||
// Corpus refusal pass (issue #18): the same photo flow, fed the answers Timmy has to refuse.
|
||||
visionOffline = false;
|
||||
await caption('The corpus also replays the answers Timmy has to refuse', 1700);
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env node
|
||||
// Record the target-GPU cost of the pinned worker (issue #15).
|
||||
//
|
||||
// This script measures; it never invents. If no GPU can be probed, if the pinned model is not
|
||||
// loaded, or if the cold start cannot be observed, it exits non-zero and writes no receipt at all,
|
||||
// because a receipt with zeroed or assumed numbers is worse than no receipt.
|
||||
import { spawn } from 'node:child_process';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const LOOPBACK = /^(127\.0\.0\.1|localhost|::1)$/;
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
out: '',
|
||||
host: '127.0.0.1',
|
||||
port: '8080',
|
||||
alias: 'SmolVLM2-2.2B-Instruct',
|
||||
gpuProbe: 'nvidia-smi',
|
||||
warmSamples: 20,
|
||||
startCommand: '',
|
||||
readyTimeoutSeconds: 900,
|
||||
};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const [flag, inline] = argv[index].split('=');
|
||||
const value = inline ?? argv[index + 1];
|
||||
const consume = inline === undefined ? 2 : 1;
|
||||
if (flag === '--out') options.out = value;
|
||||
else if (flag === '--host') options.host = value;
|
||||
else if (flag === '--port') options.port = value;
|
||||
else if (flag === '--alias') options.alias = value;
|
||||
else if (flag === '--gpu-probe') options.gpuProbe = value;
|
||||
else if (flag === '--warm-samples') options.warmSamples = Number(value);
|
||||
else if (flag === '--start-command') options.startCommand = value;
|
||||
else if (flag === '--ready-timeout') options.readyTimeoutSeconds = Number(value);
|
||||
else fail(`Unknown argument: ${flag}`);
|
||||
index += consume - 1;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function fail(message, code = 64) {
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
const reject = message => {
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
async function modelsServed({ host, port, alias }) {
|
||||
try {
|
||||
const response = await fetch(`http://${host}:${port}/v1/models`, { signal: AbortSignal.timeout(3000) });
|
||||
if (!response.ok) return false;
|
||||
const body = await response.json();
|
||||
const ids = Array.isArray(body?.data) ? body.data.map(item => item?.id).filter(Boolean) : [];
|
||||
return ids.some(id => id === alias || id.includes(alias));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runCommand(command, args) {
|
||||
return new Promise(resolve => {
|
||||
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', chunk => { stdout += chunk; });
|
||||
child.stderr.on('data', chunk => { stderr += chunk; });
|
||||
child.on('error', error => resolve({ code: 127, stdout, stderr: String(error.message) }));
|
||||
child.on('close', code => resolve({ code, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
async function probeGpu(probeCommand) {
|
||||
const result = await runCommand(probeCommand, [
|
||||
'--query-gpu=name,memory.total,memory.used',
|
||||
'--format=csv,noheader,nounits',
|
||||
]);
|
||||
if (result.code !== 0) {
|
||||
reject(`No GPU could be probed with ${probeCommand}: ${result.stderr.trim() || `exit ${result.code}`}. A benchmark receipt needs a real device, so nothing was written.`);
|
||||
}
|
||||
const [name, total, used] = result.stdout.trim().split('\n')[0].split(',').map(part => part.trim());
|
||||
if (!name || !/^\d+$/.test(total) || !/^\d+$/.test(used)) {
|
||||
reject(`The GPU probe returned unreadable output (${JSON.stringify(result.stdout.trim())}); refusing to record VRAM it cannot read.`);
|
||||
}
|
||||
return { name, vramTotalMiB: Number(total), vramUsedMiB: Number(used) };
|
||||
}
|
||||
|
||||
function percentile(sorted, fraction) {
|
||||
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(fraction * sorted.length) - 1));
|
||||
return sorted[index];
|
||||
}
|
||||
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (!options.out) fail('--out is required so the receipt has a destination');
|
||||
if (!LOOPBACK.test(options.host)) fail(`The worker is a loopback service; refusing to benchmark ${options.host}.`);
|
||||
if (!Number.isInteger(options.warmSamples) || options.warmSamples < 1) fail('--warm-samples must be a positive integer');
|
||||
|
||||
const receipts = JSON.parse(await readFile(fileURLToPath(new URL('../deploy/worker/receipts.json', import.meta.url)), 'utf8'));
|
||||
|
||||
// The device is probed first: nothing is timed until we know the numbers have somewhere to be real.
|
||||
const gpu = await probeGpu(options.gpuProbe);
|
||||
|
||||
if (!(await modelsServed(options))) {
|
||||
reject(`${options.alias} is not loaded on ${options.host}:${options.port} yet, so there is no ready worker to time. Start the worker and wait for the post-load gate.`);
|
||||
}
|
||||
|
||||
let coldStartMs = null;
|
||||
if (options.startCommand) {
|
||||
const started = Date.now();
|
||||
const started_process = await runCommand('bash', ['-c', options.startCommand]);
|
||||
if (started_process.code !== 0) {
|
||||
reject(`The cold-start command failed (exit ${started_process.code}): ${started_process.stderr.trim()}`);
|
||||
}
|
||||
const deadline = Date.now() + options.readyTimeoutSeconds * 1000;
|
||||
let ready = false;
|
||||
while (Date.now() < deadline) {
|
||||
if (await modelsServed(options)) { ready = true; break; }
|
||||
await sleep(250);
|
||||
}
|
||||
if (!ready) reject(`The worker did not become ready within ${options.readyTimeoutSeconds}s, so the cold start could not be measured.`);
|
||||
coldStartMs = Date.now() - started;
|
||||
}
|
||||
|
||||
const samples = [];
|
||||
for (let index = 0; index < options.warmSamples; index += 1) {
|
||||
const started = process.hrtime.bigint();
|
||||
const ok = await modelsServed(options);
|
||||
const elapsedMs = Number(process.hrtime.bigint() - started) / 1e6;
|
||||
if (!ok) reject('The worker stopped answering during the warm pass; the receipt would be partial, so nothing was written.');
|
||||
samples.push(Math.round(elapsedMs * 1000) / 1000);
|
||||
}
|
||||
const sorted = [...samples].sort((left, right) => left - right);
|
||||
|
||||
const receipt = {
|
||||
schemaVersion: 1,
|
||||
status: 'measured_on_target_gpu',
|
||||
measuredAt: new Date().toISOString(),
|
||||
gpu,
|
||||
coldStartMs,
|
||||
warmLatencyMs: {
|
||||
samples: sorted.length,
|
||||
min: sorted[0],
|
||||
p50: percentile(sorted, 0.5),
|
||||
p95: percentile(sorted, 0.95),
|
||||
max: sorted[sorted.length - 1],
|
||||
},
|
||||
worker: { baseUrl: `http://${options.host}:${options.port}/v1`, alias: options.alias },
|
||||
pins: {
|
||||
llamaCppCommit: receipts.llamaCpp.commit,
|
||||
modelSha256: receipts.models.map(model => model.sha256),
|
||||
},
|
||||
};
|
||||
|
||||
await writeFile(path.resolve(options.out), `${JSON.stringify(receipt, null, 2)}\n`, 'utf8');
|
||||
process.stdout.write(`${path.resolve(options.out)}\n`);
|
||||
Executable
+60
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
# Post-load readiness gate for Timmy's self-hosted vision worker (issue #15).
|
||||
#
|
||||
# A worker that answers on its port is not necessarily a worker that can answer: llama.cpp opens
|
||||
# its HTTP listener before the model finishes loading and returns an error until the weights are
|
||||
# resident. Readiness therefore passes only when the pinned model alias is actually served.
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 ready — the pinned model is loaded and being served
|
||||
# 2 the worker is not reachable
|
||||
# 3 the worker is reachable but the pinned model is not loaded yet
|
||||
# 64 usage error (including a non-loopback probe host)
|
||||
set -uo pipefail
|
||||
|
||||
HOST="${TIMMY_MODEL_HOST:-127.0.0.1}"
|
||||
PORT="${TIMMY_MODEL_PORT:-8080}"
|
||||
ALIAS="${TIMMY_MODEL_ALIAS:-SmolVLM2-2.2B-Instruct}"
|
||||
WAIT=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--host) HOST="${2:-}"; shift 2 ;;
|
||||
--port) PORT="${2:-}"; shift 2 ;;
|
||||
--alias) ALIAS="${2:-}"; shift 2 ;;
|
||||
--wait) WAIT="${2:-}"; shift 2 ;;
|
||||
*)
|
||||
printf 'Usage: %s [--host H] [--port P] [--alias A] [--wait SECONDS]\n' "$0" >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# The worker is a private, loopback-only service. Probing anything else would be a mistake about
|
||||
# exposure, so refuse rather than answer about a host this gate is not responsible for.
|
||||
if [[ ! "$HOST" =~ ^(127\.0\.0\.1|localhost|::1)$ ]]; then
|
||||
printf 'Readiness must be probed on loopback, got %s\n' "$HOST" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
deadline=$(( $(date +%s) + WAIT ))
|
||||
outcome='unreachable'
|
||||
while :; do
|
||||
body="$(curl --silent --max-time 3 "http://$HOST:$PORT/v1/models" 2>/dev/null)"
|
||||
if [[ $? -eq 0 && -n "$body" ]]; then
|
||||
if printf '%s' "$body" | grep -q -- "$ALIAS"; then
|
||||
printf 'Worker ready: %s is loaded on http://%s:%s/v1\n' "$ALIAS" "$HOST" "$PORT"
|
||||
exit 0
|
||||
fi
|
||||
outcome='not-loaded'
|
||||
printf 'Worker reachable on http://%s:%s/v1 but %s is not loaded yet.\n' "$HOST" "$PORT" "$ALIAS" >&2
|
||||
else
|
||||
outcome='unreachable'
|
||||
printf 'Worker not reachable on http://%s:%s/v1.\n' "$HOST" "$PORT" >&2
|
||||
fi
|
||||
[[ "$(date +%s)" -ge "$deadline" ]] && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
[[ "$outcome" == 'not-loaded' ]] && exit 3
|
||||
exit 2
|
||||
@@ -31,5 +31,17 @@ export function visionReadiness(status) {
|
||||
message: 'Vision worker offline. Manual logging is still available.',
|
||||
};
|
||||
}
|
||||
// A worker that answers /v1/models has not necessarily loaded the model yet (#15), so readiness
|
||||
// passes only once the pinned model is confirmed loaded. An unconfirmed model fails closed to the
|
||||
// manual path instead of offering an AI control that fails after the upload.
|
||||
if (status.modelSeen !== true) {
|
||||
return {
|
||||
phase: 'loading',
|
||||
ready: false,
|
||||
manualOnly: true,
|
||||
manualLabel: MANUAL_LABEL,
|
||||
message: 'The vision worker is up but the model is not loaded yet. Manual logging is available.',
|
||||
};
|
||||
}
|
||||
return { phase: 'ready', ready: true, manualOnly: false, manualLabel: '', message: '' };
|
||||
}
|
||||
+1
-1
@@ -18,7 +18,7 @@ main{display:block}.eyebrow{font-size:11px;font-weight:800;letter-spacing:.1em;t
|
||||
.chat-page{display:flex;flex-direction:column;min-height:calc(100vh - 175px)}.chat-title{padding-bottom:8px}.agent-status{display:flex;align-items:center;gap:11px;padding:11px 13px;background:rgba(255,253,250,.7);border:1px solid var(--line);border-radius:17px;margin-bottom:12px}.agent-status>i{width:10px;height:10px;border-radius:50%;background:#a9a39d;box-shadow:0 0 0 5px rgba(169,163,157,.13)}.agent-status.connected>i{background:#209479;box-shadow:0 0 0 5px rgba(32,148,121,.13)}.agent-status.locked>i{background:#d19b27}.agent-status strong,.agent-status small{display:block}.agent-status strong{font-size:13px}.agent-status small{font-size:11px;color:var(--muted);margin-top:2px}.conversation{background:var(--surface);border:1px solid var(--line);border-radius:23px;padding:13px;box-shadow:0 10px 30px rgba(55,40,31,.05)}.chat{display:flex;flex-direction:column;gap:9px;min-height:195px;max-height:42vh;overflow:auto;padding:4px 1px 14px}.bubble{max-width:86%;padding:11px 13px;border-radius:17px;line-height:1.42;font-size:14px;white-space:pre-wrap}.bubble.timmy{align-self:flex-start;background:#efebe4;border-bottom-left-radius:5px}.bubble.user{align-self:flex-end;background:var(--teal);color:white;border-bottom-right-radius:5px}.thinking{display:flex;gap:4px}.thinking span{width:6px;height:6px;border-radius:50%;background:#8b847d;animation:blink 1s infinite}.thinking span:nth-child(2){animation-delay:.15s}.thinking span:nth-child(3){animation-delay:.3s}@keyframes blink{50%{opacity:.25;transform:translateY(-2px)}}.composer{display:grid;grid-template-columns:1fr 45px;gap:8px;align-items:end;background:#f0ece5;border-radius:18px;padding:6px}.composer textarea{border:0;background:transparent;resize:none;min-height:42px;max-height:110px;padding:10px 9px;outline:0;color:var(--ink)}.composer button{width:44px;height:44px;border:0;border-radius:14px;background:var(--ink);color:white;font-size:22px;cursor:pointer}.composer button:disabled{opacity:.4}.composer-note{font-size:10px;color:var(--muted);margin:7px 5px 0;line-height:1.35}.chat-error{font-size:12px;color:var(--red);margin:0 4px 8px}.safety-line{margin:13px 4px 0;color:var(--muted);font-size:11px;line-height:1.45}.safety-line strong{color:var(--ink)}.unlock-card{background:#fff8e6;border:1px solid #ead9a9;border-radius:20px;padding:14px;margin-bottom:12px}.unlock-card>label{display:block;font-size:12px;font-weight:800;margin-bottom:7px}.unlock-row{display:grid;grid-template-columns:1fr auto;gap:8px}.unlock-card .fine{margin:8px 2px 0}
|
||||
.sheet-backdrop{position:fixed;inset:0;background:rgba(28,22,18,.42);display:flex;align-items:flex-end;justify-content:center;z-index:50;padding-top:28px;backdrop-filter:blur(5px)}.sheet{width:min(100%,680px);max-height:94vh;overflow:auto;background:var(--surface);border-radius:28px 28px 0 0;padding:9px 18px calc(24px + env(safe-area-inset-bottom));box-shadow:0 -15px 50px rgba(30,22,18,.2)}.sheet-handle{width:38px;height:4px;background:#d8d1c8;border-radius:999px;margin:2px auto 15px}.sheet-header{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}.sheet-header h2{margin-top:4px}.icon-btn{width:44px;height:44px;border:0;border-radius:50%;background:#efebe5;font-size:24px;cursor:pointer}.progress{height:4px;background:#eee8df;border-radius:99px;margin:7px 0 18px;overflow:hidden}.progress i{height:100%;display:block;background:var(--teal)}.progress-step-1{width:33.34%}.progress-step-2{width:66.68%}.progress-step-3{width:100%}
|
||||
.draft-resume{display:flex;align-items:center;justify-content:space-between;gap:10px;background:#fdf3d8;border:1px solid #ecd9a3;border-radius:14px;padding:8px 11px;margin:0 0 14px;font-size:12px;line-height:1.35;color:#5d4a12}.draft-resume strong{font-weight:800}
|
||||
.scan-hero{text-align:center;padding:3px 15px 12px}.scan-hero img{width:76px}.scan-hero h3{font-size:19px;margin:4px 0 7px}.scan-hero p{font-size:13px;line-height:1.45;color:var(--muted)}.model-status{border-radius:14px;padding:9px 11px;font-size:11px;font-weight:700;margin-bottom:12px}.model-status.ready{background:var(--teal-soft);color:var(--teal)}.model-status.offline{background:var(--red-soft);color:var(--red)}.model-status.checking{background:#f0ece5;color:var(--muted)}.capture-choice-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}.capture-status{background:var(--red-soft);color:var(--red);border-radius:14px;padding:10px 12px;font-size:12px;line-height:1.4}.photo-capture{display:grid;place-items:center;text-align:center;border:1.5px dashed #b9aea1;border-radius:21px;padding:16px 10px;background:#faf7f1;cursor:pointer}.photo-capture b{font-size:30px}.photo-capture strong{margin:6px 0 2px}.photo-capture span{font-size:11px;color:var(--muted)}.photo-capture input{display:none}.photo-preview{display:block;width:100%;max-height:250px;object-fit:cover;border-radius:18px;margin:10px 0}.quality-note{font-size:12px;color:var(--muted)}.consent-card{background:#f5f1e9;border-radius:18px;padding:12px;margin:12px 0}.check{display:flex;gap:11px;align-items:flex-start;padding:9px 0}.check input{width:20px;height:20px;accent-color:var(--teal);flex:0 0 auto}.check span{font-size:13px;line-height:1.4}.analyzing{text-align:center;padding:33px 10px}.analyzing img{width:86px}.spinner{width:32px;height:32px;border:3px solid var(--soft);border-top-color:var(--teal);border-radius:50%;animation:spin .8s linear infinite;margin:10px auto}@keyframes spin{to{transform:rotate(360deg)}}.analyzing p{font-size:13px;color:var(--muted)}.scan-result{background:var(--teal-soft);border-radius:20px;padding:17px;margin-bottom:13px}.scan-result.needs-input{background:#f2eee7;text-align:center}.scan-result.needs-input>b{font-size:28px}.scan-result p{font-size:13px;line-height:1.45;margin:9px 0 0}.ai-badge{font-size:10px;font-weight:850;letter-spacing:.08em;color:var(--teal)}.suggestion-pair{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin-top:12px}.suggestion-pair>div{background:var(--surface);padding:12px;border-radius:14px}.suggestion-pair small,.suggestion-pair strong{display:block}.suggestion-pair small{font-size:9px;color:var(--muted)}.suggestion-pair strong{font-size:18px;margin-top:2px}.ai-prefill{background:var(--teal-soft);border-radius:16px;padding:11px 13px;margin-bottom:12px}.ai-prefill strong,.ai-prefill small{display:block}.ai-prefill small{color:var(--muted);margin-top:3px}.choice-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.bristol{min-height:66px;border:1px solid var(--line);background:#f7f3ed;border-radius:16px;padding:10px;text-align:left;cursor:pointer}.bristol strong,.bristol span{display:block}.bristol span{font-size:11px;color:var(--muted);margin-top:2px}.bristol.selected{background:var(--ink);color:white}.bristol.selected span{color:#ddd}.field{display:block;margin-bottom:15px}.field-label{display:block;font-size:12px;font-weight:800;margin-bottom:6px}.input{width:100%;min-height:48px;border:1px solid var(--line);border-radius:14px;background:#f8f5ef;padding:10px 12px;color:var(--ink)}textarea.input{min-height:85px;resize:vertical}.range-row{display:grid;grid-template-columns:1fr 37px;gap:10px;align-items:center}.range-row input{accent-color:var(--teal)}.range-val{width:37px;height:37px;border-radius:12px;background:var(--soft);display:grid;place-items:center;font-weight:800}.photo-drop{width:100%;background:#f3eee7}.symptoms{display:flex;flex-direction:column}.alert{background:var(--red-soft);border:1px solid #eec1ba;border-radius:16px;padding:13px;color:#75261f}.alert p{margin:5px 0 0;font-size:13px;line-height:1.4}
|
||||
.scan-hero{text-align:center;padding:3px 15px 12px}.scan-hero img{width:76px}.scan-hero h3{font-size:19px;margin:4px 0 7px}.scan-hero p{font-size:13px;line-height:1.45;color:var(--muted)}.model-status{border-radius:14px;padding:9px 11px;font-size:11px;font-weight:700;margin-bottom:12px}.model-status.ready{background:var(--teal-soft);color:var(--teal)}.model-status.offline{background:var(--red-soft);color:var(--red)}.model-status.loading{background:#fdf3d8;color:#5d4a12}.capture-choice-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}.capture-status{background:var(--red-soft);color:var(--red);border-radius:14px;padding:10px 12px;font-size:12px;line-height:1.4}.photo-capture{display:grid;place-items:center;text-align:center;border:1.5px dashed #b9aea1;border-radius:21px;padding:16px 10px;background:#faf7f1;cursor:pointer}.photo-capture b{font-size:30px}.photo-capture strong{margin:6px 0 2px}.photo-capture span{font-size:11px;color:var(--muted)}.photo-capture input{display:none}.photo-preview{display:block;width:100%;max-height:250px;object-fit:cover;border-radius:18px;margin:10px 0}.quality-note{font-size:12px;color:var(--muted)}.consent-card{background:#f5f1e9;border-radius:18px;padding:12px;margin:12px 0}.check{display:flex;gap:11px;align-items:flex-start;padding:9px 0}.check input{width:20px;height:20px;accent-color:var(--teal);flex:0 0 auto}.check span{font-size:13px;line-height:1.4}.analyzing{text-align:center;padding:33px 10px}.analyzing img{width:86px}.spinner{width:32px;height:32px;border:3px solid var(--soft);border-top-color:var(--teal);border-radius:50%;animation:spin .8s linear infinite;margin:10px auto}@keyframes spin{to{transform:rotate(360deg)}}.analyzing p{font-size:13px;color:var(--muted)}.scan-result{background:var(--teal-soft);border-radius:20px;padding:17px;margin-bottom:13px}.scan-result.needs-input{background:#f2eee7;text-align:center}.scan-result.needs-input>b{font-size:28px}.scan-result p{font-size:13px;line-height:1.45;margin:9px 0 0}.ai-badge{font-size:10px;font-weight:850;letter-spacing:.08em;color:var(--teal)}.suggestion-pair{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin-top:12px}.suggestion-pair>div{background:var(--surface);padding:12px;border-radius:14px}.suggestion-pair small,.suggestion-pair strong{display:block}.suggestion-pair small{font-size:9px;color:var(--muted)}.suggestion-pair strong{font-size:18px;margin-top:2px}.ai-prefill{background:var(--teal-soft);border-radius:16px;padding:11px 13px;margin-bottom:12px}.ai-prefill strong,.ai-prefill small{display:block}.ai-prefill small{color:var(--muted);margin-top:3px}.choice-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.bristol{min-height:66px;border:1px solid var(--line);background:#f7f3ed;border-radius:16px;padding:10px;text-align:left;cursor:pointer}.bristol strong,.bristol span{display:block}.bristol span{font-size:11px;color:var(--muted);margin-top:2px}.bristol.selected{background:var(--ink);color:white}.bristol.selected span{color:#ddd}.field{display:block;margin-bottom:15px}.field-label{display:block;font-size:12px;font-weight:800;margin-bottom:6px}.input{width:100%;min-height:48px;border:1px solid var(--line);border-radius:14px;background:#f8f5ef;padding:10px 12px;color:var(--ink)}textarea.input{min-height:85px;resize:vertical}.range-row{display:grid;grid-template-columns:1fr 37px;gap:10px;align-items:center}.range-row input{accent-color:var(--teal)}.range-val{width:37px;height:37px;border-radius:12px;background:var(--soft);display:grid;place-items:center;font-weight:800}.photo-drop{width:100%;background:#f3eee7}.symptoms{display:flex;flex-direction:column}.alert{background:var(--red-soft);border:1px solid #eec1ba;border-radius:16px;padding:13px;color:#75261f}.alert p{margin:5px 0 0;font-size:13px;line-height:1.4}
|
||||
.privacy-list{display:flex;flex-direction:column;gap:4px}.privacy-item{display:grid;grid-template-columns:40px 1fr;gap:11px;padding:11px 0;border-bottom:1px solid var(--line)}.privacy-item:last-child{border-bottom:0}.privacy-item>b{width:38px;height:38px;border-radius:13px;background:var(--teal-soft);display:grid;place-items:center;color:var(--teal)}.privacy-item h3{margin-bottom:4px}.privacy-item p,.source-list p{font-size:13px;line-height:1.45;color:var(--muted);margin-bottom:5px}.source-list a{color:var(--teal)}.danger-zone{border-color:#e9c4bf}.toast{position:fixed;left:50%;bottom:96px;transform:translateX(-50%);background:var(--ink);color:white;border-radius:999px;padding:11px 16px;font-size:13px;font-weight:700;z-index:100;box-shadow:var(--shadow)}
|
||||
.staging-label{margin:8px auto 86px;text-align:center;color:var(--muted);font-size:10px;letter-spacing:.03em;opacity:.72}
|
||||
@media(min-width:560px){.app-shell{padding-inline:24px}.sleek-hero{padding-inline:10px}.choice-grid{grid-template-columns:repeat(3,1fr)}}
|
||||
|
||||
@@ -119,3 +119,27 @@ test('the vision corpus and its refusal boundary are unit-, browser-, and releas
|
||||
assert.match(builder, /"vision_corpus_refusal": "passed"/);
|
||||
assert.match(builder, /build_vision_corpus\.py", "--verify"/);
|
||||
});
|
||||
|
||||
test('the pinned worker container, post-load gate, and GPU benchmark are gated end to end', async () => {
|
||||
const [packageJsonText, workflow, builder] = await Promise.all([
|
||||
readFile(packagePath, 'utf8'),
|
||||
readFile(workflowPath, 'utf8'),
|
||||
readFile(new URL('../scripts/build_release.py', import.meta.url), 'utf8'),
|
||||
]);
|
||||
const packageJson = JSON.parse(packageJsonText);
|
||||
|
||||
assert.match(packageJson.scripts.test, /tests\/worker-container\.test\.js/);
|
||||
assert.match(packageJson.scripts.test, /tests\/worker-ready\.test\.js/);
|
||||
assert.match(packageJson.scripts.test, /tests\/worker-benchmark\.test\.js/);
|
||||
assert.equal(packageJson.scripts['test:worker-readiness'], 'node tests/worker-readiness.acceptance.mjs');
|
||||
assert.match(packageJson.scripts['check:syntax'], /node --check scripts\/record_worker_benchmark\.mjs/);
|
||||
assert.match(packageJson.scripts['check:syntax'], /node --check tests\/worker-readiness\.acceptance\.mjs/);
|
||||
for (const script of ['scripts/worker_ready.sh', 'deploy/worker/entrypoint.sh', 'deploy/worker/build_image.sh', 'deploy/worker/run_worker_container.sh']) {
|
||||
assert.ok(packageJson.scripts['check:syntax'].includes(`bash -n ${script}`), `${script} must be syntax-gated`);
|
||||
}
|
||||
assert.match(workflow, /npm run test:worker-readiness/);
|
||||
assert.match(builder, /"test:worker-readiness"/);
|
||||
assert.match(builder, /"worker_container_receipts": "passed"/);
|
||||
assert.match(builder, /"worker_post_load_readiness": "passed"/);
|
||||
assert.match(builder, /"scripts\/worker_ready\.sh"/);
|
||||
});
|
||||
@@ -18,7 +18,9 @@ for (const viewport of viewports) {
|
||||
await page.route('**/api/vision-status', route => route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ enabled: true, profile: 'selfhost', providerReady: true, model: 'synthetic-test-model' }),
|
||||
// The fixture mirrors the real /api/vision-status contract, which reports whether the pinned
|
||||
// model was actually seen loaded (#15) alongside provider reachability.
|
||||
body: JSON.stringify({ enabled: true, profile: 'selfhost', providerReady: true, modelSeen: true, model: 'synthetic-test-model' }),
|
||||
}));
|
||||
await page.route('**/api/analyze', route => {
|
||||
analysisRequests += 1;
|
||||
|
||||
@@ -185,3 +185,20 @@ test('release demo shows a filled-in out-of-range field failing closed even besi
|
||||
// The caption is a required gate, so a later release cannot silently drop the scene.
|
||||
assert.match(builder, /A filled-in field that is out of range still fails closed/);
|
||||
});
|
||||
|
||||
test('release demo shows readiness waiting for the loaded pinned model before AI is offered', async () => {
|
||||
const demo = await readFile(demoPath, 'utf8');
|
||||
const builder = await readFile(builderPath, 'utf8');
|
||||
|
||||
// Issue #15: reachable is not loaded, and the recording must prove the app withholds AI.
|
||||
assert.match(demo, /modelLoaded = false/);
|
||||
assert.match(demo, /getByText\(\/model is not loaded yet\/i\)/);
|
||||
assert.match(demo, /model-status\.loading/);
|
||||
assert.match(demo, /Reachable is not loaded: no AI control, no consent, and no upload until the pinned model is resident/);
|
||||
assert.match(demo, /No AI control may exist before the model is loaded/);
|
||||
assert.match(demo, /Once the pinned model is loaded, the same screen offers the AI analysis again/);
|
||||
// The scene is asserted in the browser, not narrated: the demo throws if the state is wrong.
|
||||
assert.match(demo, /must never be reported ready/);
|
||||
// The caption is a required release gate, so the scene cannot be dropped silently.
|
||||
assert.match(builder, /Reachable is not loaded: no AI control, no consent, and no upload until the pinned model is resident/);
|
||||
});
|
||||
@@ -14,7 +14,7 @@ page.on('console', message => { if (message.type() === 'error') errors.push(mess
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
page.on('request', request => { if (new URL(request.url()).pathname.includes('/api/')) apiRequests.push(new URL(request.url()).pathname); });
|
||||
|
||||
await page.route('**/api/vision-status', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ enabled: true, profile: 'selfhost', providerReady: true, model: 'SmolVLM2-2.2B-Instruct' }) }));
|
||||
await page.route('**/api/vision-status', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ enabled: true, profile: 'selfhost', providerReady: true, modelSeen: true, model: 'SmolVLM2-2.2B-Instruct' }) }));
|
||||
await page.route('**/api/agent/status', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ enabled: true, configured: true, authenticated: true, mode: 'hermes-agent' }) }));
|
||||
let chatRequest;
|
||||
await page.route('**/api/agent/chat', async route => {
|
||||
|
||||
@@ -16,18 +16,48 @@ test('reports distinct phases so the UI never claims a worker is ready when it i
|
||||
assert.equal(visionReadiness(null).phase, 'checking');
|
||||
assert.equal(visionReadiness({ enabled: false }).phase, 'disabled');
|
||||
assert.equal(visionReadiness({ enabled: true, providerReady: false }).phase, 'offline');
|
||||
assert.equal(visionReadiness({ enabled: true, providerReady: true }).phase, 'ready');
|
||||
// The readiness contract widened in #15: answering the provider probe is no longer enough.
|
||||
assert.equal(visionReadiness({ enabled: true, providerReady: true }).phase, 'loading');
|
||||
assert.equal(visionReadiness({ enabled: true, providerReady: true, modelSeen: false }).phase, 'loading');
|
||||
assert.equal(visionReadiness({ enabled: true, providerReady: true, modelSeen: true }).phase, 'ready');
|
||||
});
|
||||
|
||||
test('a ready provider keeps AI analysis available and does not force the manual path', () => {
|
||||
const readiness = visionReadiness({ enabled: true, providerReady: true, model: 'SmolVLM2-2.2B-Instruct' });
|
||||
test('readiness waits for the pinned model to load before promising AI', () => {
|
||||
// Issue #15: a worker that answers /v1/models has not necessarily loaded the model yet.
|
||||
// Readiness must pass only after the pinned model is loaded, or the user is offered an
|
||||
// AI control that fails after the upload.
|
||||
const notLoaded = visionReadiness({
|
||||
enabled: true,
|
||||
profile: 'selfhost',
|
||||
providerReady: true,
|
||||
modelSeen: false,
|
||||
model: 'SmolVLM2-2.2B-Instruct',
|
||||
});
|
||||
assert.equal(notLoaded.phase, 'loading');
|
||||
assert.equal(notLoaded.ready, false);
|
||||
assert.equal(notLoaded.manualOnly, true);
|
||||
assert.match(notLoaded.message, /model/i);
|
||||
assert.match(notLoaded.manualLabel, /manual|without AI/i);
|
||||
|
||||
const unverified = visionReadiness({ enabled: true, profile: 'selfhost', providerReady: true });
|
||||
assert.equal(unverified.ready, false, 'an unconfirmed model must never be treated as loaded');
|
||||
});
|
||||
|
||||
test('a loaded pinned model keeps AI analysis available and does not force the manual path', () => {
|
||||
const readiness = visionReadiness({ enabled: true, providerReady: true, modelSeen: true, model: 'SmolVLM2-2.2B-Instruct' });
|
||||
assert.equal(readiness.ready, true);
|
||||
assert.equal(readiness.manualOnly, false);
|
||||
assert.equal(readiness.manualLabel, '');
|
||||
});
|
||||
|
||||
test('readiness never promises a symptom or diagnosis capability', () => {
|
||||
for (const status of [null, { enabled: false }, { enabled: true, providerReady: false }, { enabled: true, providerReady: true }]) {
|
||||
for (const status of [
|
||||
null,
|
||||
{ enabled: false },
|
||||
{ enabled: true, providerReady: false },
|
||||
{ enabled: true, providerReady: true, modelSeen: true },
|
||||
{ enabled: true, providerReady: true, modelSeen: false },
|
||||
]) {
|
||||
const readiness = visionReadiness(status);
|
||||
const copy = `${readiness.manualLabel} ${readiness.message}`;
|
||||
assert.doesNotMatch(copy, /diagnos|bleeding|cancer|symptom/i);
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync, spawn } from 'node:child_process';
|
||||
import { chmod, mkdtemp, readFile, writeFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
const benchmark = new URL('../scripts/record_worker_benchmark.mjs', import.meta.url).pathname;
|
||||
const receiptsPath = new URL('../deploy/worker/receipts.json', import.meta.url);
|
||||
const ALIAS = 'SmolVLM2-2.2B-Instruct';
|
||||
|
||||
const FAKE_WORKER = `#!/usr/bin/env python3
|
||||
import json, os
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
loaded = os.environ['WORKER_MODE'] == 'loaded'
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
body = json.dumps({'data': [{'id': os.environ['WORKER_ALIAS']}] if loaded else []}).encode()
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
def log_message(self, *_):
|
||||
pass
|
||||
HTTPServer(('127.0.0.1', int(os.environ['WORKER_PORT'])), Handler).serve_forever()
|
||||
`;
|
||||
|
||||
async function sandbox() {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'timmy-bench-'));
|
||||
const gpuProbe = join(dir, 'nvidia-smi-stub');
|
||||
await writeFile(gpuProbe, '#!/bin/sh\nprintf "NVIDIA A40, 46068, 1234\\n"\n');
|
||||
await chmod(gpuProbe, 0o755);
|
||||
const worker = join(dir, 'worker.py');
|
||||
await writeFile(worker, FAKE_WORKER);
|
||||
return { dir, gpuProbe, worker, out: join(dir, 'worker-benchmark.json') };
|
||||
}
|
||||
|
||||
async function startWorker(box, mode) {
|
||||
const port = String(30000 + (process.pid % 2000));
|
||||
const proc = spawn('python3', [box.worker], {
|
||||
env: { ...process.env, WORKER_MODE: mode, WORKER_PORT: port, WORKER_ALIAS: ALIAS },
|
||||
stdio: 'ignore',
|
||||
});
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
const probe = spawnSync('bash', ['-c', `curl --silent --output /dev/null --max-time 1 http://127.0.0.1:${port}/v1/models`], { encoding: 'utf8' });
|
||||
if (probe.status === 0) break;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
return { proc, port };
|
||||
}
|
||||
|
||||
function run(box, port, extra = []) {
|
||||
return spawnSync('node', [benchmark, '--out', box.out, '--port', port, '--alias', ALIAS, '--gpu-probe', box.gpuProbe, ...extra], { encoding: 'utf8' });
|
||||
}
|
||||
|
||||
test('a benchmark refuses to record numbers it did not measure on a GPU', async () => {
|
||||
const box = await sandbox();
|
||||
const { proc, port } = await startWorker(box, 'loaded');
|
||||
try {
|
||||
const result = spawnSync('node', [benchmark, '--out', box.out, '--port', port, '--alias', ALIAS, '--gpu-probe', join(box.dir, 'missing-smi')], { encoding: 'utf8' });
|
||||
assert.notEqual(result.status, 0, 'measuring without a GPU probe must fail, not record zeros');
|
||||
assert.match(result.stderr, /GPU|probe|VRAM/i);
|
||||
assert.equal(existsSync(box.out), false, 'no receipt may be written from an unmeasured run');
|
||||
} finally {
|
||||
proc.kill('SIGKILL');
|
||||
}
|
||||
});
|
||||
|
||||
test('a benchmark refuses to time a model that is not loaded', async () => {
|
||||
const box = await sandbox();
|
||||
const { proc, port } = await startWorker(box, 'unloaded');
|
||||
try {
|
||||
const result = run(box, port);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /not loaded|ready/i);
|
||||
assert.equal(existsSync(box.out), false, 'an unloaded model must not produce a latency receipt');
|
||||
} finally {
|
||||
proc.kill('SIGKILL');
|
||||
}
|
||||
});
|
||||
|
||||
test('a benchmark records cold start, warm latency, VRAM, and the pins it measured', async () => {
|
||||
const box = await sandbox();
|
||||
const { proc, port } = await startWorker(box, 'loaded');
|
||||
try {
|
||||
const result = run(box, port, ['--warm-samples', '5', '--start-command', 'bash -c "sleep 0.3"']);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const receipt = JSON.parse(await readFile(box.out, 'utf8'));
|
||||
assert.equal(receipt.status, 'measured_on_target_gpu');
|
||||
assert.equal(receipt.worker.alias, ALIAS);
|
||||
assert.match(receipt.worker.baseUrl, new RegExp(`^http://127\\.0\\.0\\.1:${port}/v1$`));
|
||||
assert.match(receipt.gpu.name, /NVIDIA A40/);
|
||||
assert.equal(receipt.gpu.vramUsedMiB, 1234);
|
||||
assert.equal(receipt.gpu.vramTotalMiB, 46068);
|
||||
assert.ok(receipt.coldStartMs >= 300, 'cold start is measured, not asserted');
|
||||
assert.equal(receipt.warmLatencyMs.samples, 5);
|
||||
assert.ok(receipt.warmLatencyMs.p50 >= 0 && receipt.warmLatencyMs.p95 >= receipt.warmLatencyMs.p50);
|
||||
|
||||
const receipts = JSON.parse(await readFile(receiptsPath, 'utf8'));
|
||||
assert.equal(receipt.pins.llamaCppCommit, receipts.llamaCpp.commit);
|
||||
assert.deepEqual(receipt.pins.modelSha256, receipts.models.map(model => model.sha256));
|
||||
} finally {
|
||||
proc.kill('SIGKILL');
|
||||
}
|
||||
});
|
||||
|
||||
test('a benchmark receipt carries no notes, image bytes, or user data', async () => {
|
||||
const box = await sandbox();
|
||||
const { proc, port } = await startWorker(box, 'loaded');
|
||||
try {
|
||||
const result = run(box, port, ['--warm-samples', '3']);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const raw = await readFile(box.out, 'utf8');
|
||||
const receipt = JSON.parse(raw);
|
||||
assert.deepEqual(Object.keys(receipt).sort(), [
|
||||
'coldStartMs',
|
||||
'gpu',
|
||||
'measuredAt',
|
||||
'pins',
|
||||
'schemaVersion',
|
||||
'status',
|
||||
'warmLatencyMs',
|
||||
'worker',
|
||||
]);
|
||||
assert.doesNotMatch(raw, /data:image|base64|note|observations|symptom/i);
|
||||
// Cold start that was not measured is recorded as unmeasured, never as zero.
|
||||
assert.equal(receipt.coldStartMs, null);
|
||||
} finally {
|
||||
proc.kill('SIGKILL');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const dockerfilePath = new URL('../deploy/worker/Dockerfile', import.meta.url);
|
||||
const receiptsPath = new URL('../deploy/worker/receipts.json', import.meta.url);
|
||||
const buildScriptPath = new URL('../deploy/worker/build_image.sh', import.meta.url);
|
||||
const runScriptPath = new URL('../deploy/worker/run_worker_container.sh', import.meta.url);
|
||||
const dockerignorePath = new URL('../.dockerignore', import.meta.url);
|
||||
const bootstrapPath = new URL('../scripts/bootstrap_selfhost_smolvlm.sh', import.meta.url);
|
||||
|
||||
async function recipe() {
|
||||
const [dockerfile, receipts, buildScript, runScript, dockerignore, bootstrap] = await Promise.all([
|
||||
readFile(dockerfilePath, 'utf8'),
|
||||
readFile(receiptsPath, 'utf8').then(JSON.parse),
|
||||
readFile(buildScriptPath, 'utf8'),
|
||||
readFile(runScriptPath, 'utf8'),
|
||||
readFile(dockerignorePath, 'utf8'),
|
||||
readFile(bootstrapPath, 'utf8'),
|
||||
]);
|
||||
return { dockerfile, receipts, buildScript, runScript, dockerignore, bootstrap };
|
||||
}
|
||||
|
||||
function bootstrapDefault(script, name) {
|
||||
const match = new RegExp(`^${name}="\\$\\{[A-Z0-9_]*:-([^}]+)\\}"$`, 'm').exec(script);
|
||||
assert.ok(match, `bootstrap script must declare a default for ${name}`);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
test('the worker image pins llama.cpp and both model hashes to the bootstrap receipts', async () => {
|
||||
const { dockerfile, receipts, bootstrap } = await recipe();
|
||||
const commit = bootstrapDefault(bootstrap, 'LLAMA_CPP_COMMIT');
|
||||
const modelSha = bootstrapDefault(bootstrap, 'MODEL_SHA256');
|
||||
const mmprojSha = bootstrapDefault(bootstrap, 'MMPROJ_SHA256');
|
||||
|
||||
// Receipts are the runtime/CI source of truth and must not drift from the host bootstrap.
|
||||
assert.equal(receipts.llamaCpp.commit, commit);
|
||||
assert.equal(receipts.llamaCpp.repository, 'https://github.com/ggml-org/llama.cpp');
|
||||
assert.deepEqual(receipts.models.map(model => model.sha256), [modelSha, mmprojSha]);
|
||||
assert.deepEqual(receipts.models.map(model => model.file), [
|
||||
'SmolVLM2-2.2B-Instruct-Q4_K_M.gguf',
|
||||
'mmproj-SmolVLM2-2.2B-Instruct-Q8_0.gguf',
|
||||
]);
|
||||
assert.match(receipts.llamaCpp.commit, /^[0-9a-f]{40}$/);
|
||||
for (const model of receipts.models) assert.match(model.sha256, /^[0-9a-f]{64}$/);
|
||||
|
||||
// The image builds the same pinned revision: the build args default to the receipts.
|
||||
assert.match(dockerfile, new RegExp(`^ARG LLAMA_CPP_COMMIT=${commit}$`, 'm'));
|
||||
assert.match(dockerfile, new RegExp(`^ARG MODEL_SHA256=${modelSha}$`, 'm'));
|
||||
assert.match(dockerfile, new RegExp(`^ARG MMPROJ_SHA256=${mmprojSha}$`, 'm'));
|
||||
assert.match(dockerfile, /git fetch --depth 1 origin "\$\{LLAMA_CPP_COMMIT\}"/);
|
||||
assert.match(dockerfile, /git checkout --detach FETCH_HEAD/);
|
||||
});
|
||||
|
||||
test('the worker image uses digest-pinned bases and refuses floating model sources', async () => {
|
||||
const { dockerfile, receipts } = await recipe();
|
||||
|
||||
const froms = dockerfile.split('\n').filter(line => line.startsWith('FROM '));
|
||||
assert.equal(froms.length, 2, 'the recipe is a build stage and a runtime stage');
|
||||
for (const line of froms) assert.match(line, /@sha256:[0-9a-f]{64}/, `base must be digest-pinned: ${line}`);
|
||||
// The recorded receipts and the recipe must name the same immutable bases.
|
||||
const digests = froms.map(line => /@(sha256:[0-9a-f]{64})/.exec(line)[1]);
|
||||
assert.deepEqual(digests, [receipts.baseImages.build.digest, receipts.baseImages.runtime.digest]);
|
||||
assert.equal(receipts.baseImages.build.reference, 'nvidia/cuda:12.4.1-devel-ubuntu22.04');
|
||||
assert.equal(receipts.baseImages.runtime.reference, 'nvidia/cuda:12.4.1-runtime-ubuntu22.04');
|
||||
assert.doesNotMatch(dockerfile, /:latest|ggml-org\/llama\.cpp@?main|refs\/heads\/(?:main|master)/);
|
||||
// Weights are never baked in; the model arrives as a read-only mount.
|
||||
assert.doesNotMatch(dockerfile, /^COPY .*\.gguf/m);
|
||||
assert.doesNotMatch(dockerfile, /^ADD /m);
|
||||
assert.match(dockerfile, /\/models/);
|
||||
});
|
||||
|
||||
test('the worker image runs unprivileged with no default credentials and a post-load healthcheck', async () => {
|
||||
const { dockerfile } = await recipe();
|
||||
// Directives may be wrapped with a line continuation; assert on the joined directive text.
|
||||
const directives = dockerfile.replace(/\\\n\s*/g, ' ');
|
||||
|
||||
const user = /^USER (?!root)(\S+)$/m.exec(dockerfile);
|
||||
assert.ok(user, 'the runtime stage must drop to an unprivileged user');
|
||||
assert.match(user[1], /^\d+:\d+$/, 'use a numeric uid:gid so the image needs no passwd entry');
|
||||
assert.doesNotMatch(dockerfile, /^ENV .*(?:API_KEY|TOKEN|PASSWORD|SECRET)[ =]/im);
|
||||
// Health must gate on the loaded model, so the container is not "healthy" while it cannot answer.
|
||||
assert.match(directives, /^HEALTHCHECK .*timmy-worker-ready/m);
|
||||
assert.match(directives, /--start-period=\d+s/);
|
||||
assert.match(directives, /--host", "127\.0\.0\.1"/, 'the health probe runs on the container loopback');
|
||||
// The container listens inside its own network namespace; the run recipe, not the image, decides
|
||||
// exposure and publishes on loopback only (asserted separately).
|
||||
assert.match(dockerfile, /--host 0\.0\.0\.0|TIMMY_MODEL_HOST=0\.0\.0\.0/);
|
||||
});
|
||||
|
||||
test('the build refuses a floating or missing pin before it reaches docker', async () => {
|
||||
const floating = spawnSync('bash', [buildScriptPath.pathname, '--print-plan'], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, TIMMY_LLAMA_CPP_COMMIT: 'latest' },
|
||||
});
|
||||
assert.notEqual(floating.status, 0, 'a floating llama.cpp ref must be rejected');
|
||||
assert.match(floating.stderr, /pin|40-hex|floating/i);
|
||||
|
||||
const missingSha = spawnSync('bash', [buildScriptPath.pathname, '--print-plan'], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, TIMMY_MODEL_SHA256: '' },
|
||||
});
|
||||
assert.notEqual(missingSha.status, 0, 'a missing model hash must be rejected');
|
||||
|
||||
const plan = spawnSync('bash', [buildScriptPath.pathname, '--print-plan'], { encoding: 'utf8' });
|
||||
assert.equal(plan.status, 0, plan.stderr);
|
||||
assert.match(plan.stdout, /llama\.cpp commit: [0-9a-f]{40}/);
|
||||
assert.match(plan.stdout, /SmolVLM2-2\.2B-Instruct-Q4_K_M\.gguf sha256: [0-9a-f]{64}/);
|
||||
assert.match(plan.stdout, /bind address: 127\.0\.0\.1:8080/);
|
||||
assert.match(plan.stdout, /publish: 127\.0\.0\.1:8080/);
|
||||
});
|
||||
|
||||
test('the run recipe publishes on loopback only and drops every privilege it can', async () => {
|
||||
const { runScript } = await recipe();
|
||||
|
||||
assert.match(runScript, /127\.0\.0\.1:\$\{?PORT/);
|
||||
assert.match(runScript, /--read-only/);
|
||||
assert.match(runScript, /--cap-drop[= ]ALL/);
|
||||
assert.match(runScript, /no-new-privileges/);
|
||||
assert.match(runScript, /--security-opt/);
|
||||
assert.doesNotMatch(runScript, /--privileged/);
|
||||
assert.doesNotMatch(runScript, /-p "\$PORT:/);
|
||||
assert.doesNotMatch(runScript, /\b0\.0\.0\.0\b/);
|
||||
// The model mount is read-only, and no credential has a default value.
|
||||
assert.match(runScript, /:ro\b/);
|
||||
assert.doesNotMatch(runScript, /(?:API_KEY|TOKEN|PASSWORD)=[^\s"']/);
|
||||
});
|
||||
|
||||
test('the run recipe refuses to publish the worker anywhere but loopback', async () => {
|
||||
const hostile = spawnSync('bash', [runScriptPath.pathname, '--print-plan'], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, TIMMY_WORKER_PUBLISH_HOST: '0.0.0.0' },
|
||||
});
|
||||
assert.notEqual(hostile.status, 0, 'a non-loopback publish must be refused');
|
||||
assert.match(hostile.stderr, /loopback|127\.0\.0\.1/i);
|
||||
|
||||
const plan = spawnSync('bash', [runScriptPath.pathname, '--print-plan'], { encoding: 'utf8' });
|
||||
assert.equal(plan.status, 0, plan.stderr);
|
||||
assert.match(plan.stdout, /publish: 127\.0\.0\.1:\d+/);
|
||||
});
|
||||
|
||||
test('the build context excludes weights, user images, releases, and secrets', async () => {
|
||||
const { dockerignore } = await recipe();
|
||||
|
||||
for (const pattern of ['*.gguf', 'artifacts/', 'video/', 'models/', '.git/', 'node_modules/', '.env', '.env.*']) {
|
||||
assert.ok(dockerignore.split('\n').includes(pattern), `dockerignore must exclude ${pattern}`);
|
||||
}
|
||||
// Committed synthetic fixtures are fine, but a real medical corpus directory is never buildable.
|
||||
assert.doesNotMatch(dockerignore, /^!.*\.gguf$/m);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { chromium } from 'playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
|
||||
// Issue #15: readiness must pass only after the pinned model is loaded. A worker that answers
|
||||
// /v1/models is reachable, but reachable is not loaded — the app must not offer an AI control
|
||||
// that fails after the upload.
|
||||
await mkdir('artifacts', { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, serviceWorkers: 'block' });
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
let modelLoaded = false;
|
||||
let analysisRequests = 0;
|
||||
await page.route('**/api/vision-status', route => route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
enabled: true,
|
||||
profile: 'selfhost',
|
||||
processor: 'self-hosted',
|
||||
model: 'SmolVLM2-2.2B-Instruct',
|
||||
providerReady: true,
|
||||
modelSeen: modelLoaded,
|
||||
}),
|
||||
}));
|
||||
await page.route('**/api/analyze', route => {
|
||||
analysisRequests += 1;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
status: 'suggestion',
|
||||
isStool: true,
|
||||
bristolType: 4,
|
||||
color: 'brown',
|
||||
confidence: 0.83,
|
||||
imageQuality: 'good',
|
||||
observations: 'Smooth, formed appearance.',
|
||||
warning: 'Visual suggestion only. Confirm it yourself; this is not a diagnosis.',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
|
||||
// 1. Worker reachable, pinned model not loaded: no AI promise, no dead control, one action to manual.
|
||||
await page.locator('[data-scan]').click();
|
||||
await page.getByText(/model is not loaded/i).waitFor();
|
||||
// Visual acceptance: the not-loaded state must be visually distinct from the ready and offline
|
||||
// states, not an unstyled div that reads like either one of them.
|
||||
const loadingStatus = page.locator('.model-status.loading');
|
||||
assert.equal(await loadingStatus.count(), 1, 'the not-loaded state needs its own status treatment');
|
||||
assert.equal(await loadingStatus.evaluate(el => getComputedStyle(el).backgroundColor), 'rgb(253, 243, 216)');
|
||||
assert.equal(await page.getByText(/Self-hosted model ready/i).count(), 0, 'an unloaded model must never be reported ready');
|
||||
assert.equal(await page.locator('#analyze-photo').count(), 0, 'no analyze control before the model is loaded');
|
||||
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
|
||||
await page.getByText(/Ready for your review/i).waitFor();
|
||||
assert.equal(await page.getByText(/model is not loaded/i).count() > 0, true, 'the ready step still states why AI is unavailable');
|
||||
assert.equal(await page.locator('.consent-card').count(), 0, 'no consent is requested while the model is not loaded');
|
||||
assert.equal(await page.locator('#analyze-photo').count(), 0, 'no dead analyze control on the ready step');
|
||||
const fallback = page.locator('#manual-from-scan');
|
||||
assert.equal(await fallback.isVisible(), true);
|
||||
assert.match(await fallback.innerText(), /without AI|manual/i);
|
||||
await fallback.click();
|
||||
assert.equal(await page.getByText('Pick the closest form').isVisible(), true, 'one action reaches manual Bristol entry');
|
||||
assert.equal(analysisRequests, 0, 'nothing is uploaded before the pinned model is loaded');
|
||||
await page.screenshot({ path: 'artifacts/worker-model-loading-mobile.png' });
|
||||
await page.locator('#close-sheet').click();
|
||||
|
||||
// 2. The pinned model loads: the same flow now offers consent and analysis, and analysis works.
|
||||
modelLoaded = true;
|
||||
await page.locator('[data-scan]').click();
|
||||
await page.getByText(/Self-hosted model ready/i).waitFor();
|
||||
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
|
||||
await page.getByText(/Ready for your review/i).waitFor();
|
||||
assert.equal(await page.locator('.consent-card').isVisible(), true, 'consent returns once the model is loaded');
|
||||
await page.locator('#ai-consent').check();
|
||||
await page.locator('#analyze-photo').click();
|
||||
await page.getByText(/83% confidence/i).waitFor();
|
||||
assert.equal(analysisRequests, 1, 'analysis is offered exactly when the model is loaded');
|
||||
await page.screenshot({ path: 'artifacts/worker-model-loaded-mobile.png' });
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
await context.close();
|
||||
await browser.close();
|
||||
console.log('PASS readiness waits for the loaded pinned model: no AI control before load, full AI path after load');
|
||||
@@ -0,0 +1,107 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
const gate = new URL('../scripts/worker_ready.sh', import.meta.url).pathname;
|
||||
const bootstrap = new URL('../scripts/bootstrap_selfhost_smolvlm.sh', import.meta.url);
|
||||
const ALIAS = 'SmolVLM2-2.2B-Instruct';
|
||||
|
||||
const FAKE_SERVER = `#!/usr/bin/env python3
|
||||
import json, os, sys
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
mode = os.environ['WORKER_MODE']
|
||||
counter_path = os.environ['WORKER_COUNTER']
|
||||
count = int(open(counter_path).read() or '0') if os.path.exists(counter_path) else 0
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
global count
|
||||
count += 1
|
||||
open(counter_path, 'w').write(str(count))
|
||||
loaded = mode == 'loaded' or (mode == 'flip' and count > 2)
|
||||
body = json.dumps({'data': [{'id': os.environ['WORKER_ALIAS']}] if loaded else []}).encode()
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
def log_message(self, *_):
|
||||
pass
|
||||
|
||||
HTTPServer(('127.0.0.1', int(os.environ['WORKER_PORT'])), Handler).serve_forever()
|
||||
`;
|
||||
|
||||
async function withWorker(mode, run) {
|
||||
const sandbox = await mkdtemp(join(tmpdir(), 'timmy-worker-ready-'));
|
||||
const counter = join(sandbox, 'count.txt');
|
||||
await writeFile(counter, '0');
|
||||
const server = join(sandbox, 'server.py');
|
||||
await writeFile(server, FAKE_SERVER);
|
||||
const port = String(20000 + (process.pid % 4000));
|
||||
const { spawn } = await import('node:child_process');
|
||||
const proc = spawn('python3', [server], {
|
||||
env: { ...process.env, WORKER_MODE: mode, WORKER_COUNTER: counter, WORKER_PORT: port, WORKER_ALIAS: ALIAS },
|
||||
stdio: 'ignore',
|
||||
});
|
||||
// Wait for the socket to accept connections before probing.
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
const probe = spawnSync('bash', ['-c', `curl --silent --output /dev/null --max-time 1 http://127.0.0.1:${port}/v1/models`], { encoding: 'utf8' });
|
||||
if (probe.status === 0) break;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
try {
|
||||
return await run(port);
|
||||
} finally {
|
||||
proc.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
|
||||
function gateRun(port, extra = []) {
|
||||
return spawnSync('bash', [gate, '--port', port, '--alias', ALIAS, ...extra], { encoding: 'utf8' });
|
||||
}
|
||||
|
||||
test('readiness passes once the pinned model is actually served', async () => {
|
||||
await withWorker('loaded', async port => {
|
||||
const result = gateRun(port);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, new RegExp(`${ALIAS} is loaded`));
|
||||
});
|
||||
});
|
||||
|
||||
test('a reachable worker with the model still loading is not ready', async () => {
|
||||
await withWorker('unloaded', async port => {
|
||||
const result = gateRun(port);
|
||||
assert.equal(result.status, 3, 'reachable-but-unloaded must be its own failure, not a network error');
|
||||
assert.match(result.stderr, /not loaded yet/i);
|
||||
});
|
||||
});
|
||||
|
||||
test('an unreachable worker is reported as unreachable', async () => {
|
||||
const result = spawnSync('bash', [gate, '--port', '9', '--alias', ALIAS], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 2);
|
||||
assert.match(result.stderr, /not reachable/i);
|
||||
});
|
||||
|
||||
test('readiness never probes anything but loopback', () => {
|
||||
const result = spawnSync('bash', [gate, '--host', '10.0.0.5', '--port', '8080'], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 64);
|
||||
assert.match(result.stderr, /loopback/i);
|
||||
});
|
||||
|
||||
test('readiness can wait for the model load it is gating', async () => {
|
||||
await withWorker('flip', async port => {
|
||||
const started = Date.now();
|
||||
const result = gateRun(port, ['--wait', '15']);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.ok(Date.now() - started >= 300, 'the gate must actually wait rather than pass on the first probe');
|
||||
});
|
||||
});
|
||||
|
||||
test('the bootstrap exposes the same post-load gate instead of duplicating it', async () => {
|
||||
const script = await readFile(bootstrap, 'utf8');
|
||||
assert.match(script, /worker_ready\.sh/);
|
||||
assert.match(script, /^ ready\)$/m);
|
||||
});
|
||||
Reference in new issue
Block a user