feat(showcase/harness): support concurrent --isolate runs

- scripts/cli/_common.sh apply_isolation rewrites compose-file relative
  paths to absolute (build/context/dockerfile/volumes/env_file), enforces
  the docker compose [a-z0-9_-] project-name rule (normalize-with-warn or
  hard fail), and exports SHOWCASE_COMPOSE_FILE / SHOWCASE_INFRA_PORT_OFFSET
  plus offset host URLs (AIMOCK_URL_LOCAL / DASHBOARD_URL_LOCAL /
  POCKETBASE_URL_LOCAL) so the TS harness CLI talks to THIS project's
  aimock instead of the default :4010
- harness/src/cli/{aimock-rebuild,config,doctor,lifecycle}.ts honor the
  new env vars; lifecycle picks up the offset infra port for health
  probes so concurrent stacks no longer report each other's services as
  healthy
- Commit a generated harness/package-lock.json (new file) so npm ci
  resolves deterministically
This commit is contained in:
Jordan Ritter
2026-05-29 16:15:47 -07:00
parent af2036996a
commit 671cc6ae1d
6 changed files with 4057 additions and 20 deletions
+3948
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -9,7 +9,9 @@ const log = createLogger({ component: "aimock-rebuild" });
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SHOWCASE_DIR = path.resolve(__dirname, "../../..");
const COMPOSE_FILE = path.join(SHOWCASE_DIR, "docker-compose.local.yml");
const COMPOSE_FILE =
process.env.SHOWCASE_COMPOSE_FILE ||
path.join(SHOWCASE_DIR, "docker-compose.local.yml");
// ---------------------------------------------------------------------------
// Default aimock source locations (relative to showcase dir)
+9 -5
View File
@@ -32,10 +32,12 @@ export function loadConfig(): LocalConfig {
return {
showcaseDir: SHOWCASE_DIR,
composeFile: path.join(SHOWCASE_DIR, "docker-compose.local.yml"),
composeFile:
process.env.SHOWCASE_COMPOSE_FILE ||
path.join(SHOWCASE_DIR, "docker-compose.local.yml"),
localPorts,
pocketbase: {
url: "http://localhost:8090",
url: process.env.POCKETBASE_URL_LOCAL || "http://localhost:8090",
// PB 0.22+ rejects `admin@localhost` (single-label TLD) as an invalid
// email. Use a valid format that the PB validator accepts. The
// matching admin account is created in the entrypoint / migrations
@@ -44,9 +46,11 @@ export function loadConfig(): LocalConfig {
email: "admin@localhost.dev",
password: "showcase-local-dev",
},
aimockUrl: "http://localhost:4010",
dashboardUrl: "http://localhost:3200",
dashboardPort: 3200,
// When --isolate offsets the aimock host port, honor env overrides so the
// harness's host-side references point at the per-project aimock.
aimockUrl: process.env.AIMOCK_URL_LOCAL || "http://localhost:4010",
dashboardUrl: process.env.DASHBOARD_URL_LOCAL || "http://localhost:3200",
dashboardPort: Number(process.env.DASHBOARD_PORT_LOCAL) || 3200,
};
}
+9 -5
View File
@@ -9,16 +9,20 @@ const log = createLogger({ component: "doctor" });
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SHOWCASE_DIR = path.resolve(__dirname, "../../..");
const COMPOSE_FILE = path.join(SHOWCASE_DIR, "docker-compose.local.yml");
const COMPOSE_FILE =
process.env.SHOWCASE_COMPOSE_FILE ||
path.join(SHOWCASE_DIR, "docker-compose.local.yml");
const PORTS_FILE =
process.env.LOCAL_PORTS_FILE ||
path.join(SHOWCASE_DIR, "shared/local-ports.json");
/** Well-known infra service ports. */
/** Well-known infra service ports. Honor SHOWCASE_INFRA_PORT_OFFSET so
* doctor reports against the isolated stack when --isolate is active. */
const _INFRA_OFFSET = Number(process.env.SHOWCASE_INFRA_PORT_OFFSET) || 0;
const INFRA_PORTS: Record<string, number> = {
aimock: 4010,
pocketbase: 8090,
dashboard: 3200,
aimock: 4010 + _INFRA_OFFSET,
pocketbase: 8090 + _INFRA_OFFSET,
dashboard: 3200 + _INFRA_OFFSET,
};
// ---------------------------------------------------------------------------
+27 -8
View File
@@ -16,7 +16,13 @@ const log = createLogger({ component: "lifecycle" });
// cli/ -> src/ -> ops/ -> showcase/
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SHOWCASE_DIR = path.resolve(__dirname, "../../..");
const COMPOSE_FILE = path.join(SHOWCASE_DIR, "docker-compose.local.yml");
// Honor SHOWCASE_COMPOSE_FILE env var (set by isolation overlay) so the harness
// uses the offset/renamed temp compose file instead of the checked-in original.
// Without this override, every `docker compose` call would target the default
// project's compose file → concurrent --isolate runs collide on container names.
const COMPOSE_FILE =
process.env.SHOWCASE_COMPOSE_FILE ||
path.join(SHOWCASE_DIR, "docker-compose.local.yml");
const INTEGRATIONS_DIR = path.join(SHOWCASE_DIR, "integrations");
// Honor LOCAL_PORTS_FILE env var (set by isolation overlay) so the harness
// reads offset ports from a temp file instead of the checked-in original.
@@ -24,11 +30,17 @@ const PORTS_FILE =
process.env.LOCAL_PORTS_FILE ||
path.join(SHOWCASE_DIR, "shared/local-ports.json");
/** Well-known infra service ports that aren't in local-ports.json. */
/** Well-known infra service ports that aren't in local-ports.json.
*
* Honor SHOWCASE_INFRA_PORT_OFFSET (set by --isolate) so health checks hit
* the offset host ports of the isolated stack instead of the default
* project's :4010/:8090/:3200 (which would silently report "healthy"
* against the WRONG containers). */
const _INFRA_OFFSET = Number(process.env.SHOWCASE_INFRA_PORT_OFFSET) || 0;
const INFRA_PORTS: Record<string, number> = {
aimock: 4010,
pocketbase: 8090,
dashboard: 3200,
aimock: 4010 + _INFRA_OFFSET,
pocketbase: 8090 + _INFRA_OFFSET,
dashboard: 3200 + _INFRA_OFFSET,
};
/** Health-check endpoint overrides per service type. */
@@ -38,8 +50,10 @@ const HEALTH_ENDPOINTS: Record<string, string> = {
dashboard: "/",
};
/** Default health endpoint for integration services. */
const DEFAULT_HEALTH_ENDPOINT = "/health";
/** Default health endpoint for integration services.
* Matches the compose-level integration healthcheck
* (`curl -f http://localhost:10000/api/health`) in docker-compose.local.yml. */
const DEFAULT_HEALTH_ENDPOINT = "/api/health";
export interface LifecycleOptions {
verbose?: boolean;
@@ -583,7 +597,12 @@ export async function healthCheck(
services: string[],
): Promise<Map<string, boolean>> {
const results = new Map<string, boolean>();
const maxWaitMs = 30_000;
// Honor SHOWCASE_HEALTHCHECK_TIMEOUT_MS so cold-start isolated stacks
// (slower than the warm default project) have time to come up. Default
// bumped from 30s to 90s — Next.js + Python/JVM agents commonly need 60s+
// on first boot inside a fresh project.
const maxWaitMs =
Number(process.env.SHOWCASE_HEALTHCHECK_TIMEOUT_MS) || 90_000;
const intervalMs = 2_000;
log.info("running health checks", { services });
+61 -1
View File
@@ -185,6 +185,20 @@ apply_isolation() {
local name="${1:-}"
ISOLATE_ACTIVE=true
# docker compose project names must be lowercase ([a-z0-9_-]). Reject (or
# normalize) uppercase so the user gets a clear error instead of an opaque
# compose failure. We normalize-with-warn for ergonomic CLI use.
if [ -n "$name" ] && [[ "$name" =~ [^a-z0-9_-] ]]; then
local lowered
lowered="$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')"
if [[ "$lowered" =~ ^[a-z0-9_-]+$ ]]; then
warn "Isolation name '$name' has uppercase chars; lowercasing to '$lowered' (docker compose project-name constraint)"
name="$lowered"
else
die "Invalid --isolate name '$name': must match [a-z0-9_-]+ (docker compose project-name constraint)"
fi
fi
# Guard: clean up stale .iso-bak files from a prior botched run that
# mutated originals in-place (the old approach). This makes migration safe.
if [ -f "${PORTS_FILE}.iso-bak" ] || [ -f "${COMPOSE_FILE}.iso-bak" ]; then
@@ -235,6 +249,34 @@ def offset_port(m):
content = re.sub(r'(\s+)- \"(\d+):(\d+)\"', offset_port, content)
content = content.replace('container_name: showcase-', 'container_name: $name-')
# Rewrite relative paths to absolute, anchored at SHOWCASE_ROOT. Without this,
# docker compose resolves them against the temp dir holding the rewritten
# compose file and fails (env_file: .env, build: ./pocketbase, volume mounts).
# We touch: build context (./xxx and 'context: ./xxx'), volumes (\"- ./xxx:\"),
# and env_file: .env / .env.local style references.
ROOT = '$SHOWCASE_ROOT'
import os.path as _osp
PARENT = _osp.dirname(ROOT.rstrip('/'))
def _abs(prefix, tail, base):
return prefix + base.rstrip('/') + '/' + tail
# build: ../foo / build: ../ → rooted at <parent-of-showcase>
content = re.sub(r'(\s+build:\s+)\.\./?([^\n]*)', lambda m: _abs(m.group(1), m.group(2), PARENT), content)
# build: ./foo → rooted at <showcase>
content = re.sub(r'(\s+build:\s+)\./([^\n]+)', lambda m: _abs(m.group(1), m.group(2), ROOT), content)
# context: ../... → rooted at <parent>
content = re.sub(r'(\s+context:\s+)\.\./?([^\n]*)', lambda m: _abs(m.group(1), m.group(2), PARENT), content)
# context: ./foo → rooted at <showcase>
content = re.sub(r'(\s+context:\s+)\./([^\n]+)', lambda m: _abs(m.group(1), m.group(2), ROOT), content)
# dockerfile: ./foo
content = re.sub(r'(\s+dockerfile:\s+)\./([^\n]+)', lambda m: _abs(m.group(1), m.group(2), ROOT), content)
# volumes: - ./foo:/bar → - <showcase>/foo:/bar
content = re.sub(r'(\s+-\s+)\./([^:\n]+:)', lambda m: _abs(m.group(1), m.group(2), ROOT), content)
# env_file: .env → <showcase>/.env
content = re.sub(r'(\s+env_file:\s+)\.env(\b)', lambda m: m.group(1) + ROOT + '/.env' + m.group(2), content)
with open('$tmp_compose', 'w') as f:
f.write(content)
"
@@ -245,8 +287,26 @@ with open('$tmp_compose', 'w') as f:
COMPOSE_CMD="docker compose -f $COMPOSE_FILE --project-name $name"
PORTS_FILE="$tmp_ports"
# Export for the TS harness CLI (config.ts / lifecycle.ts honor this)
# Export for the TS harness CLI (config.ts / lifecycle.ts honor these).
# Without SHOWCASE_COMPOSE_FILE the harness hardcodes the default compose
# path, causing container-name collisions on a second concurrent --isolate.
# SHOWCASE_INFRA_PORT_OFFSET shifts the hardcoded :4010/:8090/:3200 health
# checks onto the isolated stack's offset host ports (otherwise the harness
# would silently report the DEFAULT-project aimock/pocketbase as healthy).
export LOCAL_PORTS_FILE="$tmp_ports"
export SHOWCASE_COMPOSE_FILE="$tmp_compose"
export SHOWCASE_INFRA_PORT_OFFSET="$ISOLATE_PORT_OFFSET"
# Offset host-side URLs so any harness code referencing config.aimockUrl /
# dashboardUrl / pocketbase.url talks to THIS project's instances (not the
# default :4010 / :3200 / :8090).
local aimock_host_port=$(( 4010 + ISOLATE_PORT_OFFSET ))
local dashboard_host_port=$(( 3200 + ISOLATE_PORT_OFFSET ))
local pocketbase_host_port=$(( 8090 + ISOLATE_PORT_OFFSET ))
export AIMOCK_URL_LOCAL="http://localhost:${aimock_host_port}"
export DASHBOARD_URL_LOCAL="http://localhost:${dashboard_host_port}"
export DASHBOARD_PORT_LOCAL="$dashboard_host_port"
export POCKETBASE_URL_LOCAL="http://localhost:${pocketbase_host_port}"
# Idempotent: tear down any prior run with this name
$COMPOSE_CMD down --remove-orphans 2>/dev/null || true