chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
+1457
View File
File diff suppressed because it is too large Load Diff
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env bash
# showcase aimock-rebuild — rebuild local aimock from source checkout
# Sourced by the main dispatcher; do not execute directly.
CMD_AIMOCK_REBUILD_DESC="Rebuild local aimock from source checkout"
usage_aimock_rebuild() {
cat <<'HELP'
Usage: showcase aimock-rebuild [--from <path>]
Rebuild aimock from a local source checkout and redeploy the container.
Options:
--from <path> Path to local aimock checkout
Default: $AIMOCK_SRC or ../aimock (sibling repo)
Steps performed:
1. npm run build (in aimock source)
2. Docker build (DEPOT_DISABLE=1, local --load)
3. Force-recreate aimock container
4. Wait for healthy
Environment:
AIMOCK_SRC Default aimock source directory
HELP
}
cmd_aimock_rebuild() {
local aimock_src=""
# ── Parse arguments ──────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--from)
[[ -z "${2:-}" ]] && die "--from requires a path argument"
aimock_src="$2"
shift 2
;;
-h|--help)
usage_aimock_rebuild
return 0
;;
*)
die "Unknown argument: $1 (see showcase aimock-rebuild --help)"
;;
esac
done
# ── Resolve aimock source directory ──────────────────────────────
if [[ -z "$aimock_src" ]]; then
if [[ -n "${AIMOCK_SRC:-}" && -d "$AIMOCK_SRC" ]]; then
aimock_src="$AIMOCK_SRC"
elif [[ -d "$SHOWCASE_ROOT/../../aimock" ]]; then
aimock_src="$SHOWCASE_ROOT/../../aimock"
elif [[ -d "$SHOWCASE_ROOT/../aimock" ]]; then
aimock_src="$SHOWCASE_ROOT/../aimock"
else
die "Cannot find aimock source. Set AIMOCK_SRC or use --from <path>"
fi
fi
# Canonicalise and validate
aimock_src="$(cd "$aimock_src" 2>/dev/null && pwd)" \
|| die "Cannot resolve aimock source path"
[[ -f "$aimock_src/package.json" ]] \
|| die "No package.json in $aimock_src — is this an aimock checkout?"
info "aimock source: $aimock_src"
local step_start total_start
total_start=$(date +%s.%N 2>/dev/null || date +%s)
# ── Step 1: npm build ───────────────────────────────────────────
info "Step 1/4: npm run build"
step_start=$(date +%s.%N 2>/dev/null || date +%s)
(cd "$aimock_src" && npm run build) || die "npm run build failed in $aimock_src"
local build_elapsed
build_elapsed=$(awk "BEGIN{printf \"%.1f\", $(date +%s.%N 2>/dev/null || date +%s) - $step_start}")
success "npm build (${build_elapsed}s)"
# ── Step 2: Docker build ────────────────────────────────────────
info "Step 2/4: Docker build"
step_start=$(date +%s.%N 2>/dev/null || date +%s)
# Detect available builder
local builder
if docker buildx ls 2>/dev/null | grep -q desktop-linux; then
builder="desktop-linux"
else
builder="default"
fi
info "Using builder: $builder"
DEPOT_DISABLE=1 docker buildx build \
--builder "$builder" \
--load \
-t aimock:local \
"$aimock_src" \
|| die "Docker build failed"
local docker_elapsed
docker_elapsed=$(awk "BEGIN{printf \"%.1f\", $(date +%s.%N 2>/dev/null || date +%s) - $step_start}")
success "Docker build (${docker_elapsed}s)"
# ── Step 3: Force-recreate container ────────────────────────────
info "Step 3/4: Force-recreate aimock container"
step_start=$(date +%s.%N 2>/dev/null || date +%s)
docker compose -f "$AIMOCK_COMPOSE" up -d --force-recreate aimock \
|| die "Failed to recreate aimock container"
local recreate_elapsed
recreate_elapsed=$(awk "BEGIN{printf \"%.1f\", $(date +%s.%N 2>/dev/null || date +%s) - $step_start}")
success "Force-recreate (${recreate_elapsed}s)"
# ── Step 4: Wait for healthy ────────────────────────────────────
info "Step 4/4: Waiting for aimock to become healthy"
step_start=$(date +%s.%N 2>/dev/null || date +%s)
local container_name="showcase-aimock"
local timeout_secs=30
local deadline=$((SECONDS + timeout_secs))
local status=""
while [[ $SECONDS -lt $deadline ]]; do
status=$(docker inspect --format='{{.State.Health.Status}}' "$container_name" 2>/dev/null || echo "missing")
case "$status" in
healthy)
local health_elapsed
health_elapsed=$(awk "BEGIN{printf \"%.1f\", $(date +%s.%N 2>/dev/null || date +%s) - $step_start}")
success "Healthy (${health_elapsed}s)"
local total_elapsed
total_elapsed=$(awk "BEGIN{printf \"%.1f\", $(date +%s.%N 2>/dev/null || date +%s) - $total_start}")
success "aimock rebuilt and healthy (total ${total_elapsed}s)"
return 0
;;
unhealthy)
warn "Container reports unhealthy — retrying..."
;;
esac
printf "."
sleep 1
done
# Timed out
printf "\n"
local total_elapsed
total_elapsed=$(awk "BEGIN{printf \"%.1f\", $(date +%s.%N 2>/dev/null || date +%s) - $total_start}")
warn "aimock rebuilt but health check timed out after ${timeout_secs}s (container may still be starting)"
warn "Last status: $status (total ${total_elapsed}s)"
return 1
}
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# showcase cvdiag-codegen — derive schema.json from the canonical schema.ts and
# invoke the per-language codegen stubs.
# Sourced by the main dispatcher; do not execute directly.
#
# This is the L0-A foundation stub for the `bin/showcase cvdiag codegen`
# pipeline. It (1) regenerates schema.json (the JSON-Schema IR) from
# showcase/harness/src/cvdiag/schema.ts, then (2) invokes per-language codegen
# stubs that materialize Pydantic models / .NET records / Java records / Go
# structs from that IR. The per-language emitters (L0-C/D/E/F) own the actual
# materialization; this stub provides the single invocation seam + drift check.
CMD_CVDIAG_CODEGEN_DESC="Regenerate CVDIAG schema.json + per-language bindings"
usage_cvdiag_codegen() {
cat <<'HELP'
Usage: showcase cvdiag-codegen [--check]
Regenerate the CVDIAG JSON-Schema IR (schema.json) from the canonical
TypeScript schema (showcase/harness/src/cvdiag/schema.ts), then invoke the
per-language codegen stubs (Python / .NET / Java / Go).
Options:
--check Verify schema.json is up to date with schema.ts WITHOUT writing
(exit non-zero on drift). Intended for CI.
Steps performed:
1. tsx src/cvdiag/codegen.ts → writes schema.json (the IR)
2. per-language codegen stubs (Pydantic / .NET / Java / Go) — wired by the
L0-C/D/E/F slots; no-op until those land.
HELP
}
cmd_cvdiag_codegen() {
local check_only=0
while [[ $# -gt 0 ]]; do
case "$1" in
--check)
check_only=1
shift
;;
-h|--help)
usage_cvdiag_codegen
return 0
;;
*)
die "Unknown argument: $1 (see showcase cvdiag-codegen --help)"
;;
esac
done
local harness_dir="$SHOWCASE_ROOT/harness"
[[ -f "$harness_dir/src/cvdiag/codegen.ts" ]] \
|| die "Missing codegen.ts — is the cvdiag foundation (L0-A) present?"
# ── Step 1: regenerate (or check) schema.json ───────────────────────────
if [[ "$check_only" -eq 1 ]]; then
info "Checking schema.json is up to date with schema.ts"
(cd "$harness_dir" && npx tsx src/cvdiag/codegen.ts --check) \
|| die "schema.json is STALE — run 'showcase cvdiag-codegen' and commit the result."
success "schema.json is in sync with schema.ts"
return 0
fi
info "Step 1: regenerate schema.json from schema.ts"
(cd "$harness_dir" && npx tsx src/cvdiag/codegen.ts) \
|| die "schema.json codegen failed"
success "schema.json regenerated"
# ── Step 2: per-language codegen stubs (wired by L0-C/D/E/F) ─────────────
info "Step 2: per-language codegen stubs"
# Each binding slot extends this section to read schema.json and emit its
# native types. Until those land, this is a documented no-op.
warn "per-language codegen stubs not yet wired (L0-C/D/E/F own these)"
success "CVDIAG codegen complete"
}
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env bash
# showcase cvdiag-stage-ts — stage the shared CVDIAG TypeScript emitter into
# each TS integration's own build context.
# Sourced by the main dispatcher; do not execute directly.
#
# The five TS-backed integrations (langgraph-typescript, claude-sdk-typescript,
# mastra, built-in-agent, strands-typescript) each build STANDALONE (their own
# Docker build context is the integration dir). They have no path alias back to
# `showcase/harness/src/cvdiag` and no workspace import, so the canonical L0-A
# CVDIAG sources cannot be reached across the monorepo at build time. This is
# the TS analogue of L0-C's Python `_shared` COPY-staging: copy the canonical
# sources into a co-located `src/cvdiag/` dir inside each integration so they
# are (a) inside the build context, (b) bundled into `.next` by `next build`
# (the route handler imports them in-process), and (c) resolvable via the
# `@/cvdiag/*` path alias that every TS integration already declares.
#
# NOTE on `tsc` target: the staged `emit.ts` uses BigInt literals (`40n`) in the
# UUIDv7 minter, which `tsc` flags as TS2737 under the integrations' `target:
# ES2017` tsconfig. The staged copies are kept VERBATIM (true single source) and
# this is non-fatal: every TS integration sets `typescript.ignoreBuildErrors:
# true` in next.config, `next build` transpiles BigInt literals via SWC to a
# Node-22-correct runtime, and the vitest suites pass via esbuild. The build gate
# is `next build`, not standalone `tsc`. Do NOT rewrite the staged sources to
# dodge TS2737 — that would diverge them from canonical and break --check drift.
#
# SINGLE SOURCE OF TRUTH: this command COPIES from the canonical L0-A sources
# (`showcase/harness/src/cvdiag/{schema,edge-headers,emit}.ts`) plus the shared
# barrel (`showcase/integrations/_shared/ts/cvdiag-emitter.ts`, FLATTENED so its
# re-exports point at the co-located copies instead of `../../../harness/...`).
# Re-run after any L0-A schema change and commit the staged copies. A `--check`
# mode verifies the staged copies are in sync (for CI / pre-commit).
CMD_CVDIAG_STAGE_TS_DESC="Stage the shared CVDIAG TS emitter into each TS integration build context"
# The five TS integrations that import the CVDIAG emitter (the first four
# in-process via their Next route; strands-typescript agent-side in its Express
# process — see integrations/strands-typescript/src/agent/cvdiag-backend-strands.ts).
_CVDIAG_TS_INTEGRATIONS=(
"langgraph-typescript"
"claude-sdk-typescript"
"mastra"
"built-in-agent"
"strands-typescript"
)
# The canonical L0-A sources (relative to harness/src/cvdiag/). They are
# already self-referential via `./schema.js` etc., so copying them verbatim
# into a co-located dir keeps the relative imports resolving. `scrub.ts` is the
# leaf secret/PII scrub module that BOTH `schema.ts` (metadata-value scrub) and
# `edge-headers.ts` (which re-exports `scrubSecrets`/`scrubDeep` for back-compat)
# import via `./scrub.js`; it MUST be staged alongside them or those co-located
# imports dangle inside the standalone build context (and the stale legacy
# inline scrub — which leaks base64url `sk-ant-…` keys and colon-less URL
# userinfo — would ship instead).
_CVDIAG_L0A_SOURCES=(
"scrub.ts"
"schema.ts"
"edge-headers.ts"
"emit.ts"
"pb-writer-fetch.ts"
)
usage_cvdiag_stage_ts() {
cat <<'HELP'
Usage: showcase cvdiag-stage-ts [--check]
Stage the canonical CVDIAG TypeScript emitter into each TS integration's
co-located `src/cvdiag/` dir so the emitter is reachable inside each
integration's standalone Docker build context.
Options:
--check Verify the staged copies are in sync with the canonical sources
WITHOUT writing (exit non-zero on drift). Intended for CI.
Staged into each of: langgraph-typescript, claude-sdk-typescript, mastra,
built-in-agent, strands-typescript (under src/cvdiag/):
scrub.ts, schema.ts, edge-headers.ts, emit.ts, pb-writer-fetch.ts
(verbatim from harness/src/cvdiag/)
cvdiag-emitter.ts (flattened barrel: re-exports the
co-located copies, not ../../../harness)
HELP
}
# Emit the FLATTENED barrel to stdout. Identical export surface to
# showcase/integrations/_shared/ts/cvdiag-emitter.ts, but the re-export
# specifiers point at the co-located `./{schema,edge-headers,emit}.js` copies
# rather than reaching back across the monorepo to `../../../harness/...`.
_cvdiag_emit_flattened_barrel() {
cat <<'BARREL'
/**
* cvdiag-emitter.ts — co-located CVDIAG emitter barrel for a STANDALONE TS
* integration build context. GENERATED by `showcase cvdiag-stage-ts` — do NOT
* edit by hand; edit the canonical sources under
* `showcase/harness/src/cvdiag/` and re-run the stage command.
*
* This is the FLATTENED form of `showcase/integrations/_shared/ts/cvdiag-emitter.ts`:
* the shared barrel re-exports from `../../../harness/src/cvdiag/*`, which has
* no resolution inside a standalone integration's Docker build context. The
* stage command copies the L0-A sources (scrub.ts, schema.ts, edge-headers.ts,
* emit.ts, pb-writer-fetch.ts) next to this file and rewrites the re-export
* specifiers to the co-located `./*.js` copies so `next build` bundles a
* self-contained emitter + concrete writer-role PB writer.
*/
// ── Schema (types, enums, validators, UUIDv7 regex) ─────────────────────────
export {
SCHEMA_VERSION,
CVDIAG_LAYERS,
CVDIAG_OUTCOMES,
PROBE_BOUNDARIES,
BACKEND_BOUNDARIES,
AIMOCK_BOUNDARIES,
CVDIAG_DATA_PLANE_BOUNDARIES,
CVDIAG_ACCOUNTING_BOUNDARIES,
CVDIAG_BOUNDARIES,
EDGE_HEADER_KEYS,
TERMINATION_KINDS,
TEST_ID_REGEX,
ENVELOPE_KEYS,
BOUNDARY_METADATA_KEYS,
isValidTestId,
validateEnvelope,
validateMetadata,
} from "./schema.js";
export type {
CvdiagLayer,
CvdiagOutcome,
CvdiagDataPlaneBoundary,
CvdiagAccountingBoundary,
CvdiagBoundary,
EdgeHeaders,
EdgeHeaderKey,
TerminationKind,
CvdiagEnvelope,
EnvelopeValidationResult,
MetadataValidationResult,
} from "./schema.js";
// ── Edge-header allow/deny filter + PII scrub ───────────────────────────────
export {
EDGE_HEADER_ALLOWLIST,
EDGE_HEADER_DENYLIST,
BEARER_TOKEN_REGEX,
SK_KEY_REGEX,
URL_USERINFO_REGEX,
SCRUB_REPLACEMENT,
scrubSecrets,
filterEdgeHeaders,
} from "./edge-headers.js";
// ── Emitter (tier resolution, fail-closed DEBUG, byte caps, span/id minters) ─
export {
CvdiagEmitter,
BYTE_CAP_BY_TIER,
QUEUE_CAP,
DEBUG_MAX_WALLCLOCK_MS,
DEBUG_MAX_EVENTS,
FLUSH_WINDOW_MS,
resolveEnvLabel,
mintTestId,
mintSpanId,
} from "./emit.js";
export type {
CvdiagTier,
CvdiagPbWriter,
CvdiagEnv,
CvdiagEmitterOptions,
CvdiagEmitArgs,
} from "./emit.js";
// ── Concrete writer-role PB writer (plain fetch; auth-with-password→Bearer) ──
export {
CvdiagFetchPbWriter,
createCvdiagFetchPbWriterFromEnv,
} from "./pb-writer-fetch.js";
export type {
FetchLike,
CvdiagFetchPbWriterOptions,
} from "./pb-writer-fetch.js";
BARREL
}
cmd_cvdiag_stage_ts() {
local check_only=0
while [[ $# -gt 0 ]]; do
case "$1" in
--check)
check_only=1
shift
;;
-h|--help)
usage_cvdiag_stage_ts
return 0
;;
*)
die "Unknown argument: $1 (see showcase cvdiag-stage-ts --help)"
;;
esac
done
local harness_cvdiag="$SHOWCASE_ROOT/harness/src/cvdiag"
local integrations_dir="$SHOWCASE_ROOT/integrations"
local src
for src in "${_CVDIAG_L0A_SOURCES[@]}"; do
[[ -f "$harness_cvdiag/$src" ]] \
|| die "Missing canonical source $harness_cvdiag/$src — is the cvdiag foundation (L0-A) present?"
done
# Build the flattened barrel once into a temp file for copy/compare.
local tmp_barrel
tmp_barrel="$(mktemp)"
# shellcheck disable=SC2064
trap "rm -f '$tmp_barrel'" RETURN
_cvdiag_emit_flattened_barrel > "$tmp_barrel"
local drift=0
local integration
for integration in "${_CVDIAG_TS_INTEGRATIONS[@]}"; do
local dest_dir="$integrations_dir/$integration/src/cvdiag"
if [[ "$check_only" -eq 1 ]]; then
# Compare each staged file against its source; report drift, don't write.
for src in "${_CVDIAG_L0A_SOURCES[@]}"; do
if ! cmp -s "$harness_cvdiag/$src" "$dest_dir/$src"; then
warn "DRIFT: $integration/src/cvdiag/$src differs from canonical"
drift=1
fi
done
if ! cmp -s "$tmp_barrel" "$dest_dir/cvdiag-emitter.ts"; then
warn "DRIFT: $integration/src/cvdiag/cvdiag-emitter.ts differs from canonical barrel"
drift=1
fi
continue
fi
info "Staging CVDIAG emitter into $integration/src/cvdiag/"
mkdir -p "$dest_dir"
for src in "${_CVDIAG_L0A_SOURCES[@]}"; do
cp "$harness_cvdiag/$src" "$dest_dir/$src"
done
cp "$tmp_barrel" "$dest_dir/cvdiag-emitter.ts"
# mktemp creates a 600 file; the staged copies are committed source, so
# normalize to the repo's standard 644.
chmod 644 "$dest_dir/cvdiag-emitter.ts"
done
if [[ "$check_only" -eq 1 ]]; then
if [[ "$drift" -ne 0 ]]; then
die "Staged CVDIAG TS sources are STALE — run 'showcase cvdiag-stage-ts' and commit."
fi
success "Staged CVDIAG TS sources are in sync with the canonical sources"
return 0
fi
success "CVDIAG TS emitter staged into ${#_CVDIAG_TS_INTEGRATIONS[@]} integrations"
}
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
# showcase cvdiag — query, classify, replay, and purge the CVDIAG
# flap-observability event store (the `cvdiag_events` / `cvdiag_raw_byte_samples`
# PocketBase collections). Sourced by the main dispatcher; do not execute
# directly.
#
# Backed by the L2-B node entrypoints under harness/src/cvdiag/:
# timeline → cli-replay.ts (ordered boundary timeline for a test-id)
# classify → cli-classify.ts (run the L2-A flap classifier over a test-id)
# replay → cli-replay.ts (reconstruct + validate the request sequence)
# purge → cli-purge.ts (cascade-delete events + raw-byte samples,
# then emit a cvdiag.purge_audit accounting event)
# ab-report → cli-ab-report.ts (diff the edge vs Railway-internal A/B arms,
# grouped by ab_pair_id, from the collector JSON)
#
# All reads/writes go through the harness PB superuser client, which bypasses
# the three-key ACL (the writer/purge/migration role keys are write-only — see
# the cvdiag_events migration). The CLI inherits POCKETBASE_URL +
# POCKETBASE_SUPERUSER_EMAIL/PASSWORD from the environment.
CMD_CVDIAG_DESC="Query/classify/replay/purge the CVDIAG flap-observability store"
usage_cvdiag() {
cat <<'HELP'
Usage: showcase cvdiag <subcommand> <test-id|selector>
Subcommands:
timeline <test-id> Print the ordered boundary timeline for a test-id.
classify <test-id> Run the flap classifier; print class + confidence +
reason + evidence as JSON. (alias: --classify)
replay <test-id> Reconstruct + validate the request sequence as JSON.
Rejects malformed stored rows with a clear error.
(alias: --replay)
purge <selector> Delete cvdiag_events matching the selector AND cascade
to cvdiag_raw_byte_samples, then emit a
cvdiag.purge_audit accounting event. The selector is a
test-id (UUIDv7) or a slug. (alias: --purge)
ab-report [file] Diff the edge vs Railway-internal A/B arms (grouped by
ab_pair_id) and print the report as JSON. Reads the
collected AbOutcomeRecord[] JSON from <file>, or from
stdin when no file is given. (alias: --ab-report)
Environment:
POCKETBASE_URL PB base URL (required outside test/dev).
POCKETBASE_SUPERUSER_EMAIL Superuser identity for the CLI reads/writes.
POCKETBASE_SUPERUSER_PASSWORD
CVDIAG_OPERATOR_ID Operator id stamped on the purge audit (purge).
Examples:
showcase cvdiag timeline 0190b8a0-0000-7000-8000-000000000001
showcase cvdiag classify 0190b8a0-0000-7000-8000-000000000001
showcase cvdiag replay 0190b8a0-0000-7000-8000-000000000001
showcase cvdiag purge 0190b8a0-0000-7000-8000-000000000001
showcase cvdiag purge langgraph-python
showcase cvdiag ab-report ab-outcomes.json
showcase cvdiag ab-report < ab-outcomes.json
HELP
}
# Run a cvdiag node entrypoint via tsx from the harness package. Passes the
# remaining args through verbatim. The entrypoint owns its own arg/usage checks
# and exit codes (0 ok, 1 operational error e.g. a malformed row, 2 usage).
_cvdiag_run_entrypoint() {
local script="$1"
shift
local harness_dir="$SHOWCASE_ROOT/harness"
[[ -f "$harness_dir/src/cvdiag/$script" ]] \
|| die "Missing $script — is the cvdiag CLI (L2-B) present?"
(cd "$harness_dir" && npx tsx "src/cvdiag/$script" "$@")
}
# timeline: reconstruct the ordered sequence (cli-replay.ts) and render it as a
# compact one-line-per-boundary timeline. Falls back to the raw JSON when jq is
# unavailable so the command still works without it.
cvdiag_timeline() {
local test_id="${1:-}"
[[ -n "$test_id" ]] || die "test-id required (see showcase cvdiag --help)"
local json
if ! json="$(_cvdiag_run_entrypoint cli-replay.ts "$test_id")"; then
die "cvdiag timeline failed for $test_id (see error above)"
fi
if command -v jq >/dev/null 2>&1; then
info "Boundary timeline for $test_id"
echo "$json" | jq -r '
.events[]
| "\(.ts) [\(.layer)] \(.boundary) outcome=\(.outcome)"
'
else
echo "$json"
fi
}
cmd_cvdiag() {
local subcmd="${1:-}"
shift || true
case "$subcmd" in
""|-h|--help|help)
usage_cvdiag
[[ -z "$subcmd" ]] && return 1
return 0
;;
timeline)
cvdiag_timeline "$@"
;;
classify|--classify)
_cvdiag_run_entrypoint cli-classify.ts "$@"
;;
replay|--replay)
_cvdiag_run_entrypoint cli-replay.ts "$@"
;;
purge|--purge)
_cvdiag_run_entrypoint cli-purge.ts "$@"
;;
ab-report|--ab-report)
_cvdiag_run_entrypoint cli-ab-report.ts "$@"
;;
*)
die "Unknown cvdiag subcommand: $subcmd (see showcase cvdiag --help)"
;;
esac
}
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# showcase diff-logs — show log delta for a time window
CMD_DIFF_LOGS_DESC="Show log delta for a time window"
usage_diff_logs() {
cat <<'USAGE'
Usage: showcase diff-logs <slug> --since <time> [options]
Show container logs for a specific time window. Useful when running
tests repeatedly — see only logs from the last run, not the full
container lifetime.
Options:
--since <time> Start of window (required). Accepts:
Duration: 10m, 1h, 30s
Timestamp: 2024-01-15T10:30:00
Special: "last-test" (reads .last-test-ts marker)
--until <time> End of window (default: now)
--grep <pattern> Filter output (regex, e.g. "fixture|match")
Examples:
showcase diff-logs aimock --since 5m
showcase diff-logs mastra --since 10m --grep "error|warn"
showcase diff-logs aimock --since last-test
showcase diff-logs aimock --since 10:30:00 --until 10:35:00
USAGE
}
cmd_diff_logs() {
[[ "${1:-}" == "-h" || "${1:-}" == "--help" ]] && { usage_diff_logs; return 0; }
local slug=""
local since=""
local until_ts=""
local grep_pattern=""
# First positional arg is slug, rest are flags
slug="${1:-}"
need_slug "$slug"
shift
while [[ $# -gt 0 ]]; do
case "$1" in
--since) [[ -n "${2:-}" ]] || die "--since requires a value"; since="$2"; shift 2 ;;
--until) [[ -n "${2:-}" ]] || die "--until requires a value"; until_ts="$2"; shift 2 ;;
--grep) [[ -n "${2:-}" ]] || die "--grep requires a value"; grep_pattern="$2"; shift 2 ;;
-h|--help) usage_diff_logs; return 0 ;;
*) die "Unknown option: $1" ;;
esac
done
[[ -z "$since" ]] && die "--since is required"
# Handle --since last-test convenience
if [[ "$since" == "last-test" ]]; then
local ts_file="$SHOWCASE_ROOT/.last-test-ts"
if [[ -f "$ts_file" ]]; then
since=$(cat "$ts_file")
info "Using last test timestamp: $since"
else
warn "No .last-test-ts found, falling back to 5m"
since="5m"
fi
fi
local container
container=$(slug_to_container "$slug")
local docker_args=("--since" "$since")
[[ -n "$until_ts" ]] && docker_args+=("--until" "$until_ts")
local output
if [[ -n "$grep_pattern" ]]; then
output=$(docker logs "${docker_args[@]}" "$container" 2>&1 | grep --color=auto -E "$grep_pattern") || true
else
output=$(docker logs "${docker_args[@]}" "$container" 2>&1)
fi
local line_count
if [[ -z "$output" ]]; then
line_count=0
else
line_count=$(printf '%s\n' "$output" | wc -l | tr -d ' ')
fi
local window_desc="$since"
[[ -n "$until_ts" ]] && window_desc="${since}${until_ts}"
printf '═══ %s logs (%s, %d lines) ═══\n' "$container" "$window_desc" "$line_count"
[[ -n "$output" ]] && printf '%s\n' "$output"
echo "═══════════════════════════════════════════════════════════════"
}
+385
View File
@@ -0,0 +1,385 @@
#!/usr/bin/env bash
# showcase doctor — diagnose common local stack issues
# Sourced by the main dispatcher; do not execute directly.
CMD_DOCTOR_DESC="Diagnose common local stack issues"
usage_doctor() {
cat <<'HELP'
Usage: showcase doctor
Diagnose common issues with the local showcase stack.
Checks performed:
- Docker engine and Compose availability
- Depot CLI interception (common build gotcha)
- ENV file and API keys
- Compose file validity
- Container status and stale images
- Aimock health and fixture files
- Port conflicts
HELP
}
# ── Color helpers ────────────────────────────────────────────────────────────
_doctor_has_color() {
[ -t 1 ] && { [ "${TERM:-dumb}" != "dumb" ] || [ -n "${FORCE_COLOR:-}" ]; }
}
_doctor_pass() {
if _doctor_has_color; then
printf '\033[0;32m%-22s\033[0m %s\n' "$1" "$2"
else
printf '%-22s %s\n' "$1" "$2"
fi
_DOCTOR_PASS=$((_DOCTOR_PASS + 1))
}
_doctor_warn() {
if _doctor_has_color; then
printf '\033[1;33m%-22s\033[0m %s\n' "$1" "$2"
else
printf '%-22s %s\n' "$1" "$2"
fi
_DOCTOR_WARN=$((_DOCTOR_WARN + 1))
}
_doctor_fail() {
if _doctor_has_color; then
printf '\033[1;31m%-22s\033[0m %s\n' "$1" "$2"
else
printf '%-22s %s\n' "$1" "$2"
fi
_DOCTOR_FAIL=$((_DOCTOR_FAIL + 1))
}
# ── Individual checks ───────────────────────────────────────────────────────
_check_docker_engine() {
if ! docker info >/dev/null 2>&1; then
_doctor_fail "Docker engine" "Not running — start Docker Desktop or dockerd"
return
fi
local version
version="$(docker version --format '{{.Server.Version}}' 2>/dev/null || echo "unknown")"
_doctor_pass "Docker engine" "Docker $version"
}
_check_docker_compose() {
if ! docker compose version >/dev/null 2>&1; then
_doctor_fail "Docker Compose" "Not available — install docker-compose-plugin"
return
fi
local version
version="$(docker compose version --short 2>/dev/null || echo "unknown")"
_doctor_pass "Docker Compose" "v$version"
}
_check_depot_interception() {
local docker_path
docker_path="$(which docker 2>/dev/null || true)"
if [ -n "$docker_path" ] && echo "$docker_path" | grep -qi "depot"; then
_doctor_warn "Depot CLI" "Detected — use DEPOT_DISABLE=1 for local builds"
return
fi
# Also check if depot's buildx builder is active even without shim
if DEPOT_DISABLE=1 docker buildx ls 2>/dev/null | grep -q "depot"; then
_doctor_warn "Depot CLI" "Depot buildx builder active — use --builder desktop-linux"
return
fi
_doctor_pass "Depot CLI" "No Depot interception detected"
}
_check_env_file() {
if [ ! -f "$ENV_FILE" ]; then
_doctor_fail "ENV file" ".env missing — copy showcase/.env.example"
return
fi
local key_count=0
local has_openai=false
while IFS= read -r line; do
# Skip comments and empty lines
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
# Count lines with = sign (key=value pairs)
if [[ "$line" == *"="* ]]; then
key_count=$((key_count + 1))
if [[ "$line" == OPENAI_API_KEY=* ]]; then
local val="${line#OPENAI_API_KEY=}"
# Strip quotes
val="${val#\"}"
val="${val%\"}"
val="${val#\'}"
val="${val%\'}"
[ -n "$val" ] && has_openai=true
fi
fi
done < "$ENV_FILE"
if [ "$has_openai" = false ]; then
_doctor_warn "ENV file" ".env present ($key_count keys) but missing OPENAI_API_KEY"
return
fi
_doctor_pass "ENV file" ".env present ($key_count keys)"
}
_check_compose_file() {
if [ ! -f "$COMPOSE_FILE" ]; then
_doctor_fail "Compose file" "docker-compose.local.yml missing"
return
fi
if ! docker compose -f "$COMPOSE_FILE" config --quiet 2>/dev/null; then
_doctor_fail "Compose file" "docker-compose.local.yml failed to parse"
return
fi
local service_count
service_count="$(docker compose -f "$COMPOSE_FILE" config --services 2>/dev/null | wc -l | tr -d ' ')"
_doctor_pass "Compose file" "docker-compose.local.yml valid ($service_count services)"
}
_check_running_containers() {
local containers
containers="$(docker ps -a --filter "name=showcase-" --format '{{.Names}}|{{.Status}}|{{.Image}}' 2>/dev/null || true)"
if [ -z "$containers" ]; then
_doctor_warn "Running containers" "No showcase containers found"
return
fi
local running=0
local total=0
while IFS='|' read -r name status image; do
total=$((total + 1))
if echo "$status" | grep -qi "^up"; then
running=$((running + 1))
fi
done <<< "$containers"
if [ "$running" -eq 0 ]; then
_doctor_warn "Running containers" "0 of $total running"
else
_doctor_pass "Running containers" "$running of $total running"
fi
}
_check_stale_images() {
local containers
containers="$(docker ps --filter "name=showcase-" --format '{{.Names}}' 2>/dev/null || true)"
if [ -z "$containers" ]; then
# No running containers, nothing to check
_doctor_pass "Stale images" "No running containers to check"
return
fi
local stale_list=""
while IFS= read -r cname; do
local slug="${cname#showcase-}"
# Get the image ID the container is running
local container_image_id
container_image_id="$(docker inspect --format='{{.Image}}' "$cname" 2>/dev/null || true)"
[ -z "$container_image_id" ] && continue
# Get the latest local image ID for this slug
local latest_image_id
latest_image_id="$(docker images --format '{{.ID}}' "showcase-${slug}:local" 2>/dev/null | head -1)"
[ -z "$latest_image_id" ] && continue
# Compare (container image is sha256:xxx, local image is short hash)
if ! echo "$container_image_id" | grep -q "$latest_image_id"; then
if [ -n "$stale_list" ]; then
stale_list="$stale_list, $slug"
else
stale_list="$slug"
fi
fi
done <<< "$containers"
if [ -n "$stale_list" ]; then
_doctor_warn "Stale images" "$stale_list using old image (recreate to fix)"
else
_doctor_pass "Stale images" "All containers using latest images"
fi
}
_check_aimock_health() {
local container="showcase-aimock"
# Check if container exists and is running
local status
status="$(docker inspect --format='{{.State.Status}}' "$container" 2>/dev/null || echo "missing")"
if [ "$status" = "missing" ] || [ "$status" = "exited" ]; then
_doctor_warn "Aimock" "Container not running"
return
fi
local health
health="$(docker inspect --format='{{.State.Health.Status}}' "$container" 2>/dev/null || echo "unknown")"
if [ "$health" = "healthy" ]; then
# Try to get fixture count from the health endpoint
local fixture_info=""
local health_response
health_response="$(curl -s --max-time 3 http://localhost:4010/health 2>/dev/null || true)"
if [ -n "$health_response" ] && command -v jq &>/dev/null; then
local fixture_count
fixture_count="$(echo "$health_response" | jq -r '.fixtures // .fixtureCount // empty' 2>/dev/null || true)"
[ -n "$fixture_count" ] && fixture_info=", $fixture_count fixtures loaded"
fi
_doctor_pass "Aimock" "Healthy${fixture_info}"
else
_doctor_warn "Aimock" "Running but $health"
fi
}
_check_fixture_files() {
local fixture_dir="$SHOWCASE_ROOT/aimock"
local file_count=0
local total_size=0
if [ ! -d "$fixture_dir" ]; then
_doctor_warn "Fixture files" "aimock/ directory not found"
return
fi
for f in "$fixture_dir"/*.json; do
[ -f "$f" ] || continue
file_count=$((file_count + 1))
local fsize
# macOS stat vs GNU stat
if stat --version >/dev/null 2>&1; then
fsize="$(stat -c%s "$f" 2>/dev/null || echo 0)"
else
fsize="$(stat -f%z "$f" 2>/dev/null || echo 0)"
fi
total_size=$((total_size + fsize))
done
if [ "$file_count" -eq 0 ]; then
_doctor_warn "Fixture files" "No .json files in aimock/"
return
fi
# Format size nicely
local size_str
if [ "$total_size" -ge 1048576 ]; then
size_str="$((total_size / 1048576)) MB"
elif [ "$total_size" -ge 1024 ]; then
size_str="$((total_size / 1024)) KB"
else
size_str="$total_size B"
fi
_doctor_pass "Fixture files" "$file_count files ($size_str)"
}
_check_port_conflicts() {
if [ ! -f "$PORTS_FILE" ]; then
_doctor_warn "Port conflicts" "local-ports.json not found"
return
fi
local conflicts=""
local port_list
if command -v jq &>/dev/null; then
port_list="$(jq -r 'to_entries[] | "\(.key):\(.value)"' "$PORTS_FILE" 2>/dev/null)"
else
# Fallback: parse JSON manually
port_list="$(grep -o '"[^"]*"[[:space:]]*:[[:space:]]*[0-9]*' "$PORTS_FILE" | sed 's/"//g; s/[[:space:]]*:[[:space:]]*/:/g')"
fi
# Also check well-known ports: aimock=4010, pocketbase=8090
port_list="$port_list
aimock:4010
pocketbase:8090"
while IFS=':' read -r slug port; do
[ -z "$port" ] && continue
# Check if port is in use by a non-Docker process
local listeners
listeners="$(lsof -i :"$port" -sTCP:LISTEN -P -n 2>/dev/null | tail -n +2 || true)"
[ -z "$listeners" ] && continue
# Filter out Docker/com.docker processes
local non_docker
non_docker="$(echo "$listeners" | grep -vi "docker\|com.docker" || true)"
[ -z "$non_docker" ] && continue
local proc_name
proc_name="$(echo "$non_docker" | head -1 | awk '{print $1}')"
if [ -n "$conflicts" ]; then
conflicts="$conflicts, :$port ($proc_name)"
else
conflicts=":$port ($proc_name)"
fi
done <<< "$port_list"
if [ -n "$conflicts" ]; then
_doctor_warn "Port conflicts" "$conflicts"
else
_doctor_pass "Port conflicts" "None detected"
fi
}
# ── Main entry point ────────────────────────────────────────────────────────
cmd_doctor() {
_DOCTOR_PASS=0
_DOCTOR_WARN=0
_DOCTOR_FAIL=0
echo ""
echo "showcase doctor"
echo "─────────────────────────────────"
_check_docker_engine
_check_docker_compose
_check_depot_interception
_check_env_file
_check_compose_file
_check_running_containers
_check_stale_images
_check_aimock_health
_check_fixture_files
_check_port_conflicts
echo "─────────────────────────────────"
local summary="${_DOCTOR_PASS} passed, ${_DOCTOR_WARN} warning"
[ "$_DOCTOR_WARN" -ne 1 ] && summary="${summary}s"
summary="${summary}, ${_DOCTOR_FAIL} failed"
if [ "$_DOCTOR_FAIL" -gt 0 ]; then
if _doctor_has_color; then
printf '\033[1;31m%s\033[0m\n' " $summary"
else
echo " $summary"
fi
return 1
elif [ "$_DOCTOR_WARN" -gt 0 ]; then
if _doctor_has_color; then
printf '\033[1;33m%s\033[0m\n' " $summary"
else
echo " $summary"
fi
else
if _doctor_has_color; then
printf '\033[0;32m%s\033[0m\n' " $summary"
else
echo " $summary"
fi
fi
echo ""
}
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env bash
# showcase fixtures — fixture management (validate)
# Sourced by the main dispatcher; do not execute directly.
CMD_FIXTURES_DESC="Fixture management (validate)"
usage_fixtures() {
cat <<'HELP'
Usage: showcase fixtures <subcommand>
Subcommands:
validate Check fixture JSON files for common errors
Options (validate):
--fixture-dir <path> Directory to scan (default: showcase/aimock/)
Checks performed:
- JSON syntax errors
- Duplicate userMessage + turnIndex combinations
- turnIndex sequence gaps (e.g., 0, 1, 3 — missing 2)
- Empty or missing response fields
- Orphaned sub-agent references (heuristic)
Examples:
showcase fixtures validate
showcase fixtures validate --fixture-dir /path/to/fixtures
HELP
}
cmd_fixtures() {
local subcmd="${1:-}"
shift || true
case "$subcmd" in
validate) fixtures_validate "$@" ;;
*) usage_fixtures; return 1 ;;
esac
}
# ---------------------------------------------------------------------------
# fixtures_validate — run all validation checks on aimock fixture files
# ---------------------------------------------------------------------------
fixtures_validate() {
local fixture_dir="${SHOWCASE_ROOT}/aimock"
# Parse flags
while [[ $# -gt 0 ]]; do
case "$1" in
--fixture-dir) fixture_dir="$2"; shift 2 ;;
*) die "Unknown option: $1" ;;
esac
done
# Pre-flight: jq is required for all JSON inspection
command -v jq >/dev/null || die "jq is required for fixture validation"
if [[ ! -d "$fixture_dir" ]]; then
die "Fixture directory not found: $fixture_dir"
fi
local files=0 fixtures=0 warnings=0
for f in "$fixture_dir"/*.json; do
[[ -f "$f" ]] || continue
files=$((files + 1))
local basename_f
basename_f="$(basename "$f")"
# ------------------------------------------------------------------
# Check 1: JSON syntax
# ------------------------------------------------------------------
local parse_err
if ! parse_err=$(jq empty "$f" 2>&1); then
warn "$basename_f: invalid JSON — $parse_err"
warnings=$((warnings + 1))
continue
fi
# Count fixtures in this file (supports top-level array or .fixtures array)
local count
count=$(jq '
if type == "array" then length
elif .fixtures and (.fixtures | type) == "array" then .fixtures | length
else 1
end
' "$f")
fixtures=$((fixtures + count))
# Normalize: always work with the fixtures array
local fixtures_expr
fixtures_expr='if type == "array" then . elif .fixtures then .fixtures else [.] end'
# ------------------------------------------------------------------
# Check 2: Duplicate userMessage + turnIndex combos
# ------------------------------------------------------------------
local dupes
dupes=$(jq -r "
[ ${fixtures_expr} | .[]
| select(.match.userMessage)
| { um: .match.userMessage, ti: (.match.turnIndex // \"none\") }
]
| group_by([.um, .ti])
| map(select(length > 1))
| .[]
| \" duplicate: userMessage=\\(.[0].um | tostring) turnIndex=\\(.[0].ti | tostring) (\\(length) occurrences)\"
" "$f" 2>/dev/null || true)
if [[ -n "$dupes" ]]; then
warn "$basename_f: duplicate userMessage+turnIndex combinations"
echo "$dupes"
# Count each duplicate group as one warning
local dupe_count
dupe_count=$(echo "$dupes" | wc -l | tr -d ' ')
warnings=$((warnings + dupe_count))
fi
# ------------------------------------------------------------------
# Check 3: turnIndex gaps
# ------------------------------------------------------------------
local gaps
gaps=$(jq -r "
[ ${fixtures_expr} | .[]
| select(.match.userMessage and .match.turnIndex != null)
| { um: .match.userMessage, ti: .match.turnIndex }
]
| group_by(.um)
| .[]
| sort_by(.ti)
| { um: .[0].um, indices: [.[].ti] }
| select(.indices | length > 1)
| . as \$g
| [range(.indices[0]; .indices[-1] + 1)]
- .indices
| select(length > 0) as \$missing
| \" gap: userMessage=\\(\$g.um) has indices \\(\$g.indices | tostring) — missing \\(\$missing | tostring)\"
" "$f" 2>/dev/null || true)
if [[ -n "$gaps" ]]; then
warn "$basename_f: turnIndex sequence gaps"
echo "$gaps"
local gap_count
gap_count=$(echo "$gaps" | wc -l | tr -d ' ')
warnings=$((warnings + gap_count))
fi
# ------------------------------------------------------------------
# Check 4: Empty or missing responses
# ------------------------------------------------------------------
local empty_resp
empty_resp=$(jq -r "
[ ${fixtures_expr} | to_entries[] | .key as \$idx | .value
| select(
(.response == null and .responses == null)
or (.response != null and .response == {})
or (.response != null and .response.content != null and (.response.content | length) == 0 and (.response.toolCalls == null or (.response.toolCalls | length) == 0))
or (.responses != null and (.responses | length) == 0)
)
| \" empty: fixture[\(\$idx)] \(if .match.userMessage then \"userMessage=\" + .match.userMessage else if .match.toolCallId then \"toolCallId=\" + .match.toolCallId else \"(no match key)\" end end)\"
] | .[]
" "$f" 2>/dev/null || true)
if [[ -n "$empty_resp" ]]; then
warn "$basename_f: empty or missing response fields"
echo "$empty_resp"
local empty_count
empty_count=$(echo "$empty_resp" | wc -l | tr -d ' ')
warnings=$((warnings + empty_count))
fi
# ------------------------------------------------------------------
# Check 5: Orphaned sub-agent references (heuristic)
# ------------------------------------------------------------------
# Find tool_calls that reference agent-like names, then check if any
# fixture in the same file could serve as the sub-agent's response.
# A match exists if:
# - a fixture has toolCallId equal to the call's id, OR
# - a fixture's userMessage matches the agent name, OR
# - a fixture's userMessage appears inside the call's arguments
# (sub-agents receive arguments as their prompt)
local orphans
orphans=$(jq -r "
(${fixtures_expr}) as \$all |
[ \$all[]
| select(.response.toolCalls)
| .response.toolCalls[]
| select(.name | test(\"agent\"; \"i\"))
| { name: .name, id: .id, args: (.arguments // \"\") }
] as \$agent_calls |
if (\$agent_calls | length) == 0 then empty
else
[ \$agent_calls[]
| . as \$call
| select(
[\$all[] | . as \$fix
| select(
(\$fix.match.toolCallId == \$call.id)
or (\$fix.match.userMessage != null and (\$fix.match.userMessage | test(\$call.name; \"i\")))
or (\$fix.match.userMessage != null and (\$call.args | length > 0) and (\$call.args | test(\$fix.match.userMessage; \"i\")))
)
] | length == 0
)
| \" orphan: tool_call id=\(.id) name=\(.name) — no matching fixture found\"
] | .[]
end
" "$f" 2>/dev/null || true)
if [[ -n "$orphans" ]]; then
warn "$basename_f: possible orphaned sub-agent references"
echo "$orphans"
local orphan_count
orphan_count=$(echo "$orphans" | wc -l | tr -d ' ')
warnings=$((warnings + orphan_count))
fi
done
echo ""
info "Checked $files files, $fixtures fixtures, $warnings warnings"
if [[ $warnings -eq 0 ]]; then
success "All fixtures valid"
fi
}
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# showcase logs — follow container logs with optional grep filtering
# Sourced by the main dispatcher; do not execute directly.
CMD_LOGS_DESC="Follow container logs with optional filtering"
usage_logs() {
cat <<'HELP'
Usage: showcase logs <slug> [options]
Follow container logs with optional grep filtering.
Options:
--grep <pattern> Filter log output (supports regex, e.g. "fixture|match")
--since <duration> Show logs since duration (e.g. 10m, 1h, 30s)
-n <lines> Show last N lines (default: all)
--no-follow Dump logs and exit (don't follow)
Examples:
showcase logs mastra # follow all logs
showcase logs aimock --grep "fixture|match" # filter for fixture matching
showcase logs mastra --since 5m --no-follow # last 5 minutes, exit
showcase logs aimock -n 100 --grep "404" # last 100 lines with 404s
HELP
}
cmd_logs() {
[[ "${1:-}" == "-h" || "${1:-}" == "--help" ]] && { usage_logs; return 0; }
need_slug "${1:-}"
local slug="$1"; shift
local container
container="$(slug_to_container "$slug")"
local pattern=""
local since=""
local tail=""
local follow=true
while [[ $# -gt 0 ]]; do
case "$1" in
--grep)
[[ -z "${2:-}" ]] && die "--grep requires a pattern argument"
pattern="$2"; shift 2
;;
--since)
[[ -z "${2:-}" ]] && die "--since requires a duration argument"
since="$2"; shift 2
;;
-n)
[[ -z "${2:-}" ]] && die "-n requires a number argument"
tail="$2"; shift 2
;;
--no-follow)
follow=false; shift
;;
*)
die "Unknown option: $1"
;;
esac
done
# When --grep is used, we switch to docker logs (not compose logs)
# to avoid noisy service-name prefixes and get full history.
if [[ -n "$pattern" ]]; then
_logs_with_grep "$container" "$pattern" "$since" "$tail" "$follow"
else
_logs_plain "$slug" "$since" "$tail" "$follow"
fi
}
# Plain logs via docker compose (no grep filtering)
_logs_plain() {
local slug="$1" since="$2" tail="$3" follow="$4"
local -a args=()
if [[ "$follow" == true ]]; then
args+=("-f")
fi
if [[ -n "$since" ]]; then
args+=("--since" "$since")
fi
if [[ -n "$tail" ]]; then
args+=("--tail" "$tail")
fi
docker compose -f "$COMPOSE_FILE" logs "${args[@]}" "$slug"
}
# Grep-filtered logs via docker logs (direct container)
_logs_with_grep() {
local container="$1" pattern="$2" since="$3" tail="$4" follow="$5"
local -a args=()
if [[ "$follow" == true ]]; then
args+=("-f")
fi
if [[ -n "$since" ]]; then
args+=("--since" "$since")
fi
if [[ -n "$tail" ]]; then
args+=("--tail" "$tail")
fi
if [[ "$follow" == true ]]; then
# Follow mode: --line-buffered is critical so grep flushes immediately
docker logs "${args[@]}" "$container" 2>&1 \
| grep --line-buffered --color=auto -E "$pattern"
else
docker logs "${args[@]}" "$container" 2>&1 \
| grep --color=auto -E "$pattern"
fi
}
+434
View File
@@ -0,0 +1,434 @@
#!/usr/bin/env bash
# showcase reap — tear down leaked/forgotten isolated stacks and orphaned state.
# Sourced by the main dispatcher; do not execute directly.
#
# Background. The isolation harness reserves a slot + a uniquely-named compose
# project (showcase-iso<N>, or a user-supplied --isolate <name>) per run, with
# per-run scratch under runs/<project> and a slot record under slots/<N>. A
# clean exit reaps everything; a --keep'd run (or a crash) deliberately leaves
# the stack standing. Over time those forgotten stacks accumulate — running
# containers, named volumes, slot dirs, run dirs — with no single tool to list
# or sweep them. `showcase reap` is that tool.
#
# Safety is the whole point of this command, so it errs hard toward NOT
# destroying anything by surprise:
# * DRY-RUN BY DEFAULT — `showcase reap` with no flags prints the full plan
# and changes nothing. Teardown happens only with --force (alias --yes,-f).
# * NEVER touches the base `showcase` project (the live default stack — its
# --volumes teardown would destroy PocketBase data) or BuildKit builder
# resources (buildx_buildkit_* / *_buildx). These exclusions apply to the
# dry-run/--all "unidentified — review manually" listing too, not only to
# teardown.
# * Per-project teardown reuses _reap_isolate_slot where a slot record exists
# (so the charset/path-traversal/reserved-name guards there are honored),
# and otherwise mirrors its exact teardown: compose -p <name> down
# --remove-orphans --volumes, then rm -rf runs/<name>.
CMD_REAP_DESC="Tear down leaked/forgotten isolated stacks"
usage_reap() {
cat <<'HELP'
Usage: showcase reap [<name|slot>] [options]
Tear down leaked/forgotten isolated showcase stacks (running containers, named
volumes, slot dirs, run dirs) left behind by --keep'd or crashed runs.
DRY-RUN BY DEFAULT: with no --force, `reap` only prints the plan and changes
nothing (exit 0). Pass --force to actually tear things down.
Targets (no positional arg = sweep all harness-owned isolated state):
<name|slot> Reap exactly one named compose project, or the project recorded
for one slot number. Requires --force to execute.
Options:
-f, --force Execute the teardown (alias: --yes). Reaps stale/orphaned state
plus kept stacks past their keep-TTL; PRESERVES kept stacks
within TTL and any project with a live owner.
--yes Alias of --force.
--all With --force, reap EVERY harness-owned isolated project
regardless of age/keep state (the "tear down everything
isolated now" escape hatch). Never touches base `showcase` or
BuildKit. Projects not identifiable as harness-owned are listed
"unidentified — review manually" and left alone. PRESERVES
projects with a live owner unless --include-live is also given.
--include-live
Opt in to reaping projects classified `live` (an actively-owned,
in-use stack). Without it, a live-owner project is PRESERVED and
a loud warning is emitted even when named as an explicit target
or swept by --all. Use this only when you intend to tear down a
stack that is still in active use.
--json Emit one JSON object per project (JSONL), instead of the table.
-h, --help Show this help.
Never touches the base `showcase` stack or BuildKit (buildx_buildkit_* /
*_buildx) resources under any flag.
HELP
}
# A project name is reapable-safe only if it passes the same guard
# _reap_isolate_slot enforces (compose charset) AND is neither the reserved
# base `showcase` project nor a BuildKit builder resource. The buildkit/buildx
# exclusion is belt-and-suspenders: those names never appear in our slot
# records, but a label/docker scan could surface them, and they must never be
# torn down OR listed as candidates.
_reap_name_safe() {
local name="$1"
[ -n "$name" ] || return 1
[ "$name" = "showcase" ] && return 1
case "$name" in
*buildkit*|*buildx*) return 1 ;;
esac
[[ "$name" =~ ^[a-z0-9][a-z0-9_-]*$ ]] || return 1
return 0
}
# Resolve the slot number whose `project` record names <proj>, or empty if no
# slot records it. Used to annotate the plan and to route teardown through
# _reap_isolate_slot (which removes the slot dir too).
_reap_slot_for_project() {
local proj="$1" n entry
for entry in "$ISOLATE_SLOT_DIR"/[0-9]*; do
[ -d "$entry" ] || continue
n="$(basename "$entry")"
[[ "$n" =~ ^[0-9]+$ ]] || continue
[ "$(cat "$entry/project" 2>/dev/null || true)" = "$proj" ] && { printf '%s' "$n"; return 0; }
done
return 0
}
# Count RUNNING+stopped containers for a compose project (so the plan reflects
# what teardown will actually remove, not just what is up right now). Empty
# docker → 0. Counts non-empty lines via a while-read loop (portable, and never
# emits the stray whitespace `grep -c` can on some platforms — the result is
# compared numerically by the caller).
_reap_container_count() {
local proj="$1" id count=0
while IFS= read -r id; do
[ -n "$id" ] && count=$((count + 1))
done < <(docker ps -a --filter "label=com.docker.compose.project=$proj" -q 2>/dev/null)
printf '%s' "$count"
}
# Count named volumes for a compose project (see _reap_container_count).
_reap_volume_count() {
local proj="$1" v count=0
while IFS= read -r v; do
[ -n "$v" ] && count=$((count + 1))
done < <(docker volume ls --filter "label=com.docker.compose.project=$proj" -q 2>/dev/null)
printf '%s' "$count"
}
# Print the de-duplicated identification UNION of harness-owned isolated
# project names, one per line. Sources (any one suffices):
# 1. slots/<N>/project records — the canonical registry.
# 2. runs/<name> scratch dirs — orphaned run dirs whose slot is gone.
# 3. Docker compose-project label scan, kept when the name is showcase-iso<N>,
# already in a slot record / run dir, OR carries our self-id label
# com.copilotkit.showcase.isolate=1 (the ONLY signal that catches a
# user-supplied --isolate <name> orphan whose slot record was lost).
# The base `showcase` project and BuildKit resources are filtered here so they
# can never enter the candidate set. Names failing the compose charset guard
# are dropped (a corrupt record can't drive a teardown).
_reap_identify() {
local -a names=()
local n entry proj
# 1. slot records
for entry in "$ISOLATE_SLOT_DIR"/[0-9]*; do
[ -d "$entry" ] || continue
n="$(basename "$entry")"
[[ "$n" =~ ^[0-9]+$ ]] || continue
proj="$(cat "$entry/project" 2>/dev/null || true)"
[ -n "$proj" ] && names+=("$proj")
done
# 2. orphaned run dirs
local runs_base
runs_base="$(_showcase_state_base)/runs"
if [ -d "$runs_base" ]; then
for entry in "$runs_base"/*; do
[ -d "$entry" ] || continue
names+=("$(basename "$entry")")
done
fi
# 3. docker scans (compose-project label + our self-id label). Both are
# best-effort: a docker failure leaves the registry/run-dir sources intact.
if command -v docker >/dev/null 2>&1; then
# Self-id label scan: every project carrying com.copilotkit.showcase.isolate=1
# is harness-owned by construction, so it is always a candidate.
while IFS= read -r proj; do
[ -n "$proj" ] && names+=("$proj")
done < <(docker ps -a \
--filter "label=com.copilotkit.showcase.isolate=1" \
--format '{{.Label "com.docker.compose.project"}}' 2>/dev/null | sort -u)
# Generic compose-project scan: keep showcase-iso<N>, or any name already
# known from a slot record / run dir (a name not matching either pattern is
# left for the --all "unidentified" listing, NOT auto-reaped).
local existing
existing="$(printf '%s\n' "${names[@]+"${names[@]}"}")"
while IFS= read -r proj; do
[ -n "$proj" ] || continue
if [[ "$proj" =~ ^showcase-iso[0-9]+$ ]] || printf '%s\n' "$existing" | grep -qxF "$proj"; then
names+=("$proj")
fi
done < <(docker ps -a \
--filter "label=com.docker.compose.project" \
--format '{{.Label "com.docker.compose.project"}}' 2>/dev/null | sort -u)
fi
# De-dupe + apply the hard safety filter (base showcase / buildkit / charset).
printf '%s\n' "${names[@]+"${names[@]}"}" | sort -u | while IFS= read -r proj; do
_reap_name_safe "$proj" && printf '%s\n' "$proj"
done
}
# Print every running/stopped compose project Docker knows about that is NOT in
# our identification union and is NOT base showcase / BuildKit — the
# "unidentified — review manually" set surfaced by --all and the dry-run plan.
_reap_unidentified() {
command -v docker >/dev/null 2>&1 || return 0
local identified="$1" proj
while IFS= read -r proj; do
[ -n "$proj" ] || continue
[ "$proj" = "showcase" ] && continue
case "$proj" in *buildkit*|*buildx*) continue ;; esac
printf '%s\n' "$identified" | grep -qxF "$proj" && continue
printf '%s\n' "$proj"
done < <(docker ps -a \
--filter "label=com.docker.compose.project" \
--format '{{.Label "com.docker.compose.project"}}' 2>/dev/null | sort -u)
}
# Tear down ONE identified project. Routes through _reap_isolate_slot when a
# slot records it (removes the slot dir + run dir + compose state in the
# guarded order); otherwise mirrors that exact teardown for a record-less
# orphan (compose down --volumes, then rm -rf runs/<name>). The caller has
# already confirmed _reap_name_safe.
_reap_teardown_project() {
local proj="$1" slot
slot="$(_reap_slot_for_project "$proj")"
if [ -n "$slot" ]; then
_reap_isolate_slot "$ISOLATE_SLOT_DIR/$slot" "$proj"
else
docker compose -p "$proj" down --remove-orphans --volumes >/dev/null 2>&1 || true
rm -rf "$(_showcase_state_base)/runs/$proj" 2>/dev/null || true
fi
}
cmd_reap() {
local opt_force=false
local opt_all=false
local opt_json=false
local opt_include_live=false
local target=""
while [[ $# -gt 0 ]]; do
case "$1" in
-f|--force|--yes) opt_force=true; shift ;;
--all) opt_all=true; shift ;;
--include-live) opt_include_live=true; shift ;;
--json) opt_json=true; shift ;;
-h|--help) usage_reap; return 0 ;;
-*) die "Unknown flag: $1 (see 'showcase reap --help')" ;;
*)
[ -n "$target" ] && die "Only one <name|slot> target may be given (see 'showcase reap --help')"
target="$1"; shift ;;
esac
done
if $opt_all && [ -n "$target" ]; then
die "--all and a <name|slot> target are mutually exclusive (see 'showcase reap --help')"
fi
# Build the candidate list of (project, classification) pairs.
# classification ∈ live | kept | stale | orphaned-rundir | inconclusive
# plus the per-project slot/rundir/container/volume facts.
local -a all_projects=()
while IFS= read -r proj; do
[ -n "$proj" ] && all_projects+=("$proj")
done < <(_reap_identify)
# The FULL harness-owned identification union — captured BEFORE any
# single-target narrowing below. The "unidentified — review manually" listing
# must always be computed against this complete set, NEVER the narrowed
# single-target set; otherwise every OTHER harness-owned iso project would be
# falsely surfaced as "NOT harness-owned" in single-target mode.
local full_identified_list
full_identified_list="$(printf '%s\n' "${all_projects[@]+"${all_projects[@]}"}")"
# A single named/slot target narrows the candidate set to exactly that one
# project (resolving a numeric slot to its recorded project first).
if [ -n "$target" ]; then
local resolved="$target"
if [[ "$target" =~ ^[0-9]+$ ]]; then
resolved="$(cat "$ISOLATE_SLOT_DIR/$target/project" 2>/dev/null || true)"
[ -n "$resolved" ] || die "Slot $target has no recorded project to reap"
fi
_reap_name_safe "$resolved" \
|| die "Refusing to reap '$resolved' — it is the base 'showcase' stack, a BuildKit resource, or an invalid project name"
all_projects=("$resolved")
fi
# ── Classify every candidate ────────────────────────────────────────────────
# Parallel arrays (bash 3 compatible — no associative arrays).
local -a p_name=() p_class=() p_slot=() p_rundir=() p_containers=() p_volumes=() p_reap=()
# Live-owner projects we deliberately PRESERVE (no --include-live). Collected
# so a single loud warning naming them is emitted, regardless of selection.
local -a live_preserved=()
local proj slot class rundir ncont nvol reap runs_base
runs_base="$(_showcase_state_base)/runs"
for proj in "${all_projects[@]+"${all_projects[@]}"}"; do
slot="$(_reap_slot_for_project "$proj")"
ncont="$(_reap_container_count "$proj")"; ncont="${ncont:-0}"
nvol="$(_reap_volume_count "$proj")"; nvol="${nvol:-0}"
if [ -n "$slot" ]; then
class="$(_slot_liveness "$slot")"
elif [ -d "$runs_base/$proj" ] || [ "$ncont" -gt 0 ] || [ "$nvol" -gt 0 ]; then
# Record-less orphan: a leftover run dir and/or stray containers/volumes
# with no slot. There is no owner/TTL state to consult, so it is reapable
# as a plain orphan (reaped under --force; --all reaps it too).
class="orphaned-rundir"
else
class="inconclusive"
fi
rundir="-"; [ -d "$runs_base/$proj" ] && rundir="$runs_base/$proj"
# Reapability decision:
# --all → reap every identified project (TTL/keep ignored) EXCEPT a
# live-owner stack, which is PRESERVED unless --include-live.
# --force → reap stale + orphaned-rundir; PRESERVE live + kept (kept
# is reaped only once the kept-slot TTL flips it to stale, at
# which point _slot_liveness already returns 'stale' here).
# <target> → an explicit single target is reaped regardless of class
# (the user named it); base/buildkit already excluded above.
# BUT a `live` target — an actively-owned, in-use stack — is
# NOT torn down silently: it is PRESERVED with a loud warning
# unless the operator opts in with --include-live. This
# honors the subcommand's safety stance (PRESERVES any
# project with a live owner).
# A class 'inconclusive' project (no slot, no run dir, zero containers AND
# zero volumes) is NEVER reaped: teardown would remove nothing, so counting
# it would report a phantom "Reaped 1" — even for a named target that simply
# does not exist.
reap=false
if [ "$class" = "live" ] && ! $opt_include_live; then
# Actively-owned, in-use stack — never reaped without an explicit
# --include-live opt-in, no matter how it was selected (named target or
# --all). Record it so a single loud warning is emitted before teardown.
reap=false
live_preserved+=("$proj")
elif [ "$class" = "inconclusive" ]; then
reap=false
elif [ -n "$target" ]; then
reap=true
elif $opt_all; then
reap=true
else
case "$class" in
stale|orphaned-rundir) reap=true ;;
esac
fi
p_name+=("$proj"); p_class+=("$class"); p_slot+=("${slot:--}")
p_rundir+=("$rundir"); p_containers+=("$ncont"); p_volumes+=("$nvol"); p_reap+=("$reap")
done
# ── Live-owner preservation warning ──────────────────────────────────────────
# Any project classified `live` (an actively-owned, in-use stack) is PRESERVED
# — including one named as an explicit target or swept by --all — unless the
# operator passed --include-live. Emit a single loud warning naming each one so
# the operator knows why their target/sweep left it standing (the JSON branch
# already carries this as classification:"live" with reap:false).
if ! $opt_json && [ "${#live_preserved[@]}" -gt 0 ]; then
for proj in "${live_preserved[@]}"; do
warn "Preserving '$proj' — it has a live owner (actively in use). Pass --include-live to reap it anyway."
done
fi
# ── Output: plan (always) + teardown (only with --force) ─────────────────────
local i n_planned=0
if $opt_json; then
# One JSON object per identified project (mirrors `slots --json`, which
# emits all rows). `reap` marks whether the project is in the teardown plan.
for i in "${!p_name[@]}"; do
[ "${p_reap[$i]}" = "true" ] && n_planned=$((n_planned + 1))
jq -nc \
--arg project "${p_name[$i]}" \
--arg classification "${p_class[$i]}" \
--arg slot "${p_slot[$i]}" \
--arg rundir "${p_rundir[$i]}" \
--argjson containers "${p_containers[$i]}" \
--argjson volumes "${p_volumes[$i]}" \
--argjson reap "${p_reap[$i]}" \
--argjson executed "$($opt_force && echo true || echo false)" \
'{project: $project, classification: $classification, slot: $slot, rundir: $rundir, containers: $containers, volumes: $volumes, reap: $reap, executed: $executed}'
done
else
if [ "${#p_name[@]}" -eq 0 ]; then
info "No harness-owned isolated projects found — nothing to reap."
else
$opt_force || info "DRY-RUN (no --force): printing plan only; nothing will be torn down."
printf '%-28s %-15s %-5s %-5s %-5s %s\n' \
"PROJECT" "CLASS" "SLOT" "CONT" "VOLS" "ACTION"
for i in "${!p_name[@]}"; do
local action="preserve"
if [ "${p_reap[$i]}" = "true" ]; then
action="$($opt_force && echo reap || echo "reap (planned)")"
n_planned=$((n_planned + 1))
fi
printf '%-28s %-15s %-5s %-5s %-5s %s\n' \
"${p_name[$i]}" "${p_class[$i]}" "${p_slot[$i]}" \
"${p_containers[$i]}" "${p_volumes[$i]}" "$action"
done
fi
fi
# ── Unidentified review listing (dry-run plan + --all) ───────────────────────
# Always SHOWN, never torn down — base showcase / buildkit already excluded.
if ! $opt_json; then
# Exclude the FULL harness-owned union (computed before target narrowing) —
# not just the in-plan rows — so a single-target reap never mislabels its
# harness-owned siblings as "NOT harness-owned". Also exclude the explicit
# target itself (it may be a safe-but-unidentified name the user named) so
# it never appears in the review listing while it is being reaped.
local identified_list="$full_identified_list"
[ -n "$target" ] && identified_list="$(printf '%s\n%s\n' "$full_identified_list" "${all_projects[0]:-}")"
local -a unidentified=()
while IFS= read -r proj; do
[ -n "$proj" ] && unidentified+=("$proj")
done < <(_reap_unidentified "$identified_list")
if [ "${#unidentified[@]}" -gt 0 ]; then
echo ""
warn "Unidentified compose projects (NOT harness-owned — review manually, never auto-reaped):"
for proj in "${unidentified[@]}"; do
printf ' %s\n' "$proj"
done
fi
fi
# ── Teardown ─────────────────────────────────────────────────────────────────
if ! $opt_force; then
$opt_json || info "Summary: $n_planned project(s) would be reaped (run with --force to execute)."
return 0
fi
local n_reaped=0
for i in "${!p_name[@]}"; do
[ "${p_reap[$i]}" = "true" ] || continue
_reap_teardown_project "${p_name[$i]}"
n_reaped=$((n_reaped + 1))
done
if ! $opt_json; then
if [ "$n_reaped" -eq 0 ]; then
# Nothing was in the teardown plan — e.g. an explicit target that no
# longer exists (class 'inconclusive'). Report nothing-to-reap rather
# than a misleading "Reaped 0" success.
info "Nothing to reap — no project had containers, volumes, slot, or run dir to tear down."
else
success "Reaped $n_reaped project(s)."
fi
fi
return 0
}
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# showcase recreate — force-recreate services to pick up new images
CMD_RECREATE_DESC="Force-recreate a service (picks up new image)"
usage_recreate() {
cat <<'HELP'
Usage: showcase recreate <slug> [slug...] [options]
Force-recreate service containers to pick up a new Docker image.
Unlike 'restart', this ensures the container uses the latest image.
Options:
--build Rebuild the image before recreating
--no-wait Don't wait for the container to become healthy
Examples:
showcase recreate mastra # recreate with current image
showcase recreate aimock # recreate aimock (uses test compose)
showcase recreate mastra --build # rebuild + recreate
showcase recreate mastra aimock # recreate multiple services
HELP
}
cmd_recreate() {
local slugs=()
local no_wait=0
local build=0
# Parse args: separate slugs from flags
for arg in "$@"; do
case "$arg" in
--no-wait) no_wait=1 ;;
--build) build=1 ;;
-h|--help) usage_recreate; return 0 ;;
-*) die "Unknown option: $arg (see 'showcase recreate --help')" ;;
*) slugs+=("$arg") ;;
esac
done
[ ${#slugs[@]} -gt 0 ] || die "Usage: showcase recreate <slug> [slug...] [--build] [--no-wait]"
for slug in "${slugs[@]}"; do
need_slug "$slug"
# Pick the right compose file: aimock uses the test compose, everything
# else uses the main local compose.
local compose
if [ "$slug" = "aimock" ]; then
compose="$AIMOCK_COMPOSE"
else
compose="$COMPOSE_FILE"
fi
info "Force-recreating showcase-${slug} (picks up new image, unlike restart)..."
if [ "$build" -eq 1 ]; then
trap restore_symlinks EXIT
stage_shared
docker compose -f "$compose" up -d --build --force-recreate "$slug"
else
docker compose -f "$compose" up -d --force-recreate "$slug"
fi
if [ "$no_wait" -eq 0 ]; then
wait_healthy "$slug" 30
fi
# Print the image ID so the user can verify it changed
local container
container="$(slug_to_container "$slug")"
local image_id
image_id="$(docker inspect --format='{{.Image}}' "$container" 2>/dev/null || echo "unknown")"
# Truncate sha256:... to first 12 hex chars
image_id="${image_id#sha256:}"
image_id="${image_id:0:12}"
success "showcase-${slug} recreated — image: ${image_id}"
done
}
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env bash
# showcase slots — show isolation slot status
# Sourced by the main dispatcher; do not execute directly.
CMD_SLOTS_DESC="Show isolation slot status"
usage_slots() {
cat <<'HELP'
Usage: showcase slots [options]
Show the status of all isolation slots (0..ISOLATE_MAX_SLOT).
Options:
--json Emit one JSON object per slot (JSONL format)
--free Filter to slots that are free (unclaimed, no live pid/containers, no held ports)
--reapable Filter to slots that are reapable (liveness == stale)
--brief Output only numeric slot IDs of matching slots, one per line
Flags may be combined: --free --brief → one integer per line for each free slot
--free --json → JSONL for free slots only
--reapable --brief → one integer per line for each reapable slot
Notes:
Slot 0 is reserved for the base (non-isolate) stack and is never reported as free.
Free = dir absent + liveness not live + ports not held.
Reapable = liveness == stale. The LIVE column reads live | kept | stale |
inconclusive: a `kept` slot (a --keep'd stack — running containers whose
owner is gone) is protected, NOT reapable. The PID column annotates a
recorded owner as <pid> (alive), <pid>(reused) (pid recycled), or
<pid>(dead) (owner gone / unverifiable).
HELP
}
cmd_slots() {
local opt_json=false
local opt_free=false
local opt_reapable=false
local opt_brief=false
while [[ $# -gt 0 ]]; do
case "$1" in
--json) opt_json=true; shift ;;
--free) opt_free=true; shift ;;
--reapable) opt_reapable=true; shift ;;
--brief) opt_brief=true; shift ;;
-h|--help)
usage_slots
return 0
;;
-*)
die "Unknown flag: $1 (see 'showcase slots --help')"
;;
*)
die "Unexpected argument: $1 (see 'showcase slots --help')"
;;
esac
done
if $opt_free && $opt_reapable; then
die "--free and --reapable are mutually exclusive (see 'showcase slots --help')"
fi
# Collect all slot records
local -a rows=()
local n
for n in $(seq 0 "$ISOLATE_MAX_SLOT"); do
rows+=("$(_slot_state "$n")")
done
# _row_included <slot> <dir> <liveness> <ports> — apply the active filter.
# Returns 0 (include) when no filter is set, or when the row satisfies the
# active --free / --reapable predicate; 1 (skip) otherwise. Slot 0 (the base
# stack) is excluded from BOTH filters: it is never free, and never reapable.
# --free = dir absent + liveness not live + ports not held
# --reapable = liveness == stale (a `kept` slot is protected, NOT reapable;
# the kept-slot TTL is what flips an over-age kept slot to stale)
_row_included() {
local r_slot="$1" r_dir="$2" r_liveness="$3" r_ports="$4"
if $opt_free; then
[ "$r_slot" = "0" ] && return 1
[ "$r_dir" = "absent" ] && [ "$r_liveness" != "live" ] && [ "$r_ports" != "held" ]
return
fi
if $opt_reapable; then
[ "$r_slot" = "0" ] && return 1
[ "$r_liveness" = "stale" ]
return
fi
return 0
}
# ── Output ────────────────────────────────────────────────────────────────
if $opt_brief; then
# Brief: numeric slot IDs only, one per line
for row in "${rows[@]}"; do
IFS='|' read -r slot dir pid liveness ports offset project <<< "$row"
_row_included "$slot" "$dir" "$liveness" "$ports" || continue
printf '%s\n' "$slot"
done
return 0
fi
if $opt_json; then
# JSONL: one JSON object per slot
for row in "${rows[@]}"; do
IFS='|' read -r slot dir pid liveness ports offset project <<< "$row"
_row_included "$slot" "$dir" "$liveness" "$ports" || continue
# Slot 0 project label
local proj_display="$project"
[ "$slot" = "0" ] && proj_display="showcase (base)"
jq -nc \
--argjson slot "$slot" \
--arg dir "$dir" \
--arg pid "$pid" \
--arg liveness "$liveness" \
--arg ports "$ports" \
--argjson offset "$offset" \
--arg project "$proj_display" \
'{slot: $slot, dir: $dir, pid: $pid, liveness: $liveness, ports: $ports, offset: $offset, project: $project}'
done
return 0
fi
# Default: fixed-width table
# Header: SLOT(4) DIR(7) PID(11) LIVE(12) PORTS(6) OFFSET(6) PROJECT(remainder)
# PID is 11 wide to fit the annotated forms (e.g. "79371(reused)").
printf '%-4s %-7s %-11s %-12s %-6s %-6s %s\n' \
"SLOT" "DIR" "PID" "LIVE" "PORTS" "OFFSET" "PROJECT"
for row in "${rows[@]}"; do
IFS='|' read -r slot dir pid liveness ports offset project <<< "$row"
_row_included "$slot" "$dir" "$liveness" "$ports" || continue
# Slot 0 project label
local proj_display="$project"
[ "$slot" = "0" ] && proj_display="showcase (base)"
printf '%-4s %-7s %-11s %-12s %-6s %-6s %s\n' \
"$slot" "$dir" "$pid" "$liveness" "$ports" "+$offset" "$proj_display"
done
return 0
}
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env bash
# showcase test — run probe tests against a showcase service
# Sourced by the main dispatcher; do not execute directly.
CMD_TEST_DESC="Run probe tests against a service"
usage_test() {
cat <<'HELP'
Usage: showcase test <slug> [options]
Run probe tests against a showcase service (via Docker containers).
Options:
--d6 Run D6 (e2e-full) probes only (via fleet control-plane)
--d5 Run D5 (e2e-deep) probes only (via fleet control-plane)
--d4 Run D4 probes only
--direct Legacy/debug: run d5/d6 via the in-process driver
instead of the fleet control-plane (producer->queue->worker)
--smoke Run smoke probes only
--verbose Verbose test output
--headed Run Playwright in headed (visible) mode
--repeat <n> Run N times
--keep Don't stop auto-started packages after test; with --isolate,
also leaves the isolated stack standing (teardown command
printed at exit). A kept stack left running with no owner is
auto-reaped after its keep TTL (default 4h); run
'showcase reap' to tear it down sooner.
--live Write results to PocketBase for dashboard
--rebuild Force Docker rebuild before running
--cycle On failure, auto-dump aimock logs from the test window
--isolate [name] Run in an isolated compose project with offset ports
(default name: showcase-iso<slot>). Allows parallel test runs.
The optional name may appear before OR after the <slug>.
--isolate=<N> Sugar form: pin the isolation slot to N (equivalent to
prefixing SHOWCASE_ISO_SLOT=<N>). 1≤N≤ISOLATE_MAX_SLOT.
--isolate=<name> Sugar form: explicit isolate name (non-numeric), equivalent
to '--isolate <name>'. A bare '--isolate=' is rejected.
Examples:
showcase test mastra --d6 --verbose # D6 probes (full matrix) with verbose output
showcase test mastra --d5 --verbose # D5 probes with verbose output
showcase test mastra --d5 --cycle # D5 + aimock logs on failure
showcase test langgraph-python # all tests for a slug
showcase test mastra --d5 --headed # watch the browser
showcase test agno --d5 --isolate # isolated run (auto-named)
showcase test agno --d5 --isolate d5verify # isolated with explicit name
showcase test agno --d5 --isolate=9 # pin to slot 9 (equiv: SHOWCASE_ISO_SLOT=9 ... --isolate)
HELP
}
cmd_test() {
local slug=""
local cycle=""
local isolate_name=""
local use_isolate=false
local harness_args=()
# Pending `--isolate <token>` name candidate. The space-separated
# `--isolate <name>` form is order-ambiguous when the slug is not yet known:
# `--isolate mastra` could mean "isolate the slug 'mastra' (auto-named)" OR be
# the start of "--isolate <name> <slug>". We defer the decision: stash the
# token here, and resolve it once parsing finishes. If a positional slug also
# appears, the stash was the explicit isolate NAME; if no slug appears, the
# stash WAS the slug (auto-named isolation). See the post-loop resolution.
local pending_iso_name=""
local have_pending_iso_name=false
# Parse arguments — pass most through to the harness CLI
while [[ $# -gt 0 ]]; do
case "$1" in
--d6) harness_args+=(--d6); shift ;;
--d5) harness_args+=(--d5); shift ;;
--d4) harness_args+=(--d4); shift ;;
--smoke) harness_args+=(--smoke); shift ;;
--verbose) harness_args+=(--verbose); shift ;;
--headed) harness_args+=(--headed); shift ;;
--keep) ISOLATE_KEEP=true; harness_args+=(--keep); shift ;;
--live) harness_args+=(--live); shift ;;
--rebuild) harness_args+=(--rebuild); shift ;;
--direct) harness_args+=(--direct); shift ;;
--cycle) cycle=1; shift ;;
--isolate)
use_isolate=true
shift
# Optional name argument: `--isolate [name]`. Consume the next token as
# the isolate name candidate when it is a plain word (not a flag).
# - slug already set → this token is unambiguously the NAME.
# - slug NOT set yet → AMBIGUOUS (could be the name with a slug still
# to come, or the slug itself for an auto-named run). Defer via the
# pending-name stash; the post-loop resolution decides based on
# whether a positional slug also turns up.
# A following flag (or nothing) means no explicit name → auto-named.
if [[ $# -gt 0 ]] && [[ "$1" != --* ]]; then
if [[ -n "$slug" ]]; then
isolate_name="$1"
else
pending_iso_name="$1"
have_pending_iso_name=true
fi
shift
fi
;;
--isolate=*)
# Sugar form. Two shapes share this branch:
# --isolate=<N> pins the slot by exporting SHOWCASE_ISO_SLOT; the
# picker (_claim_isolate_slot in _common.sh) owns ALL
# validation (positive int, 1≤N≤ISOLATE_MAX_SLOT,
# slot 0 reserved, port probe).
# --isolate=<name> an explicit isolate name (non-numeric), bound here.
# A bare `--isolate=` (empty value) is rejected LOUDLY: left unguarded it
# exports an empty SHOWCASE_ISO_SLOT that fails the picker's `-n` test and
# silently falls through to auto-pick, bypassing the pinned-path checks.
use_isolate=true
local iso_val="${1#--isolate=}"
if [[ -z "$iso_val" ]]; then
die "--isolate= requires a value (slot number or name); got an empty value (see 'showcase test --help')"
elif [[ "$iso_val" =~ ^[0-9]+$ ]]; then
SHOWCASE_ISO_SLOT="$iso_val"
export SHOWCASE_ISO_SLOT
else
isolate_name="$iso_val"
fi
shift
;;
--repeat)
shift
harness_args+=(--repeat "${1:?--repeat requires a value}")
shift
;;
-h|--help)
usage_test
return 0
;;
-*)
die "Unknown option: $1 (see 'showcase test --help')"
;;
*)
if [[ -z "$slug" ]]; then
slug="$1"
else
die "Unexpected argument: $1"
fi
shift
;;
esac
done
# Resolve the deferred `--isolate <token>` name candidate now that all
# positionals are known (see pending_iso_name above):
# - slug present → the pending token was the explicit isolate NAME.
# - slug absent → the pending token WAS the slug (auto-named isolation).
# This is what lets BOTH `--isolate <name> <slug>` (name first) and the
# auto-named `--isolate <slug>` parse correctly from the same ambiguous token.
if $have_pending_iso_name; then
if [[ -n "$slug" ]]; then
isolate_name="$pending_iso_name"
else
slug="$pending_iso_name"
fi
fi
need_slug "$slug"
# Apply isolation if requested (must happen before any compose commands).
# Register the trap BEFORE apply_isolation so cleanup runs even if the
# function itself crashes partway through. restore_isolation reads the
# ISOLATE_KEEP global (set above when --keep is parsed). It MUST be a global,
# not a local: on the normal path cmd_test returns and its locals unwind
# before the EXIT trap fires at top-level script exit, so a function-local
# flag would silently read as false there (a local is only visible to the
# trap when `die` exits from inside cmd_test itself). Under --keep,
# restore_isolation leaves the stack standing and prints a survival notice
# instead of tearing down, so the slot's live containers keep it from being
# reaped.
if $use_isolate; then
trap restore_isolation EXIT
apply_isolation "${isolate_name:-}" "$slug"
if $ISOLATE_KEEP; then
info "--keep set: isolated stack will be left standing after the run (teardown command printed at exit)"
fi
fi
# Build the filter description for the info line
local filter_desc=""
for arg in ${harness_args[@]+"${harness_args[@]}"}; do
case "$arg" in
--d6|--d5|--d4|--smoke) filter_desc="${filter_desc:+$filter_desc,}$arg" ;;
esac
done
# If --cycle, record aimock log position before the test
local pre_test_ts=""
local aimock_container
if $use_isolate && [[ -n "$ISOLATE_NAME" ]]; then
aimock_container="${ISOLATE_NAME}-aimock"
else
aimock_container="showcase-aimock"
fi
if [[ -n "$cycle" ]]; then
if docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^${aimock_container}$"; then
pre_test_ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
else
warn "aimock container '$aimock_container' not running; --cycle log capture disabled"
fi
fi
info "Testing $slug${filter_desc:+ ($filter_desc)}..."
date -u +%Y-%m-%dT%H:%M:%SZ > "$SHOWCASE_ROOT/.last-test-ts"
local test_exit=0
npx tsx "$SHOWCASE_ROOT/harness/src/cli.ts" test "$slug" ${harness_args[@]+"${harness_args[@]}"} \
|| test_exit=$?
# --cycle: dump aimock log delta on failure
if [[ $test_exit -ne 0 ]] && [[ -n "$cycle" ]] && [[ -n "$pre_test_ts" ]]; then
echo ""
echo "═══ aimock logs since test start ($pre_test_ts) ═══"
docker logs --since "$pre_test_ts" "$aimock_container" 2>&1
echo "═══════════════════════════════════════════════════"
fi
# Report result
if [[ $test_exit -eq 0 ]]; then
success "Tests passed for $slug"
else
warn "Tests failed for $slug (exit $test_exit)"
fi
return $test_exit
}