-
Notifications
You must be signed in to change notification settings - Fork 1
C-391: stack init — Hardware Detection, Modality Selection, and Model Recommendation #144
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -244,6 +244,45 @@ if [ "${LOCAL_STACK_LIVE:-0}" = "1" ]; then | |
| | sort -u) | ||
| fi | ||
|
|
||
| # ── C-391 `stack init` (AC-8, AC-10) ────────────────────────────────── | ||
| # AC-8: `init --yes` completes without prompting in a non-TTY invocation | ||
| # and writes a valid .env. AC-10: the generated .env renders with | ||
| # `docker compose config` (the full boot happens in CI / LOCAL_STACK_LIVE). | ||
| echo "== stack init (C-391) ==" | ||
| # Private temp dir: keeps the generated .env and the init log out of the | ||
| # predictable /tmp path, and the EXIT trap removes them on every exit path | ||
| # (success, failure, or set -e abort) without leaving a world-readable log. | ||
| INIT_TMP="$(mktemp -d)" | ||
| trap 'rm -rf "${INIT_TMP:-}"' EXIT | ||
| INIT_ENV="$INIT_TMP/.env" | ||
| INIT_LOG="$INIT_TMP/init.out" | ||
| if timeout 60 bun stack/init.ts --yes --no-color --env-path "$INIT_ENV" >"$INIT_LOG" 2>&1; then | ||
| ok "AC-8: stack init --yes runs non-interactively (exit 0)" | ||
| else | ||
| bad "AC-8: stack init --yes failed — see $INIT_LOG" | ||
| tail -30 "$INIT_LOG" >&2 | ||
| fi | ||
| if [ -f "$INIT_ENV" ] && grep -q '^COMPOSE_PROFILES=' "$INIT_ENV" \ | ||
| && grep -q '^COMPOSE_FILE=' "$INIT_ENV"; then | ||
| ok "AC-8: generated .env carries COMPOSE_PROFILES and COMPOSE_FILE" | ||
| else | ||
| bad "AC-8: generated .env missing required keys" | ||
| fi | ||
| if command -v docker >/dev/null 2>&1; then | ||
| COMPOSE_LINE="$(grep '^COMPOSE_FILE=' "$INIT_ENV" | cut -d= -f2)" | ||
| PROFILES_LINE="$(grep '^COMPOSE_PROFILES=' "$INIT_ENV" | cut -d= -f2)" | ||
| # Render the generated configuration from the local-stack project dir, | ||
| # loading the generated env file so compose interpolates the same | ||
| # variables (model paths, ports) the wizard wrote. | ||
| if COMPOSE_FILE="$COMPOSE_LINE" COMPOSE_PROFILES="$PROFILES_LINE" docker compose --env-file "$INIT_ENV" config --quiet 2>/dev/null; then | ||
| ok "AC-10: generated .env renders with docker compose config" | ||
| else | ||
| bad "AC-10: generated .env does not render (docker available)" | ||
| fi | ||
|
Comment on lines
+272
to
+281
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
docker compose --help | rg -n -- '--env-file'
# Expected: the generated .env is loaded for all interpolation values.
docker compose --env-file "$INIT_ENV" config --quietRepository: BearlySleeping/aikami Length of output: 199 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- relevant files ---'
git ls-files 'apps/backend/local-stack/scripts/check.sh' '.context/CONTEXT.md' '.context/index.md' '*aikami-conventions*'
printf '%s\n' '--- check.sh outline and target section ---'
if command -v ast-grep >/dev/null 2>&1; then
ast-grep outline apps/backend/local-stack/scripts/check.sh || true
fi
sed -n '220,290p' apps/backend/local-stack/scripts/check.sh
printf '%s\n' '--- related environment and compose references ---'
rg -n -C 3 'INIT_ENV|COMPOSE_FILE|COMPOSE_PROFILES|env-file|docker compose config|MODEL|PORT|CUDA|LICENSE|LICENCE' \
apps/backend/local-stack .context 2>/dev/null || true
printf '%s\n' '--- repository guidance ---'
for f in .context/CONTEXT.md .context/index.md; do
if [ -f "$f" ]; then
echo "### $f"
cat "$f"
fi
doneRepository: BearlySleeping/aikami Length of output: 50379 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- guidance ---'
sed -n '1,220p' .context/CONTEXT.md
sed -n '1,220p' .context/index.md
sed -n '1,220p' .pi/skills/aikami-conventions/SKILL.md
printf '%s\n' '--- check.sh beginning and init block ---'
sed -n '1,90p' apps/backend/local-stack/scripts/check.sh
sed -n '230,285p' apps/backend/local-stack/scripts/check.sh
printf '%s\n' '--- check.sh execution context and init references ---'
rg -n '(^|[[:space:]])cd |INIT_ENV|stack init|COMPOSE_FILE=|COMPOSE_PROFILES=' apps/backend/local-stack/scripts/check.sh
rg -n -C 4 'AC-10|check\.sh|scripts/check' apps/backend/local-stack/stack apps/backend/local-stack tests packages 2>/dev/null | head -240
printf '%s\n' '--- compose interpolation keys ---'
rg -n '(\$\{|\$\$\{)' apps/backend/local-stack/compose*.yaml apps/backend/local-stack/docker-compose.ymlRepository: BearlySleeping/aikami Length of output: 41298 🌐 Web query:
💡 Result: Docker Compose uses specific precedence rules for variable interpolation when resolving values in your Compose file [1][2]. When multiple sources define the same variable, the precedence order (from highest to lowest) for interpolation is [1][2]: 1. Shell environment variables: Variables already set in your current shell session take the highest precedence [1][2]. 2. Environment file via --env-file: If you use the --env-file CLI flag, variables defined in this file take precedence over default files [1][2]. 3. Default.env file: If --env-file is not specified, Compose searches for an.env file in the project directory [1][2]. The project directory is determined by the --project-directory flag (if set), the directory of the first Compose file specified, or the current working directory (PWD) [1][2]. Note that variable interpolation refers to substituting values directly into the Compose YAML file itself [1]. This is distinct from environment variables passed into the service container's environment (e.g., using the environment or env_file attributes in the YAML), which follow a different precedence hierarchy [3][4]. Regarding COMPOSE_FILE: The COMPOSE_FILE environment variable is a pre-defined variable used to specify the path(s) to your Compose file(s) [5][6]. It is equivalent to using the -f flag [7][6]. Because it is an environment variable itself, it is subject to the same shell-level availability as other variables [7]. If flags are explicitly set on the command line (like -f), they override the value of the COMPOSE_FILE environment variable [7]. Citations:
🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- generated environment writer contract ---'
sed -n '1,180p' apps/backend/local-stack/stack/env_writer.ts
sed -n '1,180p' apps/backend/local-stack/stack/init.ts
printf '%s\n' '--- all compose variable references and defaults ---'
python3 - <<'PY'
from pathlib import Path
import re
root = Path("apps/backend/local-stack")
for path in sorted(root.glob("compose*.yaml")):
text = path.read_text()
refs = sorted(set(re.findall(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}", text)))
print(f"{path}: {', '.join(refs) if refs else '(none)'}")
PY
printf '%s\n' '--- generated env keys and compose keys ---'
python3 - <<'PY'
from pathlib import Path
import re
writer = Path("apps/backend/local-stack/stack/env_writer.ts").read_text()
compose = "\n".join(p.read_text() for p in Path("apps/backend/local-stack").glob("compose*.yaml"))
writer_keys = sorted(set(re.findall(r'lines\.push\(`([A-Z][A-Z0-9_]*)=', writer)))
compose_keys = sorted(set(re.findall(r"\$\{([A-Z][A-Z0-9_]*)(?::-[^}]*)?\}", compose)))
print("writer keys:", writer_keys)
print("compose keys:", compose_keys)
print("writer keys referenced by compose:", sorted(set(writer_keys) & set(compose_keys)))
PYRepository: BearlySleeping/aikami Length of output: 15175 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- compose services and CUDA override ---'
sed -n '70,180p' apps/backend/local-stack/compose.yaml
cat apps/backend/local-stack/compose.cuda.yaml
rg -n -C 5 'cudaExtras|TEXT_SERVER_IMAGE|AIKAMI_IMAGE_PREFIX|CLIENT_CONFIG|TEXT_THREADS|MODELS_CHOWN|MODELS_PATH' \
apps/backend/local-stack/stack apps/backend/local-stack/compose*.yaml
printf '%s\n' '--- deterministic environment-selection verifier ---'
python3 - <<'PY'
from pathlib import Path
import re
root = Path("apps/backend/local-stack")
compose = "\n".join(p.read_text() for p in sorted(root.glob("compose*.yaml")))
compose_keys = sorted(set(re.findall(r"\$\{([A-Z][A-Z0-9_]*)(?::-[^}]*)?\}", compose)))
# These are the values written by env_writer.ts, represented as keys only.
generated_keys = {
"COMPOSE_PROFILES", "COMPOSE_FILE", "TEXT_MODEL", "IMAGE_MODEL",
"AIKAMI_ACCEPT_LICENSES", "ENABLE_STT", "TEXT_PORT", "IMAGE_PORT",
"TTS_PORT", "STT_PORT", "WEB_PORT",
}
command_exported_keys = {"COMPOSE_FILE", "COMPOSE_PROFILES"}
assert command_exported_keys <= generated_keys
omitted_interpolation_keys = sorted(
(generated_keys - command_exported_keys) & set(compose_keys)
)
assert omitted_interpolation_keys == [
"AIKAMI_ACCEPT_LICENSES", "ENABLE_STT", "IMAGE_MODEL", "IMAGE_PORT",
"STT_PORT", "TEXT_MODEL", "TEXT_PORT", "TTS_PORT", "WEB_PORT",
]
print("Compose interpolation keys written by init but not supplied to the check:")
print(", ".join(omitted_interpolation_keys))
print("Result: the check cannot exercise those generated interpolation values.")
PYRepository: BearlySleeping/aikami Length of output: 23132 Load the generated environment file when rendering Compose. Only Use 🤖 Prompt for AI Agents |
||
| else | ||
| ok "AC-10: docker unavailable — boot render deferred to CI" | ||
| fi | ||
|
|
||
| # ── AC-9 contract support: the published client image must serve runtime | ||
| # config mounts (the two-mount container test lives in the publish | ||
| # workflow — publish-local-stack.yml — which actually boots the image; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| /** | ||
| * apps/backend/local-stack/stack/detect.test.ts | ||
| * | ||
| * C-391 detection ACs exercised at the local-stack level (evidence files | ||
| * named in the contract matrix): AC-1 empty PATH, AC-2 stubbed nvidia-smi, | ||
| * AC-12 stubbed docker info. Detection itself lives in @aikami/local-ai; | ||
| * these tests drive it with fixture-replay executors through the CLI | ||
| * adapter path. | ||
| */ | ||
|
|
||
| import { describe, expect, test } from 'bun:test'; | ||
| import type { ProbeResult } from '@aikami/local-ai'; | ||
| import { | ||
| createFixtureExecutor, | ||
| detectHardware, | ||
| runProbeExecutorContractSuite, | ||
| } from '@aikami/local-ai'; | ||
| import { probeExecutor } from './probe_executor.ts'; | ||
|
|
||
| const ok = (stdout: string): ProbeResult => ({ ok: true, stdout, stderr: '', exitCode: 0 }); | ||
|
|
||
| describe('AC-1 — detection degrades to CPU without error (empty PATH)', () => { | ||
| test('no GPU tooling → gpu.vendor none, containerRuntime none', async () => { | ||
| const executor = createFixtureExecutor({ | ||
| table: { | ||
| commands: [], | ||
| files: [{ path: '/proc/meminfo', result: ok('MemTotal: 33554432 kB\n') }], | ||
| statfs: [{ path: '.', result: { freeBytes: 1 << 40 } }], | ||
| }, | ||
| unmatched: { ok: false, reason: 'not-found' }, | ||
| }); | ||
| const profile = await detectHardware({ executor, platform: 'linux', arch: 'x64' }); | ||
| expect(profile.gpu.vendor).toBe('none'); | ||
| expect(profile.gpuPassthroughReady).toBe(false); | ||
| expect(profile.containerRuntime).toBe('none'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('AC-2 — stubbed nvidia-smi', () => { | ||
| test('CUDA 12 driver → nvidia, cudaMajor 12', async () => { | ||
| const executor = createFixtureExecutor({ | ||
| table: { | ||
| commands: [ | ||
| { | ||
| command: 'nvidia-smi', | ||
| args: ['--query-gpu=name,memory.total,driver_version', '--format=csv,noheader'], | ||
| result: ok('NVIDIA GeForce RTX 4070, 12282 MiB, 535.104.05\n'), | ||
| }, | ||
| ], | ||
| files: [{ path: '/proc/meminfo', result: ok('MemTotal: 67108864 kB\n') }], | ||
| statfs: [{ path: '.', result: { freeBytes: 1 << 40 } }], | ||
| }, | ||
| unmatched: { ok: false, reason: 'not-found' }, | ||
| }); | ||
| const profile = await detectHardware({ executor, platform: 'linux', arch: 'x64' }); | ||
| expect(profile.gpu.vendor).toBe('nvidia'); | ||
| expect(profile.gpu.vramMb).toBe(12282); | ||
| expect(profile.gpu.cudaMajor).toBe(12); | ||
| }); | ||
|
|
||
| test('CUDA 13 driver → cudaMajor 13', async () => { | ||
| const executor = createFixtureExecutor({ | ||
| table: { | ||
| commands: [ | ||
| { | ||
| command: 'nvidia-smi', | ||
| args: ['--query-gpu=name,memory.total,driver_version', '--format=csv,noheader'], | ||
| result: ok('NVIDIA GeForce RTX 5070, 12282 MiB, 580.00\n'), | ||
| }, | ||
| ], | ||
| files: [{ path: '/proc/meminfo', result: ok('MemTotal: 67108864 kB\n') }], | ||
| statfs: [{ path: '.', result: { freeBytes: 1 << 40 } }], | ||
| }, | ||
| unmatched: { ok: false, reason: 'not-found' }, | ||
| }); | ||
| const profile = await detectHardware({ executor, platform: 'linux', arch: 'x64' }); | ||
| expect(profile.gpu.cudaMajor).toBe(13); | ||
| }); | ||
| }); | ||
|
|
||
| describe('AC-12 — stubbed docker info (toolkit absent)', () => { | ||
| test('docker info without nvidia runtime → gpuPassthroughReady false', async () => { | ||
| const executor = createFixtureExecutor({ | ||
| table: { | ||
| commands: [ | ||
| { | ||
| command: 'nvidia-smi', | ||
| args: ['--query-gpu=name,memory.total,driver_version', '--format=csv,noheader'], | ||
| result: ok('NVIDIA GeForce RTX 4070, 12282 MiB, 535.104.05\n'), | ||
| }, | ||
| { command: 'docker', args: ['info'], result: ok('Runtimes: runc\n') }, | ||
| ], | ||
| files: [{ path: '/proc/meminfo', result: ok('MemTotal: 67108864 kB\n') }], | ||
| statfs: [{ path: '.', result: { freeBytes: 1 << 40 } }], | ||
| }, | ||
| unmatched: { ok: false, reason: 'not-found' }, | ||
| }); | ||
| const profile = await detectHardware({ executor, platform: 'linux', arch: 'x64' }); | ||
| expect(profile.gpu.vendor).toBe('nvidia'); | ||
| expect(profile.gpuPassthroughReady).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe('AC-0c — shared contract suite against the Bun/CLI adapter', () => { | ||
| runProbeExecutorContractSuite({ | ||
| label: 'bun/cli', | ||
| factory: () => probeExecutor, | ||
| // /proc/1/mem is the only universally-denied read on Linux; on other | ||
| // platforms the adapter has no deterministic denial and the test is | ||
| // skipped (capability-gated in the suite). | ||
| permissionDeniedPath: process.platform === 'linux' ? '/proc/1/mem' : undefined, | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Remove the obsolete
.env.examplesetup step.The preamble still tells users to run
cp .env.example .env. This creates.envbeforestack initruns. The wizard then shows an overwrite diff and requires a second confirmation.Replace the preamble command block with the new wizard flow. This keeps the quick start consistent with the claim that users do not need manual environment setup.
🤖 Prompt for AI Agents