Skip to content

Commit dcd5733

Browse files
feat(compose,setup): bundle Redis, always configure it, fix lifecycle detection
Compose shipped no redis service at all — REDIS_URL was ${REDIS_URL:-} in both app and realtime, so every self-hosted stack ran without it. Storage silently falls back to PostgreSQL, but the pub/sub channels (live Chat task-status, table events) have no fallback, so live updates never arrived. - compose (prod + local): add a redis:7-alpine service with a healthcheck, default REDIS_URL to redis://redis:6379, and make app/realtime depend on it being healthy. Not published to the host — only the containers need it, and binding 6379 would collide with a local Redis. An external REDIS_URL in root .env still overrides. Deliberately not written into root .env: doctor pings REDIS_URL from the host, and a compose-internal hostname would fail that probe the same way DATABASE_URL would. - dev mode: configure Redis in quick too. Quick uses a new non-interactive ensureRedis (adopt whatever answers, else start the managed container, warn only if Docker is unavailable); custom keeps the ladder, with corrected copy — the old prompt claimed Redis was only for multi-replica. - lifecycle: detect compose stacks via 'docker compose ls' instead of probing '-f <file> ps' in the working directory. Compose derives the project name from the directory it was started in, so the old probe found a stack only when run from the checkout that launched it (a globally linked sim never could) and listed the same stack once per candidate file. compose ls reports the real project and its config file, so one stack yields one install from anywhere; non-Sim projects are filtered by compose filename. Every compose op now runs in that stack's directory. - lifecycle: distinguish 'Docker unreachable' from 'nothing installed'. With the daemon down, status reported containers as 'absent' and suggested re-running setup; it now says Docker is down and marks state unknown.
1 parent 0dcbc56 commit dcd5733

5 files changed

Lines changed: 175 additions & 33 deletions

File tree

docker-compose.local.yml

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ services:
2121
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-dev-encryption-key-at-least-32-chars}
2222
- API_ENCRYPTION_KEY=${API_ENCRYPTION_KEY:-}
2323
- INTERNAL_API_SECRET=${INTERNAL_API_SECRET:-dev-internal-api-secret-min-32-chars}
24-
- REDIS_URL=${REDIS_URL:-}
24+
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
2525
- COPILOT_API_KEY=${COPILOT_API_KEY}
2626
- SIM_AGENT_API_URL=${SIM_AGENT_API_URL}
2727
- OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434}
@@ -30,6 +30,8 @@ services:
3030
depends_on:
3131
db:
3232
condition: service_healthy
33+
redis:
34+
condition: service_healthy
3335
migrations:
3436
condition: service_completed_successfully
3537
realtime:
@@ -55,10 +57,12 @@ services:
5557
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:3000}
5658
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-dev-secret-at-least-32-characters-long}
5759
- INTERNAL_API_SECRET=${INTERNAL_API_SECRET:-dev-internal-api-secret-min-32-chars}
58-
- REDIS_URL=${REDIS_URL:-}
60+
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
5961
depends_on:
6062
db:
6163
condition: service_healthy
64+
redis:
65+
condition: service_healthy
6266
restart: unless-stopped
6367
ports:
6468
- '3002:3002'
@@ -86,6 +90,20 @@ services:
8690
command: ['bun', 'run', 'db:migrate']
8791
restart: 'no'
8892

93+
# Backs pub/sub (live Chat task-status and table events) and the shared caches.
94+
# The app falls back to PostgreSQL for storage when REDIS_URL is unset, but the
95+
# pub/sub channels have no fallback — without Redis, live status never streams.
96+
# Not published to the host: only the app and realtime containers need it, and
97+
# binding 6379 would collide with a Redis already running locally.
98+
redis:
99+
image: redis:7-alpine
100+
restart: always
101+
healthcheck:
102+
test: ['CMD', 'redis-cli', 'ping']
103+
interval: 5s
104+
timeout: 5s
105+
retries: 5
106+
89107
db:
90108
image: pgvector/pgvector:pg17
91109
restart: always

docker-compose.prod.yml

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ services:
3030
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
3131
- API_ENCRYPTION_KEY=${API_ENCRYPTION_KEY:-}
3232
- INTERNAL_API_SECRET=${INTERNAL_API_SECRET}
33-
- REDIS_URL=${REDIS_URL:-}
33+
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
3434
- COPILOT_API_KEY=${COPILOT_API_KEY}
3535
- SIM_AGENT_API_URL=${SIM_AGENT_API_URL}
3636
- OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434}
@@ -44,6 +44,8 @@ services:
4444
depends_on:
4545
db:
4646
condition: service_healthy
47+
redis:
48+
condition: service_healthy
4749
migrations:
4850
condition: service_completed_successfully
4951
realtime:
@@ -74,10 +76,12 @@ services:
7476
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:3000}
7577
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
7678
- INTERNAL_API_SECRET=${INTERNAL_API_SECRET}
77-
- REDIS_URL=${REDIS_URL:-}
79+
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
7880
depends_on:
7981
db:
8082
condition: service_healthy
83+
redis:
84+
condition: service_healthy
8185
healthcheck:
8286
test: ['CMD', 'curl', '-fsS', 'http://127.0.0.1:3002/health']
8387
interval: 90s
@@ -96,6 +100,20 @@ services:
96100
command: ['bun', 'run', 'db:migrate']
97101
restart: 'no'
98102

103+
# Backs pub/sub (live Chat task-status and table events) and the shared caches.
104+
# The app falls back to PostgreSQL for storage when REDIS_URL is unset, but the
105+
# pub/sub channels have no fallback — without Redis, live status never streams.
106+
# Not published to the host: only the app and realtime containers need it, and
107+
# binding 6379 would collide with a Redis already running locally.
108+
redis:
109+
image: redis:7-alpine
110+
restart: unless-stopped
111+
healthcheck:
112+
test: ['CMD', 'redis-cli', 'ping']
113+
interval: 5s
114+
timeout: 5s
115+
retries: 5
116+
99117
db:
100118
image: pgvector/pgvector:pg17
101119
restart: unless-stopped

scripts/setup/lifecycle.ts

Lines changed: 88 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { spawnSync } from 'node:child_process'
2+
import path from 'node:path'
23
import { DB_CONTAINER, type Detection, REDIS_CONTAINER, runDetection } from './detect.ts'
34
import { archiveEnvFile, ROOT } from './env-files.ts'
45
import { SetupError } from './errors.ts'
@@ -38,28 +39,37 @@ function shq(value: string): string {
3839
return `'${value.replace(/'/g, `'\\''`)}'`
3940
}
4041

42+
/** Is the Docker daemon reachable? Distinguishes "nothing installed" from "can't see". */
43+
function dockerReachable(): boolean {
44+
return spawnSync('docker', ['info'], { stdio: 'ignore' }).status === 0
45+
}
46+
4147
/** Non-throwing docker probe; returns trimmed stdout or null on any failure. */
42-
function dockerText(args: string[]): string | null {
43-
const result = spawnSync('docker', args, { cwd: ROOT, encoding: 'utf8' })
48+
function dockerText(args: string[], cwd: string = ROOT): string | null {
49+
const result = spawnSync('docker', args, { cwd, encoding: 'utf8' })
4450
return result.status === 0 ? result.stdout.trim() : null
4551
}
4652

4753
/** Docker command whose output the user should see (up, logs); returns exit code. */
48-
function dockerInherit(args: string[]): number {
49-
return spawnSync('docker', args, { cwd: ROOT, stdio: 'inherit' }).status ?? 1
54+
function dockerInherit(args: string[], cwd: string = ROOT): number {
55+
return spawnSync('docker', args, { cwd, stdio: 'inherit' }).status ?? 1
5056
}
5157

5258
/** Docker command that must succeed; throws a SetupError with stderr on failure. */
53-
function dockerRun(args: string[], failMessage: string): void {
54-
const result = spawnSync('docker', args, { cwd: ROOT, encoding: 'utf8' })
59+
function dockerRun(args: string[], failMessage: string, cwd: string = ROOT): void {
60+
const result = spawnSync('docker', args, { cwd, encoding: 'utf8' })
5561
if (result.status !== 0) {
5662
throw new SetupError(`${failMessage}: ${result.stderr.trim() || result.stdout.trim()}`)
5763
}
5864
}
5965

6066
interface ComposeInstall {
6167
kind: 'compose'
68+
/** Absolute path to the compose file Docker recorded for the project. */
6269
file: string
70+
/** Directory the stack was brought up from — every compose op runs here. */
71+
dir: string
72+
project: string
6373
}
6474
interface DevInstall {
6575
kind: 'dev'
@@ -74,15 +84,48 @@ interface K8sInstall {
7484
}
7585
type Install = ComposeInstall | DevInstall | K8sInstall
7686

87+
interface ComposeProject {
88+
Name: string
89+
Status: string
90+
ConfigFiles: string
91+
}
92+
7793
/**
78-
* A compose project brought up from ROOT reuses the same project name at `ps`,
79-
* so probing each candidate compose file recovers exactly which one owns
80-
* containers — no need to guess the project name or persist the choice.
94+
* Ask Docker which compose projects exist rather than guessing from the working
95+
* directory. Compose derives a project name from the directory it was started
96+
* in, so probing `-f <file> ps` only ever finds a stack when you happen to stand
97+
* in the checkout that launched it — a globally linked `sim` would never see one
98+
* — and it reports the same stack once per candidate file, since both files map
99+
* to the same directory-derived project. `compose ls` records the real project
100+
* and the exact config file, so one running stack yields exactly one install
101+
* wherever it was started from. Projects whose compose file isn't one of ours
102+
* (a devcontainer, an unrelated app) are filtered out by filename.
81103
*/
82104
function composeInstalls(): ComposeInstall[] {
83-
return COMPOSE_FILES.filter((file) => dockerText(['compose', '-f', file, 'ps', '-aq'])).map(
84-
(file) => ({ kind: 'compose', file })
85-
)
105+
const raw = dockerText(['compose', 'ls', '-a', '--format', 'json'])
106+
if (!raw) return []
107+
let projects: ComposeProject[]
108+
try {
109+
projects = JSON.parse(raw)
110+
} catch {
111+
return []
112+
}
113+
const installs: ComposeInstall[] = []
114+
for (const project of projects) {
115+
// ConfigFiles is a comma-separated list when a stack was started with -f more than once.
116+
const file = (project.ConfigFiles ?? '')
117+
.split(',')
118+
.map((entry) => entry.trim())
119+
.find((entry) => (COMPOSE_FILES as readonly string[]).includes(path.basename(entry)))
120+
if (!file) continue
121+
installs.push({
122+
kind: 'compose',
123+
file,
124+
dir: path.dirname(file),
125+
project: project.Name,
126+
})
127+
}
128+
return installs
86129
}
87130

88131
/** Dev mode owns the split env files and, usually, the managed Postgres/Redis. */
@@ -124,7 +167,8 @@ function detectInstalls(detection: Detection): Install[] {
124167
}
125168

126169
function describeInstall(install: Install): string {
127-
if (install.kind === 'compose') return `Docker Compose (${install.file})`
170+
if (install.kind === 'compose')
171+
return `Docker Compose (project ${install.project} in ${install.dir})`
128172
if (install.kind === 'dev') return 'Local dev (managed Postgres/Redis)'
129173
// Naming a non-local cluster is the guard against acting on the wrong one after
130174
// an ambient context switch — every destructive confirm renders this string.
@@ -165,7 +209,7 @@ function start(install: Install): void {
165209
if (install.kind === 'compose') {
166210
const spin = p.spinner()
167211
spin.start('Starting containers…')
168-
dockerRun(['compose', '-f', install.file, 'up', '-d'], 'docker compose up failed')
212+
dockerRun(['compose', '-f', install.file, 'up', '-d'], 'docker compose up failed', install.dir)
169213
spin.stop('Containers up')
170214
p.note(
171215
[`open ${APP_URL}`, 'follow logs: sim logs', 'stop: sim stop'].join('\n'),
@@ -190,7 +234,7 @@ function stop(install: Install): void {
190234
if (install.kind === 'compose') {
191235
const spin = p.spinner()
192236
spin.start('Stopping containers…')
193-
dockerRun(['compose', '-f', install.file, 'stop'], 'docker compose stop failed')
237+
dockerRun(['compose', '-f', install.file, 'stop'], 'docker compose stop failed', install.dir)
194238
spin.stop('Containers stopped (data kept)')
195239
p.note(['start again: sim start', 'remove: sim down'].join('\n'), 'Stopped')
196240
return
@@ -220,7 +264,11 @@ function restart(install: Install): void {
220264
if (install.kind === 'compose') {
221265
const spin = p.spinner()
222266
spin.start('Restarting containers…')
223-
dockerRun(['compose', '-f', install.file, 'restart'], 'docker compose restart failed')
267+
dockerRun(
268+
['compose', '-f', install.file, 'restart'],
269+
'docker compose restart failed',
270+
install.dir
271+
)
224272
spin.stop('Containers restarted')
225273
p.note(`open ${APP_URL}`, 'Running')
226274
return
@@ -237,7 +285,7 @@ function restart(install: Install): void {
237285

238286
function showLogs(install: Install): void {
239287
if (install.kind === 'compose') {
240-
dockerInherit(['compose', '-f', install.file, 'logs', '-f', '--tail', '100'])
288+
dockerInherit(['compose', '-f', install.file, 'logs', '-f', '--tail', '100'], install.dir)
241289
return
242290
}
243291
if (install.kind === 'dev') {
@@ -268,7 +316,7 @@ async function down(install: Install): Promise<void> {
268316
return
269317
}
270318
if (install.kind === 'compose') {
271-
dockerRun(['compose', '-f', install.file, 'down'], 'docker compose down failed')
319+
dockerRun(['compose', '-f', install.file, 'down'], 'docker compose down failed', install.dir)
272320
p.log.step('Containers removed (volumes kept)')
273321
return
274322
}
@@ -311,7 +359,11 @@ async function reset(install: Install | null): Promise<void> {
311359
if (backup) p.log.step(`Archived ${backup}`)
312360
}
313361
if (install?.kind === 'compose') {
314-
dockerRun(['compose', '-f', install.file, 'down', '-v'], 'docker compose down -v failed')
362+
dockerRun(
363+
['compose', '-f', install.file, 'down', '-v'],
364+
'docker compose down -v failed',
365+
install.dir
366+
)
315367
p.log.step('Containers and volumes removed')
316368
} else if (install?.kind === 'dev') {
317369
const names = managedNames(install)
@@ -343,14 +395,29 @@ async function reset(install: Install | null): Promise<void> {
343395
async function status(): Promise<void> {
344396
const detection = await runDetection()
345397
const installs = detectInstalls(detection)
398+
const docker = dockerReachable()
346399
console.log(`\n${theme.heading('◆ Sim status')}\n`)
400+
// Every container probe goes through Docker, so when the daemon is down the
401+
// honest answer is "unknown", not "absent" — and a compose stack is invisible
402+
// entirely. Saying "no install detected" there sends the user to re-run setup
403+
// for what is really a stopped Docker Desktop.
404+
if (!docker) {
405+
console.log(
406+
` ${glyph.warn} Docker is not reachable — container and Compose state below is unknown.`
407+
)
408+
console.log(` ${theme.muted('start Docker Desktop (or OrbStack), then re-run this.')}\n`)
409+
}
347410
if (installs.length === 0) {
348-
console.log(` ${glyph.warn} No Sim install detected — run ${theme.command('sim setup')}.`)
411+
console.log(
412+
docker
413+
? ` ${glyph.warn} No Sim install detected — run ${theme.command('sim setup')}.`
414+
: ` ${glyph.warn} No install detected, but that may just be Docker being down.`
415+
)
349416
return
350417
}
351418
for (const install of installs) console.log(` ${glyph.pass} ${describeInstall(install)}`)
352419
const containerState = (state: { state: 'running' | 'stopped' } | null) =>
353-
state ? state.state : 'absent'
420+
docker ? (state ? state.state : 'absent') : 'unknown (docker down)'
354421
console.log()
355422
console.log(` postgres (${DB_CONTAINER}): ${containerState(detection.dbContainer)}`)
356423
console.log(` redis (${REDIS_CONTAINER}): ${containerState(detection.redisContainer)}`)

scripts/setup/modes/dev.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { ROOT, readEnvFile, writeEnvValues } from '../env-files.ts'
77
import { SetupError } from '../errors.ts'
88
import { pgProbe } from '../probes.ts'
99
import * as p from '../prompter.ts'
10-
import { resolveRedis } from '../redis.ts'
10+
import { ensureRedis, resolveRedis } from '../redis.ts'
1111
import {
1212
collectSecrets,
1313
promptCopilotKey,
@@ -62,8 +62,8 @@ async function runMigrations(dsn: string): Promise<void> {
6262
async function promptRedis(detection: Detection, existing?: string): Promise<string | null> {
6363
const wants = await p.confirm({
6464
message:
65-
'Configure Redis? (only needed for multi-replica — single instance runs fine without it)',
66-
initialValue: Boolean(existing),
65+
'Configure Redis? (powers live Chat status and table events; storage falls back to Postgres)',
66+
initialValue: true,
6767
})
6868
if (!wants) return null
6969
return resolveRedis(detection, existing)
@@ -121,12 +121,20 @@ export async function runDevMode(
121121
if (copilotKey) values.COPILOT_API_KEY = copilotKey
122122
Object.assign(values, await promptLlmKeys(detection, !quick))
123123

124+
// Redis is set up in every mode, quick included. Storage falls back to
125+
// PostgreSQL without it, but the pub/sub channels (live Chat task-status,
126+
// table events) have no fallback — skipping it silently produces an install
127+
// where live updates never arrive. Quick configures it with no questions at
128+
// all; custom keeps the opt-out and the where-should-it-live ladder.
129+
const redisUrl = quick
130+
? await ensureRedis(detection, simAfter.vars.get('REDIS_URL'))
131+
: await promptRedis(detection, simAfter.vars.get('REDIS_URL'))
132+
if (redisUrl) {
133+
values.REDIS_URL = redisUrl
134+
writeEnvValues('realtime', { REDIS_URL: redisUrl })
135+
}
136+
124137
if (!quick) {
125-
const redisUrl = await promptRedis(detection, simAfter.vars.get('REDIS_URL'))
126-
if (redisUrl) {
127-
values.REDIS_URL = redisUrl
128-
writeEnvValues('realtime', { REDIS_URL: redisUrl })
129-
}
130138
const trigger = await promptTrigger()
131139
if (trigger) Object.assign(values, trigger)
132140
const storage = await promptStorage(simAfter.vars, false)

scripts/setup/redis.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,37 @@ async function promptRedisUrl(existing?: string): Promise<string> {
7979
return url
8080
}
8181

82+
/**
83+
* Non-interactive Redis for quick mode: adopt whatever already answers, else
84+
* start the managed container. Quick's contract is sensible defaults with no
85+
* questions, and Redis is not optional enough to skip — pub/sub (live Chat
86+
* status, table events) has no PostgreSQL fallback. Returns null only when
87+
* nothing answers and Docker is unavailable, so the caller can warn instead of
88+
* failing the whole setup.
89+
*/
90+
export async function ensureRedis(detection: Detection, existing?: string): Promise<string | null> {
91+
if (existing && (await redisPing(existing)).ok) return existing
92+
if (detection.redisPortOpen && (await redisPing(LOCAL_URL)).ok) {
93+
p.log.step(`Using the Redis already on :6379`)
94+
return LOCAL_URL
95+
}
96+
if (detection.redisContainer?.managed) {
97+
if (detection.redisContainer.state === 'stopped') docker(['start', REDIS_CONTAINER])
98+
const managedUrl = managedRedisUrl()
99+
if (managedUrl && (await waitFor(async () => (await redisPing(managedUrl)).ok, 15_000, 500))) {
100+
p.log.step(`Reusing ${REDIS_CONTAINER}`)
101+
return managedUrl
102+
}
103+
}
104+
if (!(await ensureDocker(false))) {
105+
p.log.warn(
106+
'Docker is unavailable, so Redis was not configured — live Chat status and table events will not stream.'
107+
)
108+
return null
109+
}
110+
return startManagedRedis(detection)
111+
}
112+
82113
/**
83114
* Redis ladder, mirroring the Postgres one: reuse what's running (with
84115
* consent), restart/start a wizard-managed container, or take a URL.

0 commit comments

Comments
 (0)