mirror of
https://github.com/CopilotKit/CopilotKit.git
synced 2026-09-14 16:26:20 +08:00
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:
Generated
+3948
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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,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,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user